Skip to content

Commit

Permalink
feat: Set NotImplemented from device schema. (#31)
Browse files Browse the repository at this point in the history
* Draft test example.

For coverage on all of the attribute setters and the translation from
info dict to the attributes.

* comment about plans on senml parsing.

* linter fixes.

* Add tests for H35i and T10i.

* lint fixes.

* Update golden for H35i

* Add a pytest workflow.

* rename the workflow.

Apparently I cannot trigger it. I think it is also missing a pip install
-e .

* add installation step.

* Add a readme for the golden data generation.

* add Intermediate representation of AWS devices.

- documenting reverse engineering the fields in the device configuration json.

-add senml sensor pack parsing.

* add tests.

* Use SensorPack in device_aws.

* Add a query_json helper to the ir.

* Parse the device IR in refresh.

* ruff.

* Add NotImplemented detection

If a sensor is not implemented by the device, then DeviceAws sets it
to NotImplemented.
If a sensor is implemented but its value is unavailable, sets it to None.
(the second case, is for example when H35i's fan is in standby, then h
and t are unavaiable)

* use the new type statement.

* Update to py3.12's type parameter syntax.

* Add a few comments.

* Ensure all fields are checked.

* fix typing of running.

* Add comment about specialness of AttributeType.

* Rename ir_aws to intermediate_representation_aws.

Also add the forgotten unit test file for it.

* rename usage to usage_percentage.

* Redefine missing di fields as NotImplemented.

* update pytest workflow.

* Remove the txt version files.

* Comment about the assert helper.
  • Loading branch information
rainwoodman authored Dec 14, 2024
1 parent e0c979b commit e19faf4
Show file tree
Hide file tree
Showing 5 changed files with 570 additions and 162 deletions.
166 changes: 101 additions & 65 deletions src/blueair_api/device_aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
from .callbacks import CallbacksMixin
from .http_aws_blueair import HttpAwsBlueair
from .model_enum import ModelEnum
from .util import convert_api_array_to_dict, safely_get_json_value
from . import intermediate_representation_aws as ir

_LOGGER = logging.getLogger(__name__)

type AttributeType[T] = T | None | type[NotImplemented]

@dataclasses.dataclass(slots=True)
class DeviceAws(CallbacksMixin):
Expand All @@ -28,72 +29,96 @@ async def create_device(cls, api, uuid, name, mac, type_name, refresh=False):
return device_aws

api: HttpAwsBlueair
uuid: str = None
name: str = None
name_api: str = None
mac: str = None
type_name: str = None

sku: str = None
firmware: str = None
mcu_firmware: str = None
serial_number: str = None

brightness: int = None
child_lock: bool = None
fan_speed: int = None
fan_auto_mode: bool = None
running: bool = None
night_mode: bool = None
germ_shield: bool = None

pm1: int = None
pm2_5: int = None
pm10: int = None
tVOC: int = None
temperature: int = None
humidity: int = None
filter_usage: int = None # percentage
wifi_working: bool = None

# i35
wick_usage: int = None # percentage
wick_dry_mode: bool = None
water_shortage: bool = None
auto_regulated_humidity: int = None
uuid : str | None = None
name : str | None = None
name_api : str | None = None
mac : str | None = None
type_name : str | None = None

# Attributes are defined below.
# We mandate that unittests shall test all fields of AttributeType.
sku : AttributeType[str] = None
firmware : AttributeType[str] = None
mcu_firmware : AttributeType[str] = None
serial_number : AttributeType[str] = None

brightness : AttributeType[int] = None
child_lock : AttributeType[bool] = None
fan_speed : AttributeType[int] = None
fan_auto_mode : AttributeType[bool] = None
standby : AttributeType[bool] = None
night_mode : AttributeType[bool] = None
germ_shield : AttributeType[bool] = None

pm1 : AttributeType[int] = None
pm2_5 : AttributeType[int] = None
pm10 : AttributeType[int] = None
tVOC : AttributeType[int] = None
temperature : AttributeType[int] = None
humidity : AttributeType[int] = None
filter_usage_percentage : AttributeType[int] = None
wifi_working : AttributeType[bool] = None

wick_usage_percentage : AttributeType[int] = None
wick_dry_mode : AttributeType[bool] = None
water_shortage : AttributeType[bool] = None
auto_regulated_humidity : AttributeType[int] = None

async def refresh(self):
_LOGGER.debug(f"refreshing blueair device aws: {self}")
info = await self.api.device_info(self.name_api, self.uuid)
_LOGGER.debug(dumps(info, indent=2))
sensor_data = convert_api_array_to_dict(info["sensordata"])
self.pm1 = safely_get_json_value(sensor_data, "pm1", int)
self.pm2_5 = safely_get_json_value(sensor_data, "pm2_5", int)
self.pm10 = safely_get_json_value(sensor_data, "pm10", int)
self.tVOC = safely_get_json_value(sensor_data, "tVOC", int)
self.temperature = safely_get_json_value(sensor_data, "t", int)
self.humidity = safely_get_json_value(sensor_data, "h", int)

self.name = safely_get_json_value(info, "configuration.di.name")
self.firmware = safely_get_json_value(info, "configuration.di.cfv")
self.mcu_firmware = safely_get_json_value(info, "configuration.di.mfv")
self.serial_number = safely_get_json_value(info, "configuration.di.ds")
self.sku = safely_get_json_value(info, "configuration.di.sku")

states = convert_api_array_to_dict(info["states"])
self.running = safely_get_json_value(states, "standby") is False
self.night_mode = safely_get_json_value(states, "nightmode", bool)
self.germ_shield = safely_get_json_value(states, "germshield", bool)
self.brightness = safely_get_json_value(states, "brightness", int)
self.child_lock = safely_get_json_value(states, "childlock", bool)
self.fan_speed = safely_get_json_value(states, "fanspeed", int)
self.fan_auto_mode = safely_get_json_value(states, "automode", bool)
self.filter_usage = safely_get_json_value(states, "filterusage", int)
self.wifi_working = safely_get_json_value(states, "online", bool)
self.wick_usage = safely_get_json_value(states, "wickusage", int)
self.wick_dry_mode = safely_get_json_value(states, "wickdrys", bool)
self.auto_regulated_humidity = safely_get_json_value(states, "autorh", int)
self.water_shortage = safely_get_json_value(states, "wshortage", bool)

# ir.parse_json(ir.Attribute, ir.query_json(info, "configuration.da"))
ds = ir.parse_json(ir.Sensor, ir.query_json(info, "configuration.ds"))
dc = ir.parse_json(ir.Control, ir.query_json(info, "configuration.dc"))

sensor_data = ir.SensorPack(info["sensordata"]).to_latest_value()

def sensor_data_safe_get(key):
return sensor_data.get(key) if key in ds else NotImplemented

self.pm1 = sensor_data_safe_get("pm1")
self.pm2_5 = sensor_data_safe_get("pm2_5")
self.pm10 = sensor_data_safe_get("pm10")
self.tVOC = sensor_data_safe_get("tVOC")
self.temperature = sensor_data_safe_get("t")
self.humidity = sensor_data_safe_get("h")

def info_safe_get(path):
# directly reads for the schema. If the schema field is
# undefined, it is NotImplemented, not merely unavailable.
value = ir.query_json(info, path)
if value is None:
return NotImplemented
return value

self.name = info_safe_get("configuration.di.name")
self.firmware = info_safe_get("configuration.di.cfv")
self.mcu_firmware = info_safe_get("configuration.di.mfv")
self.serial_number = info_safe_get("configuration.di.ds")
self.sku = info_safe_get("configuration.di.sku")

states = ir.SensorPack(info["states"]).to_latest_value()

def states_safe_get(key):
return states.get(key) if key in dc else NotImplemented

# "online" is not defined in the schema.
self.wifi_working = states.get("online")

self.standby = states_safe_get("standby")
self.night_mode = states_safe_get("nightmode")
self.germ_shield = states_safe_get("germshield")
self.brightness = states_safe_get("brightness")
self.child_lock = states_safe_get("childlock")
self.fan_speed = states_safe_get("fanspeed")
self.fan_auto_mode = states_safe_get("automode")
self.filter_usage_percentage = states_safe_get("filterusage")
self.wick_usage_percentage = states_safe_get("wickusage")
self.wick_dry_mode = states_safe_get("wickdrys")
self.auto_regulated_humidity = states_safe_get("autorh")
self.water_shortage = states_safe_get("wshortage")

self.publish_updates()
_LOGGER.debug(f"refreshed blueair device aws: {self}")
Expand All @@ -108,11 +133,22 @@ async def set_fan_speed(self, value: int):
await self.api.set_device_info(self.uuid, "fanspeed", "v", value)
self.publish_updates()

async def set_running(self, running: bool):
self.running = running
await self.api.set_device_info(self.uuid, "standby", "vb", not running)
async def set_standby(self, value: bool):
self.standby = value
await self.api.set_device_info(self.uuid, "standby", "vb", value)
self.publish_updates()

# FIXME: avoid state translation at the API level and depreate running.
# replace with standby which is standard across aws devices.
@property
def running(self) -> AttributeType[bool]:
if self.standby is None or self.standby is NotImplemented:
return self.standby
return not self.standby

async def set_running(self, running: bool):
await self.set_standby(not running)

async def set_fan_auto_mode(self, fan_auto_mode: bool):
self.fan_auto_mode = fan_auto_mode
await self.api.set_device_info(self.uuid, "automode", "vb", fan_auto_mode)
Expand Down
176 changes: 176 additions & 0 deletions src/blueair_api/intermediate_representation_aws.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
from typing import Any, TypeVar
from collections.abc import Iterable
import dataclasses
import base64

type ScalarType = str | float | bool
type MappingType = dict[str, "ObjectType"]
type SequenceType = list["ObjectType"]
type ObjectType = ScalarType | MappingType | SequenceType


def query_json(jsonobj: ObjectType, path: str):
value = jsonobj
segs = path.split(".")
for i, seg in enumerate(segs[:-1]):
if not isinstance(value, dict | list):
raise KeyError(
f"cannot resolve path segment on a scalar "
f"when resolving segment {i}:{seg} of {path}.")
if isinstance(value, list):
value = value[int(seg)]
else:
try:
value = value[seg]
except KeyError:
raise KeyError(
f"cannot resolve path segment on a scalar "
f"when resolving segment {i}:{seg} of {path}. "
f"available keys are {value.keys()}.")

# last segment returns None if it is not found.
return value.get(segs[-1])


def parse_json[T](kls: type[T], jsonobj: MappingType) -> dict[str, T]:
"""Parses a json mapping object to dict.
The key is preserved. The value is parsed as dataclass type kls.
"""
result = {}
fields = dataclasses.fields(kls)

for key, value in jsonobj.items():
a = dict(value) # make a copy.
kwargs = {}
for field in fields:
if field.name == "extra_fields":
continue
if field.default is dataclasses.MISSING:
kwargs[field.name] = a.pop(field.name)
else:
kwargs[field.name] = a.pop(field.name, field.default)

result[key] = kls(**kwargs, extra_fields=a)
return result


########################
# Blueair AWS API Schema.

@dataclasses.dataclass
class Attribute:
"""DeviceAttribute(da); defines an attribute
An attribute is most likely mutable. An attribute may
also have alias names, likely derived from the 'dc' relation
e.g. a/sb, a/standby all refer to the 'sb' attribute.
"""
extra_fields : MappingType
n: str # name
a: int | bool # default attribute value, example value?
e: bool # ??? always True
fe:bool # ??? always True
ot: str # object type? topic type?
p: bool # only false for reboot and sflu
tn: str # topic name a path-like name d/????/a/{n}


@dataclasses.dataclass
class Sensor:
"""DeviceSensor(ds); seems to define a sensor.
We never directly access these objects. Thos this defines
the schema for 'h', 't', 'pm10' etc that gets returned in
the sensor_data senml SensorPack.
"""
extra_fields : MappingType
n: str # name
i: int # integration time? in millis
e: bool # ???
fe: bool # ??? always True.
ot: str # object type / topic name
tf: str # senml+json; topic format
tn: str # topic name a path-like name d/????/s/{n}
ttl: int # only seen 0 or -1, not sure if used.

@dataclasses.dataclass
class Control:
"""DeviceControl (dc); seems to define a state.
The states SensorPack seem to be using fields defined
in dc. The only exception is 'online' which is not defined
here.
"""
extra_fields : MappingType
n: str # name
v: int | bool
a: str | None = None
s: str | None = None
d: str | None = None # device info json path


########################
# SenML RFC8428

@dataclasses.dataclass
class Record:
"""A RFC8428 SenML record, resolved to Python types."""
name: str
unit: str | None
value: ScalarType
timestamp: float | None
integral: float | None


class SensorPack(list[Record]):
"""Represents a RFC8428 SensorPack, resolved to Python Types."""

def __init__(self, stream: Iterable[MappingType]):
seq = []
for record in stream:
rs = None
rt = None
rn = 0
ru = None
for label, value in record.items():
match label:
case 'bn' | 'bt' | 'bu' | 'bv' | 'bs' | 'bver':
raise ValueError("TODO: base fields not supported. c.f. RFC8428, 4.1")
case 't':
rt = float(value)
case 's':
rs = float(value)
case 'v':
rv = float(value)
case 'vb':
rv = bool(value)
case 'vs':
rv = str(value)
case 'vd':
rv = bytes(base64.b64decode(value))
case 'n':
rn = str(value)
case 'u':
ru = str(value)
case 't':
rn = float(value)
seq.append(Record(name=rn, unit=ru, value=rv, integral=rs, timestamp=rt))
super().__init__(seq)

def to_latest_value(self) -> dict[str, ScalarType]:
return {rn : record.value for rn, record in self.to_latest().items()}

def to_latest(self) -> dict[str, Record]:
latest = {}
for record in self:
rn = record.name
if record.name not in latest:
latest[rn] = record
elif record.timestamp is None:
latest[rn] = record
elif latest[record.name].timestamp is None:
latest[rn] = record
elif latest[record.name].timestamp < record.timestamp:
latest[rn] = record
return latest
4 changes: 2 additions & 2 deletions tests/device_info/README.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
This folder contains golden device info returned from blueair AWS servers.

The content of the text file is the first element of the deviceInfo field of
the response from the initial get info API call, dumped as a json with intent 2 in the debug logs.

the response from the initial get info API call, dumped as a json with intent 2
in the debug logs.
Loading

0 comments on commit e19faf4

Please sign in to comment.