diff --git a/.gitignore b/.gitignore index 43233e4..83d850b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__ .idea/ +custom_components/hubspace/dump_*.json diff --git a/README.md b/README.md index c18f85b..aac5f03 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ the following setup steps must be run: ``` * Download requirements * ```sh - python -m pip install requests "hubspace_async>=0.0.5" click + python -m pip install requests "hubspace_async>=0.0.5" asyncclick ``` * Goto your home directory * Unix diff --git a/custom_components/hubspace/anonomyize_data.py b/custom_components/hubspace/anonomyize_data.py index e8abd61..4ada3e8 100644 --- a/custom_components/hubspace/anonomyize_data.py +++ b/custom_components/hubspace/anonomyize_data.py @@ -1,9 +1,12 @@ -import asyncio +__all__ = ["generate_anon_data", "generate_raw_data"] + + import json import logging from dataclasses import asdict from uuid import uuid4 +import aiofiles from hubspace_async import HubSpaceConnection, HubSpaceDevice, HubSpaceState logger = logging.getLogger(__name__) @@ -12,32 +15,63 @@ FNAME_IND = 0 -async def generate_anon_data(conn: HubSpaceConnection): +async def generate_anon_data( + conn: HubSpaceConnection, + anon_name: bool = False, +) -> dict: devices = await conn.devices fake_devices = [] - parents = {} + parents = await generate_parent_mapping(devices) + device_links = {} for dev in devices.values(): - fake_devices.append(anonymize_device(dev, parents)) + fake_devices.append( + await anonymize_device(dev, parents, device_links, anon_name) + ) return fake_devices -def get_devices(conn: HubSpaceConnection) -> dict: - loop = asyncio.get_event_loop() - loop.run_until_complete(conn.populate_data()) +async def generate_raw_data(conn: HubSpaceConnection) -> dict: + devices = await conn.devices + fake_devs = [] + for dev in devices.values(): + fake_dev = asdict(dev) + fake_dev["states"] = [] + for state in dev.states: + fake_dev["states"].append(await anonymize_state(state, only_geo=True)) + fake_devs.append(fake_dev) + return fake_devs + + +async def generate_parent_mapping(devices: list[HubSpaceDevice]) -> dict: + mapping = {} + for device in devices.values(): + if device.children: + device.id = str(uuid4()) + new_children = [] + for child_id in device.children: + new_uuid = str(uuid4()) + mapping[child_id] = {"parent": device.id, "new": new_uuid} + new_children.append(new_uuid) + device.children = new_children + return mapping + + +async def get_devices(conn: HubSpaceConnection) -> dict: + await conn.populate_data() return conn._devices -def generate_name(dev: HubSpaceDevice) -> str: +async def generate_name(dev: HubSpaceDevice) -> str: return f"{dev.device_class}-{dev.model}" -def output_devices(conn: HubSpaceConnection): +async def output_devices(conn: HubSpaceConnection): """Output all devices associated with the Account This should only be run when testing as it modified the log level. """ - devices = get_devices(conn) + devices = await get_devices(conn) for child_id in devices: dev = devices[child_id] logger.info("Friendly Name: %s", dev.friendly_name) @@ -46,8 +80,11 @@ def output_devices(conn: HubSpaceConnection): logger.info("\tDevice Class: %s", dev.device_class) -def anonymize_device_lookup( - conn: HubSpaceConnection, friendly_name: str = None, child_id: str = None +async def anonymize_device_lookup( + conn: HubSpaceConnection, + friendly_name: str = None, + child_id: str = None, + anon_name: bool = False, ): """Output anonymized device data for a device @@ -56,7 +93,7 @@ def anonymize_device_lookup( three entries, one of the fan, one of the light, and one for the overall. """ - devices = get_devices(conn) + devices = await get_devices(conn) matched_dev = None related = [] # Find the device match @@ -77,77 +114,89 @@ def anonymize_device_lookup( if dev.device_id == matched_dev.device_id: related.append(dev) anon_data = [] - mapping = {} + parents = await generate_parent_mapping(devices) + device_links = {} for dev in related: - anon_data.append(anonymize_device(dev, mapping)) - identifier = f"{generate_name(related[0])}.json" - with open(identifier, "w") as f: - json.dump(anon_data, f, indent=4) + anon_data.append(await anonymize_device(dev, parents, device_links, anon_name)) + identifier = f"dump_{await generate_name(related[0])}.json" + async with aiofiles.open(identifier, "w") as f: + await f.write(json.dumps(anon_data, indent=4)) return anon_data -def anonymize_device(dev: HubSpaceDevice, parent_mapping: dict): +async def anonymize_device( + dev: HubSpaceDevice, + parent_mapping: dict, + device_links: dict, + anon_name, +): fake_dev = asdict(dev) - global FNAME_IND - # Modify the name - fake_dev["friendly_name"] = f"friendly-device-{FNAME_IND}" - FNAME_IND += 1 + if anon_name: + global FNAME_IND + # Modify the name + fake_dev["friendly_name"] = f"friendly-device-{FNAME_IND}" + FNAME_IND += 1 # Modify the id - fake_dev["id"] = str(uuid4()) - # Remove parent id - parent = dev.device_id - if parent not in parent_mapping: - parent_mapping[parent] = str(uuid4()) - fake_dev["device_id"] = parent_mapping[parent] + if dev.id in parent_mapping: + fake_dev["id"] = parent_mapping[dev.id]["new"] + else: + fake_dev["id"] = str(uuid4()) + # Generate a custom device_id link + dev_link = dev.device_id + if dev_link not in device_links: + device_links[dev_link] = str(uuid4()) + fake_dev["device_id"] = device_links[dev_link] fake_dev["states"] = [] for state in dev.states: - fake_dev["states"].append(anonymize_state(state)) + fake_dev["states"].append(await anonymize_state(state)) return fake_dev -def anonymize_state(state: HubSpaceState): +async def anonymize_state(state: HubSpaceState, only_geo: bool = False): fake_state = asdict(state) fake_state["lastUpdateTime"] = 0 - if fake_state["functionClass"] == "wifi-ssid": - fake_state["value"] = str(uuid4()) - elif fake_state["functionClass"] == "geo-coordinates": + if fake_state["functionClass"] == "geo-coordinates": fake_state["value"] = {"geo-coordinates": {"latitude": "0", "longitude": "0"}} - elif isinstance(state.value, str): - if "mac" in state.functionClass: + elif not only_geo: + if fake_state["functionClass"] == "wifi-ssid": fake_state["value"] = str(uuid4()) + elif isinstance(state.value, str): + if "mac" in state.functionClass: + fake_state["value"] = str(uuid4()) return fake_state -def get_states( +async def get_states( conn: HubSpaceConnection, friendly_name: str = None, child_id: str = None ): """Get all states associated to a specific ID""" states = [] - devices = get_devices(conn) + devices = await get_devices(conn) for dev in devices.values(): if friendly_name and dev.friendly_name != friendly_name: continue if child_id and dev.id != child_id: continue for state in dev.states: - states.append(anonymize_state(state)) - with open(f"{generate_name(dev)}.json", "w") as f: - json.dump(states, f, indent=4) + states.append(await anonymize_state(state)) + async with aiofiles.open(f"dump_{await generate_name(dev)}.json", "w") as f: + await f.write(json.dumps(states, indent=4)) return states try: - import click + import asyncclick as click import hubspace_async user = click.option("--username", required=True, help="HubSpace Username") pwd = click.option("--password", required=True, help="HubSpace password") + aname = click.option("--anon-name", default=False, help="Anonymize name") @click.group() @user @pwd @click.pass_context - def cli(ctx, username, password): + async def cli(ctx, username, password): logger.setLevel(logging.INFO) logger.addHandler(logging.StreamHandler()) logging.getLogger("asyncio").setLevel(logging.WARNING) @@ -156,40 +205,58 @@ def cli(ctx, username, password): @cli.command() @click.pass_context - def get_devs(ctx): + async def get_devs(ctx): """Output all devices associated with the Account""" - click.echo(output_devices(ctx.obj["conn"])) + click.echo(await output_devices(ctx.obj["conn"])) @cli.command() @click.option("--fn", required=True, help="Friendly Name") + @aname @click.pass_context - def friendly_name(ctx, fn): + async def friendly_name(ctx, fn, anon_name): """Output all devices associated to the name""" - click.echo(anonymize_device_lookup(ctx.obj["conn"], friendly_name=fn)) + click.echo( + await anonymize_device_lookup( + ctx.obj["conn"], friendly_name=fn, anon_name=anon_name + ) + ) @cli.command() @click.option("--child_id", required=True, help="Child ID") + @aname @click.pass_context - def child_id(ctx, child_id): + async def child_id(ctx, child_id, anon_name): """Output all devices associated to the child_id""" - click.echo(anonymize_device_lookup(ctx.obj["conn"], child_id=child_id)) + click.echo( + await anonymize_device_lookup( + ctx.obj["conn"], child_id=child_id, anon_name=anon_name + ) + ) @cli.command() @click.option("--child_id", required=True, help="Child ID") @click.pass_context - def states(ctx, child_id): + async def states(ctx, child_id): """Output all devices associated to the child_id""" - click.echo(get_states(ctx.obj["conn"], child_id=child_id)) + click.echo(await get_states(ctx.obj["conn"], child_id=child_id)) @cli.command() @click.pass_context - def raw_data(ctx): + async def raw_data(ctx): """Get the raw output. This data is not anonymized""" - get_devices(ctx.obj["conn"]) - with open("raw-data.json", "w") as f: - import json + await get_devices(ctx.obj["conn"]) + async with aiofiles.open("dump_raw-data.json", "w") as f: + data = await generate_raw_data(ctx.obj["conn"]) + await f.write(json.dumps(data, indent=4)) - json.dump(ctx.obj["conn"].raw_devices, f, indent=4) + @cli.command() + @aname + @click.pass_context + async def anon_data(ctx, anon_name): + """Get the raw output. This data is not anonymized""" + async with aiofiles.open("dump_anon-data.json", "w") as f: + data = await generate_anon_data(ctx.obj["conn"], anon_name=anon_name) + await f.write(json.dumps(data, indent=4)) except: # noqa pass @@ -198,4 +265,4 @@ def raw_data(ctx): try: cli() except NameError: - logger.warning("Click is not installed") + logger.exception("Click is not installed") diff --git a/custom_components/hubspace/coordinator.py b/custom_components/hubspace/coordinator.py index 4c417d4..dc1915b 100644 --- a/custom_components/hubspace/coordinator.py +++ b/custom_components/hubspace/coordinator.py @@ -108,8 +108,9 @@ async def _async_update_data( await fh.write(json.dumps(data, indent=4)) dev_raw = os.path.join(curr_directory, "_dump_hs_raw.json") _LOGGER.debug("Writing out raw device data to %s", dev_raw) + data = await anonomyize_data.generate_raw_data(self.conn) async with aiofiles.open(dev_raw, "w") as fh: - await fh.write(json.dumps(self.conn.raw_devices, indent=4)) + await fh.write(json.dumps(data, indent=4)) return await self.process_tracked_devices() async def hs_data_update(self) -> None: diff --git a/tests/device_dumps/fan-ZandraFan.json b/tests/device_dumps/fan-ZandraFan.json index ecf6c5e..d8fdeb3 100644 --- a/tests/device_dumps/fan-ZandraFan.json +++ b/tests/device_dumps/fan-ZandraFan.json @@ -328,6 +328,7 @@ ] } ], + "children": [], "states": [ { "functionClass": "toggle", @@ -624,6 +625,7 @@ ] } ], + "children": [], "states": [ { "functionClass": "power", @@ -820,6 +822,10 @@ ] } ], + "children": [ + "066c0e38-c49b-4f60-b805-486dc07cab74", + "3a0c5015-c19d-417f-8e08-e71cd5bc221b" + ], "states": [ { "functionClass": "error-flag",