blueair-api 1.24.1__py3-none-any.whl → 1.26.0__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 CHANGED
@@ -1,5 +1,5 @@
1
1
  from collections.abc import Mapping
2
- from typing_extensions import TypedDict
2
+ from typing import TypedDict
3
3
 
4
4
  SENSITIVE_FIELD_NAMES = [
5
5
  "username",
blueair_api/device_aws.py CHANGED
@@ -1,13 +1,15 @@
1
1
  import dataclasses
2
2
  import logging
3
+ from json import dumps
3
4
 
4
5
  from .callbacks import CallbacksMixin
5
6
  from .http_aws_blueair import HttpAwsBlueair
6
7
  from .model_enum import ModelEnum
7
- from .util import convert_api_array_to_dict, safely_get_json_value
8
+ from . import intermediate_representation_aws as ir
8
9
 
9
10
  _LOGGER = logging.getLogger(__name__)
10
11
 
12
+ type AttributeType[T] = T | None | type[NotImplemented]
11
13
 
12
14
  @dataclasses.dataclass(slots=True)
13
15
  class DeviceAws(CallbacksMixin):
@@ -27,71 +29,96 @@ class DeviceAws(CallbacksMixin):
27
29
  return device_aws
28
30
 
29
31
  api: HttpAwsBlueair
30
- uuid: str = None
31
- name: str = None
32
- name_api: str = None
33
- mac: str = None
34
- type_name: str = None
35
-
36
- sku: str = None
37
- firmware: str = None
38
- mcu_firmware: str = None
39
- serial_number: str = None
40
-
41
- brightness: int = None
42
- child_lock: bool = None
43
- fan_speed: int = None
44
- fan_auto_mode: bool = None
45
- running: bool = None
46
- night_mode: bool = None
47
- germ_shield: bool = None
48
-
49
- pm1: int = None
50
- pm2_5: int = None
51
- pm10: int = None
52
- tVOC: int = None
53
- temperature: int = None
54
- humidity: int = None
55
- filter_usage: int = None # percentage
56
- wifi_working: bool = None
57
-
58
- # i35
59
- wick_usage: int = None # percentage
60
- wick_dry_mode: bool = None
61
- water_shortage: bool = None
62
- auto_regulated_humidity: int = None
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
63
66
 
64
67
  async def refresh(self):
65
68
  _LOGGER.debug(f"refreshing blueair device aws: {self}")
66
69
  info = await self.api.device_info(self.name_api, self.uuid)
67
- sensor_data = convert_api_array_to_dict(info["sensordata"])
68
- self.pm1 = safely_get_json_value(sensor_data, "pm1", int)
69
- self.pm2_5 = safely_get_json_value(sensor_data, "pm2_5", int)
70
- self.pm10 = safely_get_json_value(sensor_data, "pm10", int)
71
- self.tVOC = safely_get_json_value(sensor_data, "tVOC", int)
72
- self.temperature = safely_get_json_value(sensor_data, "t", int)
73
- self.humidity = safely_get_json_value(sensor_data, "h", int)
74
-
75
- self.name = safely_get_json_value(info, "configuration.di.name")
76
- self.firmware = safely_get_json_value(info, "configuration.di.cfv")
77
- self.mcu_firmware = safely_get_json_value(info, "configuration.di.mfv")
78
- self.serial_number = safely_get_json_value(info, "configuration.di.ds")
79
- self.sku = safely_get_json_value(info, "configuration.di.sku")
80
-
81
- states = convert_api_array_to_dict(info["states"])
82
- self.running = safely_get_json_value(states, "standby") is False
83
- self.night_mode = safely_get_json_value(states, "nightmode", bool)
84
- self.germ_shield = safely_get_json_value(states, "germshield", bool)
85
- self.brightness = safely_get_json_value(states, "brightness", int)
86
- self.child_lock = safely_get_json_value(states, "childlock", bool)
87
- self.fan_speed = safely_get_json_value(states, "fanspeed", int)
88
- self.fan_auto_mode = safely_get_json_value(states, "automode", bool)
89
- self.filter_usage = safely_get_json_value(states, "filterusage", int)
90
- self.wifi_working = safely_get_json_value(states, "online", bool)
91
- self.wick_usage = safely_get_json_value(states, "wickusage", int)
92
- self.wick_dry_mode = safely_get_json_value(states, "wickdrys", bool)
93
- self.auto_regulated_humidity = safely_get_json_value(states, "autorh", int)
94
- self.water_shortage = safely_get_json_value(states, "wshortage", bool)
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")
95
122
 
96
123
  self.publish_updates()
97
124
  _LOGGER.debug(f"refreshed blueair device aws: {self}")
@@ -106,11 +133,22 @@ class DeviceAws(CallbacksMixin):
106
133
  await self.api.set_device_info(self.uuid, "fanspeed", "v", value)
107
134
  self.publish_updates()
108
135
 
109
- async def set_running(self, running: bool):
110
- self.running = running
111
- await self.api.set_device_info(self.uuid, "standby", "vb", not running)
136
+ async def set_standby(self, value: bool):
137
+ self.standby = value
138
+ await self.api.set_device_info(self.uuid, "standby", "vb", value)
112
139
  self.publish_updates()
113
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
+
114
152
  async def set_fan_auto_mode(self, fan_auto_mode: bool):
115
153
  self.fan_auto_mode = fan_auto_mode
116
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
+ 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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: blueair_api
3
- Version: 1.24.1
3
+ Version: 1.26.0
4
4
  Summary: Blueair Api Wrapper
5
5
  Author-email: Brendan Dahl <dahl.brendan@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/dahlb/blueair_api
@@ -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=fRth29dQT1dguM7L4ij8pDefB_vBrevAHqTdfNvSugs,1048
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=mZFWsXVrKgEYgqKVJyAxXfuEADtDKujZcevaMf1z-Y4,5947
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=gq9HhTDX1UbJ1RyrlhKSON7oQxFRDxLI-1e2reY_9uU,5653
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.24.1.dist-info/LICENSE,sha256=W6UV41yCe1R_Avet8VtsxwdJar18n40b3MRnbEMHZmI,1066
15
- blueair_api-1.24.1.dist-info/METADATA,sha256=-79G2o5JYGfQMu249nFw5BNsxRLKnGUxwAA-gengZQM,1995
16
- blueair_api-1.24.1.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
17
- blueair_api-1.24.1.dist-info/top_level.txt,sha256=-gn0jNtmE83qEu70uMW5F4JrXnHGRfuFup1EPWF70oc,12
18
- blueair_api-1.24.1.dist-info/RECORD,,
15
+ blueair_api-1.26.0.dist-info/LICENSE,sha256=W6UV41yCe1R_Avet8VtsxwdJar18n40b3MRnbEMHZmI,1066
16
+ blueair_api-1.26.0.dist-info/METADATA,sha256=WKAV7xH5aRN7foL-5ipUyDGEtT-HPdwkSTE2IhhLgUo,1995
17
+ blueair_api-1.26.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
18
+ blueair_api-1.26.0.dist-info/top_level.txt,sha256=-gn0jNtmE83qEu70uMW5F4JrXnHGRfuFup1EPWF70oc,12
19
+ blueair_api-1.26.0.dist-info/RECORD,,