blueair-api 1.24.1__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 (27) hide show
  1. {blueair_api-1.24.1 → blueair_api-1.26.0}/PKG-INFO +1 -1
  2. {blueair_api-1.24.1 → blueair_api-1.26.0}/pyproject.toml +1 -1
  3. {blueair_api-1.24.1 → 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.24.1 → blueair_api-1.26.0}/src/blueair_api.egg-info/PKG-INFO +1 -1
  7. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api.egg-info/SOURCES.txt +4 -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.24.1/src/blueair_api/device_aws.py +0 -156
  11. {blueair_api-1.24.1 → blueair_api-1.26.0}/LICENSE +0 -0
  12. {blueair_api-1.24.1 → blueair_api-1.26.0}/README.md +0 -0
  13. {blueair_api-1.24.1 → blueair_api-1.26.0}/setup.cfg +0 -0
  14. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api/__init__.py +0 -0
  15. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api/callbacks.py +0 -0
  16. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api/device.py +0 -0
  17. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api/errors.py +0 -0
  18. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api/http_aws_blueair.py +0 -0
  19. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api/http_blueair.py +0 -0
  20. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api/model_enum.py +0 -0
  21. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api/stub.py +0 -0
  22. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api/util.py +0 -0
  23. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api/util_bootstrap.py +0 -0
  24. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api/util_http.py +0 -0
  25. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api.egg-info/dependency_links.txt +0 -0
  26. {blueair_api-1.24.1 → blueair_api-1.26.0}/src/blueair_api.egg-info/requires.txt +0 -0
  27. {blueair_api-1.24.1 → 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.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
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
8
8
 
9
9
  [project]
10
10
  name = "blueair_api"
11
- version = "1.24.1"
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.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
@@ -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
@@ -18,4 +19,6 @@ src/blueair_api.egg-info/PKG-INFO
18
19
  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
- src/blueair_api.egg-info/top_level.txt
22
+ src/blueair_api.egg-info/top_level.txt
23
+ tests/test_device_aws.py
24
+ tests/test_intermediate_representation_aws.py
@@ -0,0 +1,366 @@
1
+ """Tests for DeviceAws.
2
+
3
+ Here is one way to run it:
4
+
5
+ First install the package in developer mode
6
+
7
+ $ pip install -e .
8
+
9
+ Then use pytest to drive the tests
10
+
11
+ $ pytest tests
12
+ """
13
+ import typing
14
+ from typing import Any
15
+
16
+ import contextlib
17
+ import dataclasses
18
+ from importlib import resources
19
+ import json
20
+ from unittest import mock
21
+ from unittest import IsolatedAsyncioTestCase
22
+
23
+ import pytest
24
+
25
+ from blueair_api.device_aws import DeviceAws, AttributeType
26
+ from blueair_api.model_enum import ModelEnum
27
+ from blueair_api import http_aws_blueair
28
+ from blueair_api import intermediate_representation_aws as ir
29
+
30
+
31
+ class FakeDeviceInfoHelper:
32
+ """Fake for the 'device info' interface of HttpAwsBlueAir class."""
33
+ def __init__(self, info: dict[str, Any]):
34
+ self.info = info
35
+
36
+ async def device_info(self, *args, **kwargs):
37
+ return self.info
38
+
39
+ async def set_device_info(self, device_uuid, service_name, action_verb, action_value):
40
+ # this function seems to be only updating the states consider rename the method.
41
+ # action_verb seems to be a type annotation:
42
+ # c.f. senml: https://www.rfc-editor.org/rfc/rfc8428.html#section-5
43
+ # the senml parsing library (utils.py) could use some additional love.
44
+ # to make it more conformal to the RFC standard.
45
+ for state in self.info['states']:
46
+ if state['n'] == service_name:
47
+ break
48
+ else:
49
+ state = {'n': service_name}
50
+ self.info['states'].append(state)
51
+ # Watch out: mutate after append produces desired mutation to info.
52
+ state[action_verb] = action_value
53
+
54
+
55
+ class AssertFullyCheckedHelper:
56
+ """Assert that all attributes of AttributeType are accessed once."""
57
+ def __init__(self, device : DeviceAws):
58
+ self.device = device
59
+ self.logs = []
60
+ self.fields = set()
61
+ for field in dataclasses.fields(self.device):
62
+ if typing.get_origin(field.type) is AttributeType:
63
+ self.fields.add(field.name)
64
+
65
+ def __getattr__(self, attr):
66
+ if attr in self.fields:
67
+ self.logs.append(attr)
68
+ return getattr(self.device, attr)
69
+
70
+
71
+ @contextlib.contextmanager
72
+ def assert_fully_checked(device):
73
+ helper = AssertFullyCheckedHelper(device)
74
+ yield helper
75
+ assert set(helper.logs) == helper.fields
76
+
77
+
78
+ class DeviceAwsTestBase(IsolatedAsyncioTestCase):
79
+
80
+ def setUp(self):
81
+
82
+ patcher = mock.patch('blueair_api.http_aws_blueair.HttpAwsBlueair', autospec=True)
83
+ self.api_class = patcher.start()
84
+ self.addCleanup(patcher.stop)
85
+ self.api = self.api_class(username="fake-username", password="fake-password")
86
+
87
+ self.device = DeviceAws(self.api,
88
+ name_api="fake-name-api",
89
+ uuid="fake-uuid",
90
+ name="fake-name",
91
+ mac="fake-mac",
92
+ type_name='fake-type-name')
93
+
94
+ self.device_info_helper = FakeDeviceInfoHelper(
95
+ {"configuration": {"di" : {}, "ds" : {}, "dc" : {}, "da" : {},},
96
+ "sensordata": [],
97
+ "states": [],
98
+ })
99
+
100
+ self.api.device_info.side_effect = self.device_info_helper.device_info
101
+ self.api.set_device_info.side_effect = self.device_info_helper.set_device_info
102
+
103
+
104
+ class DeviceAwsSetterTest(DeviceAwsTestBase):
105
+ """Tests for all of the setters."""
106
+
107
+ def setUp(self):
108
+ super().setUp()
109
+ # minimally populate dc to define the states.
110
+ fake = {"n": "n", "v": 0}
111
+ ir.query_json(self.device_info_helper.info, "configuration.dc").update({
112
+ "brightness": fake,
113
+ "fanspeed": fake,
114
+ "standby": fake,
115
+ "automode": fake,
116
+ "autorh": fake,
117
+ "childlock": fake,
118
+ "nightmode": fake,
119
+ "wickdrys": fake,
120
+ })
121
+
122
+ async def test_brightness(self):
123
+ # test cache works
124
+ self.device.brightness = None
125
+ await self.device.set_brightness(1)
126
+ assert self.device.brightness == 1
127
+
128
+ # test refresh works
129
+ await self.device.set_brightness(2)
130
+ self.device.brightness = None
131
+ await self.device.refresh()
132
+ assert self.device.brightness == 2
133
+
134
+ async def test_fan_speed(self):
135
+ # test cache works
136
+ self.device.fan_speed = None
137
+ await self.device.set_fan_speed(1)
138
+ assert self.device.fan_speed == 1
139
+
140
+ # test refresh works
141
+ await self.device.set_fan_speed(2)
142
+ self.device.fan_speed = None
143
+ await self.device.refresh()
144
+ assert self.device.fan_speed == 2
145
+
146
+ async def test_running(self):
147
+ # test cache works
148
+ self.device.standby = None
149
+ await self.device.set_running(False)
150
+ assert self.device.running is False
151
+
152
+ # test refresh works
153
+ await self.device.set_running(True)
154
+ self.device.standby = None
155
+ await self.device.refresh()
156
+ assert self.device.running is True
157
+
158
+ async def test_standby(self):
159
+ # test cache works
160
+ self.device.standby = None
161
+ await self.device.set_standby(False)
162
+ assert self.device.standby is False
163
+
164
+ # test refresh works
165
+ await self.device.set_standby(True)
166
+ self.device.standby = None
167
+ await self.device.refresh()
168
+ assert self.device.standby is True
169
+
170
+ async def test_fan_auto_mode(self):
171
+ # test cache works
172
+ self.device.fan_auto_mode = None
173
+ await self.device.set_fan_auto_mode(False)
174
+ assert self.device.fan_auto_mode is False
175
+
176
+ # test refresh works
177
+ await self.device.set_fan_auto_mode(True)
178
+ self.device.fan_auto_mode = None
179
+ await self.device.refresh()
180
+ assert self.device.fan_auto_mode is True
181
+
182
+ async def test_auto_regulated_humidity(self):
183
+ # test cache works
184
+ self.device.auto_regulated_humidity = None
185
+ await self.device.set_auto_regulated_humidity(1)
186
+ assert self.device.auto_regulated_humidity == 1
187
+
188
+ # test refresh works
189
+ await self.device.set_auto_regulated_humidity(2)
190
+ self.device.auto_regulated_humidity = None
191
+ await self.device.refresh()
192
+ assert self.device.auto_regulated_humidity == 2
193
+
194
+ async def test_child_lock(self):
195
+ # test cache works
196
+ self.device.child_lock = None
197
+ await self.device.set_child_lock(False)
198
+ assert self.device.child_lock is False
199
+
200
+ # test refresh works
201
+ await self.device.set_child_lock(True)
202
+ self.device.child_lock = None
203
+ await self.device.refresh()
204
+ assert self.device.child_lock is True
205
+
206
+ async def test_night_mode(self):
207
+ # test cache works
208
+ self.device.night_mode = None
209
+ await self.device.set_night_mode(False)
210
+ assert self.device.night_mode is False
211
+
212
+ # test refresh works
213
+ await self.device.set_night_mode(True)
214
+ self.device.night_mode = None
215
+ await self.device.refresh()
216
+ assert self.device.night_mode is True
217
+
218
+ async def test_wick_dry_mode(self):
219
+ # test cache works
220
+ self.device.wick_dry_mode = None
221
+ await self.device.set_wick_dry_mode(False)
222
+ assert self.device.wick_dry_mode is False
223
+
224
+ # test refresh works
225
+ await self.device.set_wick_dry_mode(True)
226
+ self.device.wick_dry_mode = None
227
+ await self.device.refresh()
228
+ assert self.device.wick_dry_mode is True
229
+
230
+
231
+ class EmptyDeviceAwsTest(DeviceAwsTestBase):
232
+ """Tests for a emptydevice.
233
+
234
+ This is a made-up device. All attrs are not implemented.
235
+
236
+ Other device types shall override setUp and populate self.info with the
237
+ golden dataset.
238
+ """
239
+
240
+ async def test_attributes(self):
241
+
242
+ await self.device.refresh()
243
+ self.api.device_info.assert_awaited_with("fake-name-api", "fake-uuid")
244
+
245
+ with assert_fully_checked(self.device) as device:
246
+
247
+ assert device.model == ModelEnum.UNKNOWN
248
+
249
+ assert device.pm1 is NotImplemented
250
+ assert device.pm2_5 is NotImplemented
251
+ assert device.pm10 is NotImplemented
252
+ assert device.tVOC is NotImplemented
253
+ assert device.temperature is NotImplemented
254
+ assert device.humidity is NotImplemented
255
+ assert device.name is NotImplemented
256
+ assert device.firmware is NotImplemented
257
+ assert device.mcu_firmware is NotImplemented
258
+ assert device.serial_number is NotImplemented
259
+ assert device.sku is NotImplemented
260
+
261
+ assert device.running is NotImplemented
262
+ assert device.standby is NotImplemented
263
+ assert device.night_mode is NotImplemented
264
+ assert device.germ_shield is NotImplemented
265
+ assert device.brightness is NotImplemented
266
+ assert device.child_lock is NotImplemented
267
+ assert device.fan_speed is NotImplemented
268
+ assert device.fan_auto_mode is NotImplemented
269
+ assert device.filter_usage_percentage is NotImplemented
270
+ assert device.wifi_working is None
271
+ assert device.wick_usage_percentage is NotImplemented
272
+ assert device.auto_regulated_humidity is NotImplemented
273
+ assert device.water_shortage is NotImplemented
274
+ assert device.wick_dry_mode is NotImplemented
275
+
276
+
277
+ class H35iTest(DeviceAwsTestBase):
278
+ """Tests for H35i."""
279
+
280
+ def setUp(self):
281
+ super().setUp()
282
+ with open(resources.files().joinpath('device_info/H35i.json')) as sample_file:
283
+ info = json.load(sample_file)
284
+ self.device_info_helper.info.update(info)
285
+
286
+ async def test_attributes(self):
287
+
288
+ await self.device.refresh()
289
+ self.api.device_info.assert_awaited_with("fake-name-api", "fake-uuid")
290
+
291
+ with assert_fully_checked(self.device) as device:
292
+
293
+ assert device.model == ModelEnum.HUMIDIFIER_H35I
294
+
295
+ assert device.pm1 is NotImplemented
296
+ assert device.pm2_5 is NotImplemented
297
+ assert device.pm10 is NotImplemented
298
+ assert device.tVOC is NotImplemented
299
+ assert device.temperature == 19
300
+ assert device.humidity == 50
301
+ assert device.name == "Bedroom"
302
+ assert device.firmware == "1.0.1"
303
+ assert device.mcu_firmware == "1.0.1"
304
+ assert device.serial_number == "111163300201110210004036"
305
+ assert device.sku == "111633"
306
+
307
+ assert device.running is True
308
+ assert device.standby is False
309
+ assert device.night_mode is False
310
+ assert device.germ_shield is NotImplemented
311
+ assert device.brightness == 49
312
+ assert device.child_lock is False
313
+ assert device.fan_speed == 24
314
+ assert device.fan_auto_mode is False
315
+ assert device.filter_usage_percentage is NotImplemented
316
+ assert device.wifi_working is True
317
+ assert device.wick_usage_percentage == 13
318
+ assert device.auto_regulated_humidity == 50
319
+ assert device.water_shortage is False
320
+ assert device.wick_dry_mode is False
321
+
322
+
323
+ class T10iTest(DeviceAwsTestBase):
324
+ """Tests for T10i."""
325
+
326
+ def setUp(self):
327
+ super().setUp()
328
+ with open(resources.files().joinpath('device_info/T10i.json')) as sample_file:
329
+ info = json.load(sample_file)
330
+ self.device_info_helper.info.update(info)
331
+
332
+ async def test_attributes(self):
333
+
334
+ await self.device.refresh()
335
+ self.api.device_info.assert_awaited_with("fake-name-api", "fake-uuid")
336
+
337
+ with assert_fully_checked(self.device) as device:
338
+
339
+ assert device.model == ModelEnum.T10I
340
+
341
+ assert device.pm1 is NotImplemented
342
+ assert device.pm2_5 == 0
343
+ assert device.pm10 is NotImplemented
344
+ assert device.tVOC is NotImplemented
345
+ assert device.temperature == 18
346
+ assert device.humidity == 28
347
+ assert device.name == "Allen's Office"
348
+ assert device.firmware == "1.0.4"
349
+ assert device.mcu_firmware == "1.0.4"
350
+ assert device.serial_number == "111212400002313210001961"
351
+ assert device.sku == "112124"
352
+
353
+ assert device.running is True
354
+ assert device.standby is False
355
+ assert device.night_mode is NotImplemented
356
+ assert device.germ_shield is NotImplemented
357
+ assert device.brightness == 100
358
+ assert device.child_lock is False
359
+ assert device.fan_speed is NotImplemented
360
+ assert device.fan_auto_mode is NotImplemented
361
+ assert device.filter_usage_percentage == 0
362
+ assert device.wifi_working is True
363
+ assert device.wick_usage_percentage is NotImplemented
364
+ assert device.auto_regulated_humidity is NotImplemented
365
+ assert device.water_shortage is NotImplemented
366
+ assert device.wick_dry_mode is NotImplemented
@@ -0,0 +1,127 @@
1
+ import dataclasses
2
+ from unittest import TestCase
3
+
4
+ import pytest
5
+
6
+ from blueair_api import intermediate_representation_aws as ir
7
+
8
+ class SensorPackTest(TestCase):
9
+
10
+ def testSimple(self):
11
+ sp = ir.SensorPack( [
12
+ {'n': 'v', 't': 1, 'v': 1},
13
+ {'n': 'vb', 't': 2, 'vb': True},
14
+ {'n': 'vs', 't': 3, 'vs': "s"},
15
+ {'n': 'vd', 't': 4, 'vd': "MTIzCg=="}, # b'123\n'
16
+ {'n': 'no_t', 'v': 1},
17
+ {'n': 'no_s', 'v': 1},
18
+ {'n': 's', 'v': 1, 's': 2},
19
+ {'n': 'u', 'v': 1, 'u': "unit"},
20
+ {'n': 'no_u', 'v': 1},
21
+ ])
22
+
23
+ latest = sp.to_latest()
24
+
25
+ assert latest['v'].timestamp == 1
26
+ assert latest['v'].value == 1
27
+ assert isinstance(latest['v'].value, float)
28
+
29
+ assert latest['vb'].timestamp == 2
30
+ assert latest['vb'].value is True
31
+ assert isinstance(latest['vb'].value, bool)
32
+
33
+ assert latest['vs'].timestamp == 3
34
+ assert latest['vs'].value == "s"
35
+ assert isinstance(latest['vs'].value, str)
36
+
37
+ assert latest['vd'].timestamp == 4
38
+ assert latest['vd'].value == b"123\n"
39
+ assert isinstance(latest['vd'].value, bytes)
40
+
41
+ assert latest['no_t'].timestamp is None
42
+
43
+ assert latest['no_s'].integral is None
44
+ assert latest['s'].integral == 2
45
+ assert latest['s'].value == 1
46
+
47
+ assert latest['no_u'].unit is None
48
+ assert latest['u'].unit == "unit"
49
+
50
+ def testToLatestMissingT1None(self):
51
+ sp = ir.SensorPack( [
52
+ {'n': 'missing_t', 't': 1, 'v': 1},
53
+ {'n': 'missing_t', 'v': 2},
54
+ ])
55
+
56
+ latest = sp.to_latest()
57
+ assert latest['missing_t'].value == 2
58
+
59
+ def testToLatestMissingTNoneNone(self):
60
+ sp = ir.SensorPack( [
61
+ {'n': 'missing_t', 'v': 1},
62
+ {'n': 'missing_t', 'v': 2},
63
+ ])
64
+
65
+ latest = sp.to_latest()
66
+ assert latest['missing_t'].value == 2
67
+
68
+ def testToLatestMissingTNone1(self):
69
+ sp = ir.SensorPack( [
70
+ {'n': 'missing_t', 'v': 1},
71
+ {'n': 'missing_t', 't': 1, 'v': 2},
72
+ ])
73
+
74
+ latest = sp.to_latest()
75
+ assert latest['missing_t'].value == 2
76
+
77
+ def testToLatestReversedOrder(self):
78
+ sp = ir.SensorPack( [
79
+ {'n': 'missing_t', 't': 2, 'v': 2},
80
+ {'n': 'missing_t', 't': 1, 'v': 1},
81
+ ])
82
+
83
+ latest = sp.to_latest()
84
+ assert latest['missing_t'].value == 2
85
+
86
+
87
+ class QueryJsonTest(TestCase):
88
+
89
+ def test_mapping_one(self):
90
+ assert ir.query_json({"a": 0}, "a") == 0
91
+
92
+ def test_mapping_two(self):
93
+ assert ir.query_json({"a": {"b": 0}}, "a.b") == 0
94
+
95
+ def test_sequence(self):
96
+ assert ir.query_json([{"a": 1}], "0.a") == 1
97
+
98
+ def test_none(self):
99
+ # last segment not found produces None.
100
+ assert ir.query_json([{"a": 1}], "0.r") is None
101
+
102
+ def test_scalar_error(self):
103
+ with pytest.raises(KeyError):
104
+ ir.query_json(3, "0.r")
105
+
106
+ def test_key_error(self):
107
+ with pytest.raises(KeyError):
108
+ # intermediate segment not found produces KeyError
109
+ ir.query_json({"a": {"b": 3}}, "r.b")
110
+
111
+ @dataclasses.dataclass
112
+ class FakeObjectType:
113
+ a : str
114
+ extra_fields: ir.MappingType
115
+
116
+ class ParseJsonTest(TestCase):
117
+
118
+ def test_mapping_one(self):
119
+ d = ir.parse_json(FakeObjectType, {
120
+ "one" :{"a": "1", "e" : 1},
121
+ "two" :{"a": "2", "e" : 2},
122
+ })
123
+ assert d == {
124
+ "one" : FakeObjectType(a="1", extra_fields={"e":1}),
125
+ "two" : FakeObjectType(a="2", extra_fields={"e":2}),
126
+ }
127
+
@@ -1,156 +0,0 @@
1
- import dataclasses
2
- import logging
3
-
4
- from .callbacks import CallbacksMixin
5
- from .http_aws_blueair import HttpAwsBlueair
6
- from .model_enum import ModelEnum
7
- from .util import convert_api_array_to_dict, safely_get_json_value
8
-
9
- _LOGGER = logging.getLogger(__name__)
10
-
11
-
12
- @dataclasses.dataclass(slots=True)
13
- class DeviceAws(CallbacksMixin):
14
- @classmethod
15
- async def create_device(cls, api, uuid, name, mac, type_name, refresh=False):
16
- _LOGGER.debug("UUID:"+uuid)
17
- device_aws = DeviceAws(
18
- api=api,
19
- uuid=uuid,
20
- name_api=name,
21
- mac=mac,
22
- type_name=type_name,
23
- )
24
- if refresh:
25
- await device_aws.refresh()
26
- _LOGGER.debug(f"create_device blueair device_aws: {device_aws}")
27
- return device_aws
28
-
29
- 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
63
-
64
- async def refresh(self):
65
- _LOGGER.debug(f"refreshing blueair device aws: {self}")
66
- 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)
95
-
96
- self.publish_updates()
97
- _LOGGER.debug(f"refreshed blueair device aws: {self}")
98
-
99
- async def set_brightness(self, value: int):
100
- self.brightness = value
101
- await self.api.set_device_info(self.uuid, "brightness", "v", value)
102
- self.publish_updates()
103
-
104
- async def set_fan_speed(self, value: int):
105
- self.fan_speed = value
106
- await self.api.set_device_info(self.uuid, "fanspeed", "v", value)
107
- self.publish_updates()
108
-
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)
112
- self.publish_updates()
113
-
114
- async def set_fan_auto_mode(self, fan_auto_mode: bool):
115
- self.fan_auto_mode = fan_auto_mode
116
- await self.api.set_device_info(self.uuid, "automode", "vb", fan_auto_mode)
117
- self.publish_updates()
118
-
119
- async def set_auto_regulated_humidity(self, value: int):
120
- self.auto_regulated_humidity = value
121
- await self.api.set_device_info(self.uuid, "autorh", "v", value)
122
- self.publish_updates()
123
-
124
- async def set_child_lock(self, child_lock: bool):
125
- self.child_lock = child_lock
126
- await self.api.set_device_info(self.uuid, "childlock", "vb", child_lock)
127
- self.publish_updates()
128
-
129
- async def set_night_mode(self, night_mode: bool):
130
- self.night_mode = night_mode
131
- await self.api.set_device_info(self.uuid, "nightmode", "vb", night_mode)
132
- self.publish_updates()
133
-
134
- async def set_wick_dry_mode(self, value: bool):
135
- self.wick_dry_mode = value
136
- await self.api.set_device_info(self.uuid, "wickdrys", "vb", value)
137
- self.publish_updates()
138
-
139
- @property
140
- def model(self) -> ModelEnum:
141
- if self.sku == "111633":
142
- return ModelEnum.HUMIDIFIER_H35I
143
- if self.sku == "105820":
144
- return ModelEnum.PROTECT_7440I
145
- if self.sku == "105826":
146
- return ModelEnum.PROTECT_7470I
147
- if self.sku == "110059":
148
- return ModelEnum.MAX_211I
149
- if self.sku == "110092":
150
- return ModelEnum.MAX_311I
151
- if self.sku == "110057":
152
- return ModelEnum.MAX_411I
153
- if self.sku == "112124":
154
- return ModelEnum.T10I
155
- return ModelEnum.UNKNOWN
156
-
File without changes
File without changes
File without changes