blueair-api 1.25.0__tar.gz → 1.26.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {blueair_api-1.25.0 → blueair_api-1.26.0}/PKG-INFO +1 -1
- {blueair_api-1.25.0 → blueair_api-1.26.0}/pyproject.toml +1 -1
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/const.py +1 -1
- blueair_api-1.26.0/src/blueair_api/device_aws.py +194 -0
- blueair_api-1.26.0/src/blueair_api/intermediate_representation_aws.py +176 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api.egg-info/PKG-INFO +1 -1
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api.egg-info/SOURCES.txt +3 -1
- blueair_api-1.26.0/tests/test_device_aws.py +366 -0
- blueair_api-1.26.0/tests/test_intermediate_representation_aws.py +127 -0
- blueair_api-1.25.0/src/blueair_api/device_aws.py +0 -158
- blueair_api-1.25.0/tests/test_device_aws.py +0 -295
- {blueair_api-1.25.0 → blueair_api-1.26.0}/LICENSE +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/README.md +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/setup.cfg +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/__init__.py +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/callbacks.py +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/device.py +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/errors.py +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/http_aws_blueair.py +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/http_blueair.py +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/model_enum.py +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/stub.py +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/util.py +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/util_bootstrap.py +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/util_http.py +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api.egg-info/dependency_links.txt +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api.egg-info/requires.txt +0 -0
- {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api.egg-info/top_level.txt +0 -0
@@ -0,0 +1,194 @@
|
|
1
|
+
import dataclasses
|
2
|
+
import logging
|
3
|
+
from json import dumps
|
4
|
+
|
5
|
+
from .callbacks import CallbacksMixin
|
6
|
+
from .http_aws_blueair import HttpAwsBlueair
|
7
|
+
from .model_enum import ModelEnum
|
8
|
+
from . import intermediate_representation_aws as ir
|
9
|
+
|
10
|
+
_LOGGER = logging.getLogger(__name__)
|
11
|
+
|
12
|
+
type AttributeType[T] = T | None | type[NotImplemented]
|
13
|
+
|
14
|
+
@dataclasses.dataclass(slots=True)
|
15
|
+
class DeviceAws(CallbacksMixin):
|
16
|
+
@classmethod
|
17
|
+
async def create_device(cls, api, uuid, name, mac, type_name, refresh=False):
|
18
|
+
_LOGGER.debug("UUID:"+uuid)
|
19
|
+
device_aws = DeviceAws(
|
20
|
+
api=api,
|
21
|
+
uuid=uuid,
|
22
|
+
name_api=name,
|
23
|
+
mac=mac,
|
24
|
+
type_name=type_name,
|
25
|
+
)
|
26
|
+
if refresh:
|
27
|
+
await device_aws.refresh()
|
28
|
+
_LOGGER.debug(f"create_device blueair device_aws: {device_aws}")
|
29
|
+
return device_aws
|
30
|
+
|
31
|
+
api: HttpAwsBlueair
|
32
|
+
uuid : str | None = None
|
33
|
+
name : str | None = None
|
34
|
+
name_api : str | None = None
|
35
|
+
mac : str | None = None
|
36
|
+
type_name : str | None = None
|
37
|
+
|
38
|
+
# Attributes are defined below.
|
39
|
+
# We mandate that unittests shall test all fields of AttributeType.
|
40
|
+
sku : AttributeType[str] = None
|
41
|
+
firmware : AttributeType[str] = None
|
42
|
+
mcu_firmware : AttributeType[str] = None
|
43
|
+
serial_number : AttributeType[str] = None
|
44
|
+
|
45
|
+
brightness : AttributeType[int] = None
|
46
|
+
child_lock : AttributeType[bool] = None
|
47
|
+
fan_speed : AttributeType[int] = None
|
48
|
+
fan_auto_mode : AttributeType[bool] = None
|
49
|
+
standby : AttributeType[bool] = None
|
50
|
+
night_mode : AttributeType[bool] = None
|
51
|
+
germ_shield : AttributeType[bool] = None
|
52
|
+
|
53
|
+
pm1 : AttributeType[int] = None
|
54
|
+
pm2_5 : AttributeType[int] = None
|
55
|
+
pm10 : AttributeType[int] = None
|
56
|
+
tVOC : AttributeType[int] = None
|
57
|
+
temperature : AttributeType[int] = None
|
58
|
+
humidity : AttributeType[int] = None
|
59
|
+
filter_usage_percentage : AttributeType[int] = None
|
60
|
+
wifi_working : AttributeType[bool] = None
|
61
|
+
|
62
|
+
wick_usage_percentage : AttributeType[int] = None
|
63
|
+
wick_dry_mode : AttributeType[bool] = None
|
64
|
+
water_shortage : AttributeType[bool] = None
|
65
|
+
auto_regulated_humidity : AttributeType[int] = None
|
66
|
+
|
67
|
+
async def refresh(self):
|
68
|
+
_LOGGER.debug(f"refreshing blueair device aws: {self}")
|
69
|
+
info = await self.api.device_info(self.name_api, self.uuid)
|
70
|
+
_LOGGER.debug(dumps(info, indent=2))
|
71
|
+
|
72
|
+
# ir.parse_json(ir.Attribute, ir.query_json(info, "configuration.da"))
|
73
|
+
ds = ir.parse_json(ir.Sensor, ir.query_json(info, "configuration.ds"))
|
74
|
+
dc = ir.parse_json(ir.Control, ir.query_json(info, "configuration.dc"))
|
75
|
+
|
76
|
+
sensor_data = ir.SensorPack(info["sensordata"]).to_latest_value()
|
77
|
+
|
78
|
+
def sensor_data_safe_get(key):
|
79
|
+
return sensor_data.get(key) if key in ds else NotImplemented
|
80
|
+
|
81
|
+
self.pm1 = sensor_data_safe_get("pm1")
|
82
|
+
self.pm2_5 = sensor_data_safe_get("pm2_5")
|
83
|
+
self.pm10 = sensor_data_safe_get("pm10")
|
84
|
+
self.tVOC = sensor_data_safe_get("tVOC")
|
85
|
+
self.temperature = sensor_data_safe_get("t")
|
86
|
+
self.humidity = sensor_data_safe_get("h")
|
87
|
+
|
88
|
+
def info_safe_get(path):
|
89
|
+
# directly reads for the schema. If the schema field is
|
90
|
+
# undefined, it is NotImplemented, not merely unavailable.
|
91
|
+
value = ir.query_json(info, path)
|
92
|
+
if value is None:
|
93
|
+
return NotImplemented
|
94
|
+
return value
|
95
|
+
|
96
|
+
self.name = info_safe_get("configuration.di.name")
|
97
|
+
self.firmware = info_safe_get("configuration.di.cfv")
|
98
|
+
self.mcu_firmware = info_safe_get("configuration.di.mfv")
|
99
|
+
self.serial_number = info_safe_get("configuration.di.ds")
|
100
|
+
self.sku = info_safe_get("configuration.di.sku")
|
101
|
+
|
102
|
+
states = ir.SensorPack(info["states"]).to_latest_value()
|
103
|
+
|
104
|
+
def states_safe_get(key):
|
105
|
+
return states.get(key) if key in dc else NotImplemented
|
106
|
+
|
107
|
+
# "online" is not defined in the schema.
|
108
|
+
self.wifi_working = states.get("online")
|
109
|
+
|
110
|
+
self.standby = states_safe_get("standby")
|
111
|
+
self.night_mode = states_safe_get("nightmode")
|
112
|
+
self.germ_shield = states_safe_get("germshield")
|
113
|
+
self.brightness = states_safe_get("brightness")
|
114
|
+
self.child_lock = states_safe_get("childlock")
|
115
|
+
self.fan_speed = states_safe_get("fanspeed")
|
116
|
+
self.fan_auto_mode = states_safe_get("automode")
|
117
|
+
self.filter_usage_percentage = states_safe_get("filterusage")
|
118
|
+
self.wick_usage_percentage = states_safe_get("wickusage")
|
119
|
+
self.wick_dry_mode = states_safe_get("wickdrys")
|
120
|
+
self.auto_regulated_humidity = states_safe_get("autorh")
|
121
|
+
self.water_shortage = states_safe_get("wshortage")
|
122
|
+
|
123
|
+
self.publish_updates()
|
124
|
+
_LOGGER.debug(f"refreshed blueair device aws: {self}")
|
125
|
+
|
126
|
+
async def set_brightness(self, value: int):
|
127
|
+
self.brightness = value
|
128
|
+
await self.api.set_device_info(self.uuid, "brightness", "v", value)
|
129
|
+
self.publish_updates()
|
130
|
+
|
131
|
+
async def set_fan_speed(self, value: int):
|
132
|
+
self.fan_speed = value
|
133
|
+
await self.api.set_device_info(self.uuid, "fanspeed", "v", value)
|
134
|
+
self.publish_updates()
|
135
|
+
|
136
|
+
async def set_standby(self, value: bool):
|
137
|
+
self.standby = value
|
138
|
+
await self.api.set_device_info(self.uuid, "standby", "vb", value)
|
139
|
+
self.publish_updates()
|
140
|
+
|
141
|
+
# FIXME: avoid state translation at the API level and depreate running.
|
142
|
+
# replace with standby which is standard across aws devices.
|
143
|
+
@property
|
144
|
+
def running(self) -> AttributeType[bool]:
|
145
|
+
if self.standby is None or self.standby is NotImplemented:
|
146
|
+
return self.standby
|
147
|
+
return not self.standby
|
148
|
+
|
149
|
+
async def set_running(self, running: bool):
|
150
|
+
await self.set_standby(not running)
|
151
|
+
|
152
|
+
async def set_fan_auto_mode(self, fan_auto_mode: bool):
|
153
|
+
self.fan_auto_mode = fan_auto_mode
|
154
|
+
await self.api.set_device_info(self.uuid, "automode", "vb", fan_auto_mode)
|
155
|
+
self.publish_updates()
|
156
|
+
|
157
|
+
async def set_auto_regulated_humidity(self, value: int):
|
158
|
+
self.auto_regulated_humidity = value
|
159
|
+
await self.api.set_device_info(self.uuid, "autorh", "v", value)
|
160
|
+
self.publish_updates()
|
161
|
+
|
162
|
+
async def set_child_lock(self, child_lock: bool):
|
163
|
+
self.child_lock = child_lock
|
164
|
+
await self.api.set_device_info(self.uuid, "childlock", "vb", child_lock)
|
165
|
+
self.publish_updates()
|
166
|
+
|
167
|
+
async def set_night_mode(self, night_mode: bool):
|
168
|
+
self.night_mode = night_mode
|
169
|
+
await self.api.set_device_info(self.uuid, "nightmode", "vb", night_mode)
|
170
|
+
self.publish_updates()
|
171
|
+
|
172
|
+
async def set_wick_dry_mode(self, value: bool):
|
173
|
+
self.wick_dry_mode = value
|
174
|
+
await self.api.set_device_info(self.uuid, "wickdrys", "vb", value)
|
175
|
+
self.publish_updates()
|
176
|
+
|
177
|
+
@property
|
178
|
+
def model(self) -> ModelEnum:
|
179
|
+
if self.sku == "111633":
|
180
|
+
return ModelEnum.HUMIDIFIER_H35I
|
181
|
+
if self.sku == "105820":
|
182
|
+
return ModelEnum.PROTECT_7440I
|
183
|
+
if self.sku == "105826":
|
184
|
+
return ModelEnum.PROTECT_7470I
|
185
|
+
if self.sku == "110059":
|
186
|
+
return ModelEnum.MAX_211I
|
187
|
+
if self.sku == "110092":
|
188
|
+
return ModelEnum.MAX_311I
|
189
|
+
if self.sku == "110057":
|
190
|
+
return ModelEnum.MAX_411I
|
191
|
+
if self.sku == "112124":
|
192
|
+
return ModelEnum.T10I
|
193
|
+
return ModelEnum.UNKNOWN
|
194
|
+
|
@@ -0,0 +1,176 @@
|
|
1
|
+
from typing import Any, TypeVar
|
2
|
+
from collections.abc import Iterable
|
3
|
+
import dataclasses
|
4
|
+
import base64
|
5
|
+
|
6
|
+
type ScalarType = str | float | bool
|
7
|
+
type MappingType = dict[str, "ObjectType"]
|
8
|
+
type SequenceType = list["ObjectType"]
|
9
|
+
type ObjectType = ScalarType | MappingType | SequenceType
|
10
|
+
|
11
|
+
|
12
|
+
def query_json(jsonobj: ObjectType, path: str):
|
13
|
+
value = jsonobj
|
14
|
+
segs = path.split(".")
|
15
|
+
for i, seg in enumerate(segs[:-1]):
|
16
|
+
if not isinstance(value, dict | list):
|
17
|
+
raise KeyError(
|
18
|
+
f"cannot resolve path segment on a scalar "
|
19
|
+
f"when resolving segment {i}:{seg} of {path}.")
|
20
|
+
if isinstance(value, list):
|
21
|
+
value = value[int(seg)]
|
22
|
+
else:
|
23
|
+
try:
|
24
|
+
value = value[seg]
|
25
|
+
except KeyError:
|
26
|
+
raise KeyError(
|
27
|
+
f"cannot resolve path segment on a scalar "
|
28
|
+
f"when resolving segment {i}:{seg} of {path}. "
|
29
|
+
f"available keys are {value.keys()}.")
|
30
|
+
|
31
|
+
# last segment returns None if it is not found.
|
32
|
+
return value.get(segs[-1])
|
33
|
+
|
34
|
+
|
35
|
+
def parse_json[T](kls: type[T], jsonobj: MappingType) -> dict[str, T]:
|
36
|
+
"""Parses a json mapping object to dict.
|
37
|
+
|
38
|
+
The key is preserved. The value is parsed as dataclass type kls.
|
39
|
+
"""
|
40
|
+
result = {}
|
41
|
+
fields = dataclasses.fields(kls)
|
42
|
+
|
43
|
+
for key, value in jsonobj.items():
|
44
|
+
a = dict(value) # make a copy.
|
45
|
+
kwargs = {}
|
46
|
+
for field in fields:
|
47
|
+
if field.name == "extra_fields":
|
48
|
+
continue
|
49
|
+
if field.default is dataclasses.MISSING:
|
50
|
+
kwargs[field.name] = a.pop(field.name)
|
51
|
+
else:
|
52
|
+
kwargs[field.name] = a.pop(field.name, field.default)
|
53
|
+
|
54
|
+
result[key] = kls(**kwargs, extra_fields=a)
|
55
|
+
return result
|
56
|
+
|
57
|
+
|
58
|
+
########################
|
59
|
+
# Blueair AWS API Schema.
|
60
|
+
|
61
|
+
@dataclasses.dataclass
|
62
|
+
class Attribute:
|
63
|
+
"""DeviceAttribute(da); defines an attribute
|
64
|
+
|
65
|
+
An attribute is most likely mutable. An attribute may
|
66
|
+
also have alias names, likely derived from the 'dc' relation
|
67
|
+
e.g. a/sb, a/standby all refer to the 'sb' attribute.
|
68
|
+
"""
|
69
|
+
extra_fields : MappingType
|
70
|
+
n: str # name
|
71
|
+
a: int | bool # default attribute value, example value?
|
72
|
+
e: bool # ??? always True
|
73
|
+
fe:bool # ??? always True
|
74
|
+
ot: str # object type? topic type?
|
75
|
+
p: bool # only false for reboot and sflu
|
76
|
+
tn: str # topic name a path-like name d/????/a/{n}
|
77
|
+
|
78
|
+
|
79
|
+
@dataclasses.dataclass
|
80
|
+
class Sensor:
|
81
|
+
"""DeviceSensor(ds); seems to define a sensor.
|
82
|
+
|
83
|
+
We never directly access these objects. Thos this defines
|
84
|
+
the schema for 'h', 't', 'pm10' etc that gets returned in
|
85
|
+
the sensor_data senml SensorPack.
|
86
|
+
"""
|
87
|
+
extra_fields : MappingType
|
88
|
+
n: str # name
|
89
|
+
i: int # integration time? in millis
|
90
|
+
e: bool # ???
|
91
|
+
fe: bool # ??? always True.
|
92
|
+
ot: str # object type / topic name
|
93
|
+
tf: str # senml+json; topic format
|
94
|
+
tn: str # topic name a path-like name d/????/s/{n}
|
95
|
+
ttl: int # only seen 0 or -1, not sure if used.
|
96
|
+
|
97
|
+
@dataclasses.dataclass
|
98
|
+
class Control:
|
99
|
+
"""DeviceControl (dc); seems to define a state.
|
100
|
+
|
101
|
+
The states SensorPack seem to be using fields defined
|
102
|
+
in dc. The only exception is 'online' which is not defined
|
103
|
+
here.
|
104
|
+
"""
|
105
|
+
extra_fields : MappingType
|
106
|
+
n: str # name
|
107
|
+
v: int | bool
|
108
|
+
a: str | None = None
|
109
|
+
s: str | None = None
|
110
|
+
d: str | None = None # device info json path
|
111
|
+
|
112
|
+
|
113
|
+
########################
|
114
|
+
# SenML RFC8428
|
115
|
+
|
116
|
+
@dataclasses.dataclass
|
117
|
+
class Record:
|
118
|
+
"""A RFC8428 SenML record, resolved to Python types."""
|
119
|
+
name: str
|
120
|
+
unit: str | None
|
121
|
+
value: ScalarType
|
122
|
+
timestamp: float | None
|
123
|
+
integral: float | None
|
124
|
+
|
125
|
+
|
126
|
+
class SensorPack(list[Record]):
|
127
|
+
"""Represents a RFC8428 SensorPack, resolved to Python Types."""
|
128
|
+
|
129
|
+
def __init__(self, stream: Iterable[MappingType]):
|
130
|
+
seq = []
|
131
|
+
for record in stream:
|
132
|
+
rs = None
|
133
|
+
rt = None
|
134
|
+
rn = 0
|
135
|
+
ru = None
|
136
|
+
for label, value in record.items():
|
137
|
+
match label:
|
138
|
+
case 'bn' | 'bt' | 'bu' | 'bv' | 'bs' | 'bver':
|
139
|
+
raise ValueError("TODO: base fields not supported. c.f. RFC8428, 4.1")
|
140
|
+
case 't':
|
141
|
+
rt = float(value)
|
142
|
+
case 's':
|
143
|
+
rs = float(value)
|
144
|
+
case 'v':
|
145
|
+
rv = float(value)
|
146
|
+
case 'vb':
|
147
|
+
rv = bool(value)
|
148
|
+
case 'vs':
|
149
|
+
rv = str(value)
|
150
|
+
case 'vd':
|
151
|
+
rv = bytes(base64.b64decode(value))
|
152
|
+
case 'n':
|
153
|
+
rn = str(value)
|
154
|
+
case 'u':
|
155
|
+
ru = str(value)
|
156
|
+
case 't':
|
157
|
+
rn = float(value)
|
158
|
+
seq.append(Record(name=rn, unit=ru, value=rv, integral=rs, timestamp=rt))
|
159
|
+
super().__init__(seq)
|
160
|
+
|
161
|
+
def to_latest_value(self) -> dict[str, ScalarType]:
|
162
|
+
return {rn : record.value for rn, record in self.to_latest().items()}
|
163
|
+
|
164
|
+
def to_latest(self) -> dict[str, Record]:
|
165
|
+
latest = {}
|
166
|
+
for record in self:
|
167
|
+
rn = record.name
|
168
|
+
if record.name not in latest:
|
169
|
+
latest[rn] = record
|
170
|
+
elif record.timestamp is None:
|
171
|
+
latest[rn] = record
|
172
|
+
elif latest[record.name].timestamp is None:
|
173
|
+
latest[rn] = record
|
174
|
+
elif latest[record.name].timestamp < record.timestamp:
|
175
|
+
latest[rn] = record
|
176
|
+
return latest
|
@@ -9,6 +9,7 @@ src/blueair_api/device_aws.py
|
|
9
9
|
src/blueair_api/errors.py
|
10
10
|
src/blueair_api/http_aws_blueair.py
|
11
11
|
src/blueair_api/http_blueair.py
|
12
|
+
src/blueair_api/intermediate_representation_aws.py
|
12
13
|
src/blueair_api/model_enum.py
|
13
14
|
src/blueair_api/stub.py
|
14
15
|
src/blueair_api/util.py
|
@@ -19,4 +20,5 @@ src/blueair_api.egg-info/SOURCES.txt
|
|
19
20
|
src/blueair_api.egg-info/dependency_links.txt
|
20
21
|
src/blueair_api.egg-info/requires.txt
|
21
22
|
src/blueair_api.egg-info/top_level.txt
|
22
|
-
tests/test_device_aws.py
|
23
|
+
tests/test_device_aws.py
|
24
|
+
tests/test_intermediate_representation_aws.py
|