blueair-api 1.25.0__py3-none-any.whl → 1.26.1__py3-none-any.whl
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/const.py +1 -1
- blueair_api/device_aws.py +101 -65
- blueair_api/intermediate_representation_aws.py +176 -0
- {blueair_api-1.25.0.dist-info → blueair_api-1.26.1.dist-info}/METADATA +1 -1
- {blueair_api-1.25.0.dist-info → blueair_api-1.26.1.dist-info}/RECORD +8 -7
- {blueair_api-1.25.0.dist-info → blueair_api-1.26.1.dist-info}/LICENSE +0 -0
- {blueair_api-1.25.0.dist-info → blueair_api-1.26.1.dist-info}/WHEEL +0 -0
- {blueair_api-1.25.0.dist-info → blueair_api-1.26.1.dist-info}/top_level.txt +0 -0
blueair_api/const.py
CHANGED
blueair_api/device_aws.py
CHANGED
@@ -5,10 +5,11 @@ from json import dumps
|
|
5
5
|
from .callbacks import CallbacksMixin
|
6
6
|
from .http_aws_blueair import HttpAwsBlueair
|
7
7
|
from .model_enum import ModelEnum
|
8
|
-
from .
|
8
|
+
from . import intermediate_representation_aws as ir
|
9
9
|
|
10
10
|
_LOGGER = logging.getLogger(__name__)
|
11
11
|
|
12
|
+
type AttributeType[T] = T | None | type[NotImplemented]
|
12
13
|
|
13
14
|
@dataclasses.dataclass(slots=True)
|
14
15
|
class DeviceAws(CallbacksMixin):
|
@@ -28,72 +29,96 @@ class DeviceAws(CallbacksMixin):
|
|
28
29
|
return device_aws
|
29
30
|
|
30
31
|
api: HttpAwsBlueair
|
31
|
-
uuid: str = None
|
32
|
-
name: str = None
|
33
|
-
name_api: str = None
|
34
|
-
mac: str = None
|
35
|
-
type_name: str = None
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
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
|
64
66
|
|
65
67
|
async def refresh(self):
|
66
68
|
_LOGGER.debug(f"refreshing blueair device aws: {self}")
|
67
69
|
info = await self.api.device_info(self.name_api, self.uuid)
|
68
70
|
_LOGGER.debug(dumps(info, indent=2))
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
self.
|
80
|
-
self.
|
81
|
-
self.
|
82
|
-
|
83
|
-
|
84
|
-
self.
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
self.
|
95
|
-
self.
|
96
|
-
self.
|
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")
|
97
122
|
|
98
123
|
self.publish_updates()
|
99
124
|
_LOGGER.debug(f"refreshed blueair device aws: {self}")
|
@@ -108,11 +133,22 @@ class DeviceAws(CallbacksMixin):
|
|
108
133
|
await self.api.set_device_info(self.uuid, "fanspeed", "v", value)
|
109
134
|
self.publish_updates()
|
110
135
|
|
111
|
-
async def
|
112
|
-
self.
|
113
|
-
await self.api.set_device_info(self.uuid, "standby", "vb",
|
136
|
+
async def set_standby(self, value: bool):
|
137
|
+
self.standby = value
|
138
|
+
await self.api.set_device_info(self.uuid, "standby", "vb", value)
|
114
139
|
self.publish_updates()
|
115
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
|
+
|
116
152
|
async def set_fan_auto_mode(self, fan_auto_mode: bool):
|
117
153
|
self.fan_auto_mode = fan_auto_mode
|
118
154
|
await self.api.set_device_info(self.uuid, "automode", "vb", fan_auto_mode)
|
@@ -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
|
+
tn: str # topic name a path-like name d/????/s/{n}
|
94
|
+
ttl: int # only seen 0 or -1, not sure if used.
|
95
|
+
tf: str | None = None # senml+json; topic format
|
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
|
@@ -1,18 +1,19 @@
|
|
1
1
|
blueair_api/__init__.py,sha256=GucsIENhTF4AVxPn4Xyr4imUxJJ8RO8RYt1opHCF2hQ,326
|
2
2
|
blueair_api/callbacks.py,sha256=eqgfe1qN_NNWOp2qNSP123zQJxh_Ie6vZXp5EHp6ePc,757
|
3
|
-
blueair_api/const.py,sha256=
|
3
|
+
blueair_api/const.py,sha256=q1smSEhwyYvuQiR867lToFm-mGV-d3dNJvN0NJgscbU,1037
|
4
4
|
blueair_api/device.py,sha256=1cauwVaRXTVcUay_wA1mIpR-f7RPoxUp45lpredkNMo,3768
|
5
|
-
blueair_api/device_aws.py,sha256=
|
5
|
+
blueair_api/device_aws.py,sha256=c8dZQ9rbljeYBjzXelcnhwQWIJ0dw3JACLFRzxD7s38,7379
|
6
6
|
blueair_api/errors.py,sha256=lJ_iFU_W6zQfGRi_wsMhWDw-fAVPFeCkCbT1erIlYQQ,233
|
7
7
|
blueair_api/http_aws_blueair.py,sha256=m_qoCFOYICCu_U_maBvkmOha3YmNtxxtPYyapVBGKNc,17821
|
8
8
|
blueair_api/http_blueair.py,sha256=n9F5fvEROIyAkqDMM22l84PB7ZoeEkWbj2YuCZpeDNg,9411
|
9
|
+
blueair_api/intermediate_representation_aws.py,sha256=TteTMuy1UQ2L0vpGF2Te8v_E1Ageg0QTp6ZM_UUZ9dI,5667
|
9
10
|
blueair_api/model_enum.py,sha256=Z9Ne4icNEjbGNwdHJZSDibcKJKwv-W1BRpZx01RGFuY,2480
|
10
11
|
blueair_api/stub.py,sha256=sTWyRSDObzrXpZToAgDmZhCk3q8SsGN35h-kzMOqSOc,1272
|
11
12
|
blueair_api/util.py,sha256=4g8dTlxawBYKslOJS7WCWss0670mtUc53c3L8NiPTsM,2201
|
12
13
|
blueair_api/util_bootstrap.py,sha256=Vewg7mT1qSRgzOOJDrpXrVhGwcFPRnMrqhJVSIB-0AA,1688
|
13
14
|
blueair_api/util_http.py,sha256=45AJG3Vb6LMVzI0WV22AoSyt64f_Jj3KpOAwF5M6EFE,1327
|
14
|
-
blueair_api-1.
|
15
|
-
blueair_api-1.
|
16
|
-
blueair_api-1.
|
17
|
-
blueair_api-1.
|
18
|
-
blueair_api-1.
|
15
|
+
blueair_api-1.26.1.dist-info/LICENSE,sha256=W6UV41yCe1R_Avet8VtsxwdJar18n40b3MRnbEMHZmI,1066
|
16
|
+
blueair_api-1.26.1.dist-info/METADATA,sha256=2YkJF6-wGfk6kGRzU1Pp6mMAS-xlqs_49m06Uvj8Ypk,1995
|
17
|
+
blueair_api-1.26.1.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
18
|
+
blueair_api-1.26.1.dist-info/top_level.txt,sha256=-gn0jNtmE83qEu70uMW5F4JrXnHGRfuFup1EPWF70oc,12
|
19
|
+
blueair_api-1.26.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|