Skip to content

Commit

Permalink
Update anonymize data (#118)
Browse files Browse the repository at this point in the history
Update anonymize data

 * Ensure geo-coordinates are always anonymized
 * Fix an issue where children would link to the original,
   non-anonymized device
 * Output default names, unless otherwise specified

Sem-Ver: bugfix
  • Loading branch information
Expl0dingBanana authored Dec 13, 2024
1 parent 898ca7b commit 77b0038
Show file tree
Hide file tree
Showing 5 changed files with 134 additions and 59 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
__pycache__
.idea/
custom_components/hubspace/dump_*.json
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
181 changes: 124 additions & 57 deletions custom_components/hubspace/anonomyize_data.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -198,4 +265,4 @@ def raw_data(ctx):
try:
cli()
except NameError:
logger.warning("Click is not installed")
logger.exception("Click is not installed")
3 changes: 2 additions & 1 deletion custom_components/hubspace/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions tests/device_dumps/fan-ZandraFan.json
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@
]
}
],
"children": [],
"states": [
{
"functionClass": "toggle",
Expand Down Expand Up @@ -624,6 +625,7 @@
]
}
],
"children": [],
"states": [
{
"functionClass": "power",
Expand Down Expand Up @@ -820,6 +822,10 @@
]
}
],
"children": [
"066c0e38-c49b-4f60-b805-486dc07cab74",
"3a0c5015-c19d-417f-8e08-e71cd5bc221b"
],
"states": [
{
"functionClass": "error-flag",
Expand Down

0 comments on commit 77b0038

Please sign in to comment.