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.
Files changed (28) hide show
  1. {blueair_api-1.25.0 → blueair_api-1.26.0}/PKG-INFO +1 -1
  2. {blueair_api-1.25.0 → blueair_api-1.26.0}/pyproject.toml +1 -1
  3. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/const.py +1 -1
  4. blueair_api-1.26.0/src/blueair_api/device_aws.py +194 -0
  5. blueair_api-1.26.0/src/blueair_api/intermediate_representation_aws.py +176 -0
  6. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api.egg-info/PKG-INFO +1 -1
  7. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api.egg-info/SOURCES.txt +3 -1
  8. blueair_api-1.26.0/tests/test_device_aws.py +366 -0
  9. blueair_api-1.26.0/tests/test_intermediate_representation_aws.py +127 -0
  10. blueair_api-1.25.0/src/blueair_api/device_aws.py +0 -158
  11. blueair_api-1.25.0/tests/test_device_aws.py +0 -295
  12. {blueair_api-1.25.0 → blueair_api-1.26.0}/LICENSE +0 -0
  13. {blueair_api-1.25.0 → blueair_api-1.26.0}/README.md +0 -0
  14. {blueair_api-1.25.0 → blueair_api-1.26.0}/setup.cfg +0 -0
  15. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/__init__.py +0 -0
  16. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/callbacks.py +0 -0
  17. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/device.py +0 -0
  18. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/errors.py +0 -0
  19. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/http_aws_blueair.py +0 -0
  20. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/http_blueair.py +0 -0
  21. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/model_enum.py +0 -0
  22. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/stub.py +0 -0
  23. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/util.py +0 -0
  24. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/util_bootstrap.py +0 -0
  25. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api/util_http.py +0 -0
  26. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api.egg-info/dependency_links.txt +0 -0
  27. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api.egg-info/requires.txt +0 -0
  28. {blueair_api-1.25.0 → blueair_api-1.26.0}/src/blueair_api.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: blueair_api
3
- Version: 1.25.0
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
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
8
8
 
9
9
  [project]
10
10
  name = "blueair_api"
11
- version = "1.25.0"
11
+ version = "1.26.0"
12
12
  authors = [
13
13
  { name="Brendan Dahl", email="dahl.brendan@gmail.com" },
14
14
  ]
@@ -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",
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: blueair_api
3
- Version: 1.25.0
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
@@ -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