blebox-uniapi 2.5.5__tar.gz → 2.5.6__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 (52) hide show
  1. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/CONTRIBUTING.rst +8 -4
  2. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/MANIFEST.in +0 -1
  3. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/PKG-INFO +17 -5
  4. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/__init__.py +1 -1
  5. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/box.py +6 -0
  6. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/box_types.py +7 -3
  7. blebox_uniapi-2.5.6/blebox_uniapi/button.py +141 -0
  8. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/cover.py +29 -2
  9. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/error.py +0 -2
  10. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/session.py +2 -2
  11. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi.egg-info/PKG-INFO +18 -6
  12. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi.egg-info/SOURCES.txt +13 -1
  13. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/setup.cfg +2 -1
  14. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/setup.py +2 -1
  15. blebox_uniapi-2.5.6/tests/test_box.py +236 -0
  16. blebox_uniapi-2.5.6/tests/test_box_types.py +80 -0
  17. blebox_uniapi-2.5.6/tests/test_button.py +266 -0
  18. blebox_uniapi-2.5.6/tests/test_climate.py +386 -0
  19. blebox_uniapi-2.5.6/tests/test_cover.py +962 -0
  20. blebox_uniapi-2.5.6/tests/test_light.py +1369 -0
  21. blebox_uniapi-2.5.6/tests/test_light_unit.py +141 -0
  22. blebox_uniapi-2.5.6/tests/test_sensor.py +477 -0
  23. blebox_uniapi-2.5.6/tests/test_session.py +233 -0
  24. blebox_uniapi-2.5.6/tests/test_switch.py +447 -0
  25. blebox_uniapi-2.5.6/tests/test_unknown.py +59 -0
  26. blebox_uniapi-2.5.6/tests/test_update.py +70 -0
  27. blebox_uniapi-2.5.5/blebox_uniapi/button.py +0 -75
  28. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/HISTORY.rst +0 -0
  29. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/LICENSE +0 -0
  30. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/README.rst +0 -0
  31. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/binary_sensor.py +0 -0
  32. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/climate.py +0 -0
  33. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/feature.py +0 -0
  34. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/jfollow.py +0 -0
  35. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/light.py +0 -0
  36. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/sensor.py +0 -0
  37. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/switch.py +0 -0
  38. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/update.py +0 -0
  39. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi.egg-info/dependency_links.txt +0 -0
  40. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi.egg-info/not-zip-safe +0 -0
  41. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi.egg-info/requires.txt +0 -0
  42. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi.egg-info/top_level.txt +0 -0
  43. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/Makefile +0 -0
  44. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/authors.rst +0 -0
  45. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/conf.py +0 -0
  46. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/contributing.rst +0 -0
  47. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/history.rst +0 -0
  48. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/index.rst +0 -0
  49. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/installation.rst +0 -0
  50. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/make.bat +0 -0
  51. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/readme.rst +0 -0
  52. {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/usage.rst +0 -0
@@ -122,7 +122,11 @@ Make sure all your changes are committed (including an entry in HISTORY.rst).
122
122
  Then run::
123
123
 
124
124
  $ bump2version patch # possible: major / minor / patch
125
- $ git push
126
- $ git push --tags
127
-
128
- Travis will then deploy to PyPI if tests pass.
125
+ $ git push --follow-tags
126
+
127
+ bump2version bumps the version in setup.py, blebox_uniapi/__init__.py and
128
+ setup.cfg, commits the change and creates a matching git tag (e.g. v2.6.0).
129
+ Pushing the tag triggers the "Publish to PyPI" GitHub Actions workflow
130
+ (.github/workflows/release.yml), which first runs lint + tests, then builds and
131
+ uploads to PyPI via Trusted Publishing (OIDC) from the blebox/blebox_uniapi repo.
132
+ The publish step only runs if the tests pass.
@@ -1,4 +1,3 @@
1
- include AUTHORS.rst
2
1
  include CONTRIBUTING.rst
3
2
  include HISTORY.rst
4
3
  include LICENSE
@@ -1,22 +1,36 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: blebox_uniapi
3
- Version: 2.5.5
3
+ Version: 2.5.6
4
4
  Summary: Python API for accessing BleBox smart home devices
5
5
  Home-page: https://github.com/blebox/blebox_uniapi
6
6
  Author: BleBox
7
7
  Author-email: opensource@blebox.eu
8
8
  License: Apache Software License 2.0
9
9
  Keywords: blebox_uniapi
10
- Platform: UNKNOWN
11
10
  Classifier: Development Status :: 2 - Pre-Alpha
12
11
  Classifier: Intended Audience :: Developers
13
12
  Classifier: License :: OSI Approved :: Apache Software License
14
13
  Classifier: Natural Language :: English
15
14
  Classifier: Programming Language :: Python :: 3.9
16
15
  Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
17
  Requires-Python: >=3.9
18
18
  Description-Content-Type: text/x-rst
19
19
  License-File: LICENSE
20
+ Requires-Dist: aiohttp>=3
21
+ Requires-Dist: jmespath>1.0.0
22
+ Dynamic: author
23
+ Dynamic: author-email
24
+ Dynamic: classifier
25
+ Dynamic: description
26
+ Dynamic: description-content-type
27
+ Dynamic: home-page
28
+ Dynamic: keywords
29
+ Dynamic: license
30
+ Dynamic: license-file
31
+ Dynamic: requires-dist
32
+ Dynamic: requires-python
33
+ Dynamic: summary
20
34
 
21
35
  ====================
22
36
  BleBox Python UniAPI
@@ -227,5 +241,3 @@ History
227
241
  ------------------
228
242
 
229
243
  * First release on PyPI.
230
-
231
-
@@ -2,4 +2,4 @@
2
2
 
3
3
  __author__ = """BleBox"""
4
4
  __email__ = "opensource@blebox.eu"
5
- __version__ = "2.5.5"
5
+ __version__ = "2.5.6"
@@ -111,6 +111,7 @@ class Box:
111
111
 
112
112
  self._data_path = config["api_path"]
113
113
  self._type = type
114
+ self._product = product
114
115
  self._unique_id = unique_id
115
116
  self._name = name
116
117
  self._address = address
@@ -212,6 +213,11 @@ class Box:
212
213
  def type(self) -> str:
213
214
  return self._type
214
215
 
216
+ @property
217
+ def product(self) -> str:
218
+ """Product name for informational purpose only - do not use it in any application logic."""
219
+ return self._product
220
+
215
221
  @property
216
222
  def unique_id(self) -> Any:
217
223
  return self._unique_id
@@ -1,3 +1,4 @@
1
+ from .button import GateBoxSecondOutput, TvLift
1
2
  from .cover import Gate, GateBox, GateBoxB, Shutter
2
3
  from typing import Union, Any
3
4
 
@@ -50,9 +51,8 @@ BOX_TYPE_CONF: dict[str, dict[int, dict[str, Any]]] = {
50
51
  "set": lambda command: ("GET", f"/s/c/{command}")
51
52
  }, # dictionary with interaction methods
52
53
  "buttons": [
53
- "tvLift",
54
- {"lift": ""},
55
- ], # key used to set platform, list elements used in cls init, e.g. [<alias>, {"path": "state_value"}]
54
+ ["tvLift", {}, TvLift],
55
+ ], # key used to set platform, list elements used in cls init, e.g. [<alias>, {"path": "state_value"}, <optional type class>]
56
56
  }
57
57
  },
58
58
  # airSensor
@@ -135,11 +135,15 @@ BOX_TYPE_CONF: dict[str, dict[int, dict[str, Any]]] = {
135
135
  {
136
136
  "position": "gate.currentPos",
137
137
  "gate_type": "gate.gateType",
138
+ "extra_button_type": "gate.extraButtonType",
138
139
  },
139
140
  "gatebox",
140
141
  GateBoxB,
141
142
  ]
142
143
  ],
144
+ "buttons": [
145
+ ["second_output", {}, GateBoxSecondOutput],
146
+ ],
143
147
  },
144
148
  },
145
149
  # gateController
@@ -0,0 +1,141 @@
1
+ from .cover import GateBoxControlType, GateBoxExtraButtonType
2
+ from .feature import Feature
3
+ from typing import TYPE_CHECKING, Any, Optional, Type
4
+
5
+ from enum import Enum, auto
6
+
7
+ if TYPE_CHECKING:
8
+ from .box import Box
9
+
10
+ TV_LIFT_CONTROL_TYPES_API = {
11
+ 0: {"1": "open_or_stop", "2": "close_or_stop"},
12
+ 1: {"1": "up_or_stop", "2": "down_or_stop"},
13
+ 2: {"1": "up_or_stop", "2": "down_or_stop"},
14
+ 3: {"1": "up_or_stop", "2": "down_or_stop"},
15
+ 4: {"1": "open_or_stop", "2": "close_or_stop", "3": "to_fav"},
16
+ }
17
+
18
+
19
+ class ControlType(Enum):
20
+ UP = auto()
21
+ DOWN = auto()
22
+ FAVORITE = auto()
23
+ OPEN = auto()
24
+ CLOSE = auto()
25
+
26
+
27
+ class Buttons:
28
+ """Buttons defines how a product derives its buttons from extended state."""
29
+
30
+ @staticmethod
31
+ def many_from_extended_state(
32
+ button_cls: Type["Button"],
33
+ product: "Box",
34
+ alias: str,
35
+ methods: dict,
36
+ extended_state: dict,
37
+ ) -> list["Button"]:
38
+ raise NotImplementedError # pragma: no cover
39
+
40
+
41
+ class TvLift(Buttons):
42
+ @staticmethod
43
+ def many_from_extended_state(
44
+ button_cls: Type["Button"],
45
+ product: "Box",
46
+ alias: str,
47
+ methods: dict,
48
+ extended_state: dict,
49
+ ) -> list["Button"]:
50
+ control_type = extended_state.get("tvLift", {}).get("controlType")
51
+ endpoints = TV_LIFT_CONTROL_TYPES_API.get(control_type, {})
52
+ return [
53
+ button_cls(product, f"{alias}_{endpoint}", methods, endpoint)
54
+ for endpoint in endpoints.values()
55
+ ]
56
+
57
+
58
+ class GateBoxSecondOutput(Buttons):
59
+ """GateBoxSecondOutput exposes the gateBox extra button output as a button.
60
+
61
+ WALK_IN and OTHER share one button name because what the output does is a
62
+ device setting the user can change at any time, not a hardware trait.
63
+ """
64
+
65
+ @staticmethod
66
+ def many_from_extended_state(
67
+ button_cls: Type["Button"],
68
+ product: "Box",
69
+ alias: str,
70
+ methods: dict,
71
+ extended_state: dict,
72
+ ) -> list["Button"]:
73
+ gate = extended_state.get("gate", {})
74
+ if gate.get("extraButtonType") not in (
75
+ GateBoxExtraButtonType.WALK_IN,
76
+ GateBoxExtraButtonType.OTHER,
77
+ ):
78
+ return []
79
+
80
+ # Api levels below 20230102 report no mode and cannot be wired that way.
81
+ if gate.get("openCloseMode") == GateBoxControlType.OPEN_CLOSE:
82
+ return []
83
+
84
+ return [button_cls(product, alias, methods, "second_output", "secondary")]
85
+
86
+
87
+ class Button(Feature):
88
+ def __init__(
89
+ self,
90
+ product: "Box",
91
+ alias: str,
92
+ methods: dict,
93
+ query_string: str,
94
+ api_command: str = "set",
95
+ ) -> None:
96
+ super().__init__(product, alias, methods)
97
+ self._device_class = "UPDATE"
98
+ self._query_string: str = query_string
99
+ self._api_command: str = api_command
100
+
101
+ @classmethod
102
+ def many_from_config(
103
+ cls, product: "Box", box_type_config: list, extended_state: Any
104
+ ) -> list["Button"]:
105
+ if not isinstance(extended_state, dict):
106
+ return []
107
+
108
+ features: list[Button] = []
109
+ for alias, methods, buttons in box_type_config:
110
+ features.extend(
111
+ buttons.many_from_extended_state(
112
+ cls, product, alias, methods, extended_state
113
+ )
114
+ )
115
+ return features
116
+
117
+ async def set(self) -> None:
118
+ await self.async_api_command(self._api_command, self.query_string)
119
+
120
+ def after_update(self) -> None:
121
+ pass
122
+
123
+ @property
124
+ def control_type(self) -> Optional[ControlType]:
125
+ """Return icon for endpoint."""
126
+ if "up" in self.query_string:
127
+ return ControlType.UP
128
+ elif "down" in self.query_string:
129
+ return ControlType.DOWN
130
+ elif "fav" in self.query_string:
131
+ return ControlType.FAVORITE
132
+ elif "open" in self.query_string:
133
+ return ControlType.OPEN
134
+ elif "close" in self.query_string:
135
+ return ControlType.CLOSE
136
+ else:
137
+ return None
138
+
139
+ @property
140
+ def query_string(self) -> str:
141
+ return self._query_string
@@ -65,6 +65,19 @@ class GateBoxControlType(IntEnum):
65
65
  OPEN_CLOSE = 2
66
66
 
67
67
 
68
+ class GateBoxExtraButtonType(IntEnum):
69
+ """GateBoxExtraButtonType defines gateBox extra button semantics.
70
+
71
+ Only meaningful outside OPEN_CLOSE mode, which takes the extra button output
72
+ for the close action and leaves this field unused.
73
+ """
74
+
75
+ DISABLED = 0
76
+ STOP = 1
77
+ WALK_IN = 2
78
+ OTHER = 3
79
+
80
+
68
81
  class GateBoxGateType(IntEnum):
69
82
  """GateBoxGateType defines possible gate/cover types reported by gateBox"""
70
83
 
@@ -305,7 +318,7 @@ class GateBox(Gate):
305
318
  if button_type is None:
306
319
  return False
307
320
 
308
- return button_type == 1
321
+ return button_type == GateBoxExtraButtonType.STOP
309
322
 
310
323
 
311
324
  class GateBoxB(GateBox):
@@ -328,9 +341,23 @@ class GateBoxB(GateBox):
328
341
  return raw_value("position")
329
342
 
330
343
  def read_has_stop(self, alias: str, raw_value: Any, product: "Box") -> bool:
344
+ if product.last_data is None:
345
+ return False
346
+
331
347
  # note: if control type is unknown we assume it is not open/close
332
348
  # and has the stop feature via secondary button command.
333
- return self._control_type != GateBoxControlType.OPEN_CLOSE
349
+ if self._control_type == GateBoxControlType.OPEN_CLOSE:
350
+ return False
351
+
352
+ # stop_command issues the same secondary command as the second_output button.
353
+ extra_button_type = raw_value("extra_button_type")
354
+ if extra_button_type in (
355
+ GateBoxExtraButtonType.WALK_IN,
356
+ GateBoxExtraButtonType.OTHER,
357
+ ):
358
+ return False
359
+
360
+ return True
334
361
 
335
362
  def read_cover_type(
336
363
  self, alias: str, raw_value: Any, product: "Box"
@@ -39,7 +39,6 @@ class UnsupportedBoxVersion(BoxError):
39
39
  pass
40
40
 
41
41
 
42
-
43
42
  class BadFieldExceedsMax(BoxError):
44
43
  def __init__(self, dev_name: str, field: str, value: int, max_value: int):
45
44
  self._dev_name = dev_name
@@ -103,7 +102,6 @@ class BadFieldNotRGBW(BoxError):
103
102
  return f"{self._dev_name}.{self._field} is {self._value} which is not a rgbw string"
104
103
 
105
104
 
106
-
107
105
  # misc errors
108
106
 
109
107
 
@@ -98,7 +98,7 @@ class ApiHost:
98
98
  try:
99
99
  return await self.async_request(path, self._session.get)
100
100
  except Exception as ex:
101
- logger.error(f"EXCEPTION DURING API CALL: {ex}")
101
+ logger.debug(f"EXCEPTION DURING API CALL: {ex}")
102
102
  raise ex
103
103
 
104
104
  async def async_api_get_ota(self, path: str) -> Optional[dict]:
@@ -107,7 +107,7 @@ class ApiHost:
107
107
  path, self._session.get, allow_ota_check_response=True
108
108
  )
109
109
  except Exception as ex:
110
- logger.error(f"EXCEPTION DURING API CALL: {ex}")
110
+ logger.debug(f"EXCEPTION DURING API CALL: {ex}")
111
111
  raise ex
112
112
 
113
113
  async def async_api_post(
@@ -1,22 +1,36 @@
1
- Metadata-Version: 2.1
2
- Name: blebox-uniapi
3
- Version: 2.5.5
1
+ Metadata-Version: 2.4
2
+ Name: blebox_uniapi
3
+ Version: 2.5.6
4
4
  Summary: Python API for accessing BleBox smart home devices
5
5
  Home-page: https://github.com/blebox/blebox_uniapi
6
6
  Author: BleBox
7
7
  Author-email: opensource@blebox.eu
8
8
  License: Apache Software License 2.0
9
9
  Keywords: blebox_uniapi
10
- Platform: UNKNOWN
11
10
  Classifier: Development Status :: 2 - Pre-Alpha
12
11
  Classifier: Intended Audience :: Developers
13
12
  Classifier: License :: OSI Approved :: Apache Software License
14
13
  Classifier: Natural Language :: English
15
14
  Classifier: Programming Language :: Python :: 3.9
16
15
  Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
17
  Requires-Python: >=3.9
18
18
  Description-Content-Type: text/x-rst
19
19
  License-File: LICENSE
20
+ Requires-Dist: aiohttp>=3
21
+ Requires-Dist: jmespath>1.0.0
22
+ Dynamic: author
23
+ Dynamic: author-email
24
+ Dynamic: classifier
25
+ Dynamic: description
26
+ Dynamic: description-content-type
27
+ Dynamic: home-page
28
+ Dynamic: keywords
29
+ Dynamic: license
30
+ Dynamic: license-file
31
+ Dynamic: requires-dist
32
+ Dynamic: requires-python
33
+ Dynamic: summary
20
34
 
21
35
  ====================
22
36
  BleBox Python UniAPI
@@ -227,5 +241,3 @@ History
227
241
  ------------------
228
242
 
229
243
  * First release on PyPI.
230
-
231
-
@@ -35,4 +35,16 @@ docs/index.rst
35
35
  docs/installation.rst
36
36
  docs/make.bat
37
37
  docs/readme.rst
38
- docs/usage.rst
38
+ docs/usage.rst
39
+ tests/test_box.py
40
+ tests/test_box_types.py
41
+ tests/test_button.py
42
+ tests/test_climate.py
43
+ tests/test_cover.py
44
+ tests/test_light.py
45
+ tests/test_light_unit.py
46
+ tests/test_sensor.py
47
+ tests/test_session.py
48
+ tests/test_switch.py
49
+ tests/test_unknown.py
50
+ tests/test_update.py
@@ -1,7 +1,8 @@
1
1
  [bumpversion]
2
- current_version = 2.5.5
2
+ current_version = 2.5.6
3
3
  commit = True
4
4
  tag = True
5
+ tag_name = v{new_version}
5
6
 
6
7
  [bumpversion:file:setup.py]
7
8
  search = version="{current_version}"
@@ -23,7 +23,7 @@ test_requirements = [
23
23
  ]
24
24
 
25
25
  setup(
26
- version="2.5.5",
26
+ version="2.5.6",
27
27
  author="BleBox",
28
28
  author_email="opensource@blebox.eu",
29
29
  python_requires=">=3.9",
@@ -34,6 +34,7 @@ setup(
34
34
  "Natural Language :: English",
35
35
  "Programming Language :: Python :: 3.9",
36
36
  "Programming Language :: Python :: 3.10",
37
+ "Programming Language :: Python :: 3.11",
37
38
  ],
38
39
  description="Python API for accessing BleBox smart home devices",
39
40
  install_requires=requirements,
@@ -0,0 +1,236 @@
1
+ import pytest
2
+ from unittest import mock
3
+ from unittest.mock import AsyncMock, patch
4
+ from blebox_uniapi.box import Box
5
+ from blebox_uniapi import error
6
+ from blebox_uniapi.jfollow import follow
7
+
8
+ pytestmark = pytest.mark.asyncio
9
+
10
+
11
+ @pytest.fixture
12
+ def mock_session():
13
+ return mock.MagicMock(host="172.1.2.3", port=80)
14
+
15
+
16
+ @pytest.fixture
17
+ def sample_data():
18
+ return {
19
+ "id": "abcd1234ef",
20
+ "type": "airSensor",
21
+ "deviceName": "foobar",
22
+ "fv": "1.23",
23
+ "hv": "4.56",
24
+ "apiLevel": "20180403",
25
+ }
26
+
27
+
28
+ @pytest.fixture
29
+ def config(sample_data):
30
+ return Box._match_device_config(sample_data)
31
+
32
+
33
+ async def test_without_type(mock_session, sample_data, config):
34
+ del sample_data["type"]
35
+
36
+ with pytest.raises(error.UnsupportedBoxResponse, match="has no type"):
37
+ Box(mock_session, sample_data, config, None)
38
+
39
+
40
+ async def test_with_unknown_type(mock_session, sample_data):
41
+ sample_data["type"] = "unknownBox"
42
+
43
+ with pytest.raises(error.UnsupportedBoxResponse, match="not a supported type"):
44
+ Box._match_device_config(sample_data)
45
+
46
+
47
+ async def test_without_name(mock_session, sample_data, config):
48
+ del sample_data["deviceName"]
49
+
50
+ with pytest.raises(error.UnsupportedBoxResponse, match="has no name"):
51
+ Box(mock_session, sample_data, config, None)
52
+
53
+
54
+ async def test_without_firmware_version(mock_session, sample_data, config):
55
+ del sample_data["fv"]
56
+
57
+ with pytest.raises(error.UnsupportedBoxResponse, match="has no firmware version"):
58
+ Box(mock_session, sample_data, config, None)
59
+
60
+
61
+ async def test_without_hardware_version(mock_session, sample_data, config):
62
+ del sample_data["hv"]
63
+
64
+ with pytest.raises(error.UnsupportedBoxResponse, match="has no hardware version"):
65
+ Box(mock_session, sample_data, config, None)
66
+
67
+
68
+ async def test_without_api_level(mock_session, sample_data, config):
69
+ del sample_data["apiLevel"]
70
+
71
+ with pytest.raises(error.UnsupportedBoxVersion, match=r"unsupported version"):
72
+ Box._match_device_config(sample_data)
73
+
74
+
75
+ async def test_json_path_extraction(mock_session, sample_data, config):
76
+ # note: follow is thin wrapper over jmespath so there's no real reason to test it.
77
+ # However, this tests makes sure we understand the syntax and know how it works.
78
+ # We can also use it as a canary for any unexpected change in syntax that may come
79
+ # in newer version.
80
+
81
+ # succesfull extraction
82
+ assert follow(["foo"], "[0]") == "foo"
83
+ assert follow([{"foo": "3", "value": 4}], "[?foo=='3'].value") == [4]
84
+
85
+ # "dud" extraction
86
+ assert follow([{"foo": "ab", "value": 4}], "[?foo=='bc'].value") == []
87
+ assert follow([{"value": 4}], "[1].value") is None
88
+ assert follow({"value": 4}, "foo") is None
89
+ assert follow({"foo": [4]}, "[?bar==`0`].value") is None
90
+
91
+
92
+ async def test_missing_device_id(mock_session, sample_data, config):
93
+ del sample_data["id"]
94
+ with pytest.raises(error.UnsupportedBoxResponse, match="has no id"):
95
+ Box(mock_session, sample_data, config, None)
96
+
97
+
98
+ # Add more test cases for other missing fields (type, name, versions, etc.)
99
+
100
+
101
+ async def test_invalid_init(mock_session, sample_data, config):
102
+ with mock.patch(
103
+ "blebox_uniapi.sensor.SensorFactory.many_from_config",
104
+ spec_set=True,
105
+ autospec=True,
106
+ ) as mock_sensor:
107
+ mock_sensor.side_effect = KeyError
108
+ with pytest.raises(
109
+ error.UnsupportedBoxResponse, match=r"Failed to initialize:"
110
+ ):
111
+ Box(mock_session, sample_data, config, None)
112
+
113
+
114
+ async def test_properties(mock_session, sample_data, config):
115
+ box = Box(mock_session, sample_data, config, None)
116
+ assert box.name == "foobar"
117
+ assert box.last_data is None
118
+ assert box.type == "airSensor"
119
+ assert box.model == "airSensor"
120
+ assert box.product == "airSensor"
121
+ assert box.unique_id == "abcd1234ef"
122
+ assert box.firmware_version == "1.23"
123
+ assert box.hardware_version == "4.56"
124
+ assert box.brand == "BleBox"
125
+ assert box.api_version == 20180403
126
+ assert box.address == "172.1.2.3:80"
127
+
128
+
129
+ async def test_field_validations(mock_session, sample_data, config):
130
+ box = Box(mock_session, sample_data, config, None)
131
+
132
+ with pytest.raises(
133
+ error.BadFieldExceedsMax,
134
+ match=r"foobar.field1 is 123 which exceeds max \(100\)",
135
+ ):
136
+ box.check_int_range(123, "field1", 100, 0)
137
+
138
+ with pytest.raises(
139
+ error.BadFieldLessThanMin,
140
+ match=r"foobar.field1 is 123 which is less than minimum \(200\)",
141
+ ):
142
+ box.check_int_range(123, "field1", 300, 200)
143
+
144
+ with pytest.raises(error.BadFieldMissing, match=r"foobar.field1 is missing"):
145
+ box.check_int(None, "field1", 300, 200)
146
+
147
+ with pytest.raises(
148
+ error.BadFieldNotANumber, match=r"foobar.field1 is '123' which is not a number"
149
+ ):
150
+ box.check_int("123", "field1", 300, 200)
151
+
152
+ with pytest.raises(error.BadFieldMissing, match=r"foobar.field1 is missing"):
153
+ box.check_hex_str(None, "field1", 300, 200)
154
+
155
+ with pytest.raises(
156
+ error.BadFieldNotAString, match=r"foobar.field1 is 123 which is not a string"
157
+ ):
158
+ box.check_hex_str(123, "field1", 300, 200)
159
+
160
+ with pytest.raises(error.BadFieldMissing, match=r"foobar.field1 is missing"):
161
+ box.check_rgbw(None, "field1")
162
+
163
+ with pytest.raises(
164
+ error.BadFieldNotAString, match=r"foobar.field1 is 123 which is not a string"
165
+ ):
166
+ box.check_rgbw(123, "field1")
167
+
168
+ with pytest.raises(
169
+ error.BadFieldNotRGBW, match=r"foobar.field1 is 123 which is not a rgbw string"
170
+ ):
171
+ box.check_rgbw("123", "field1")
172
+
173
+
174
+ async def test_available_firmware_version_none(mock_session, sample_data, config):
175
+ box = Box(mock_session, sample_data, config, None)
176
+ assert box.available_firmware_version is None
177
+
178
+
179
+ async def test_available_firmware_version_set(mock_session, sample_data, config):
180
+ sample_data["availableFv"] = "2.0"
181
+ box = Box(mock_session, sample_data, config, None)
182
+ assert box.available_firmware_version == "2.0"
183
+
184
+
185
+ async def test_async_ota_update_calls_session(mock_session, sample_data, config):
186
+ box = Box(mock_session, sample_data, config, None)
187
+ mock_session.async_api_get = AsyncMock(return_value=None)
188
+ await box.async_ota_update()
189
+ mock_session.async_api_get.assert_called_once_with("/api/ota/update")
190
+
191
+
192
+ async def test_async_ota_check_updates_firmware_versions(
193
+ mock_session, sample_data, config
194
+ ):
195
+ box = Box(mock_session, sample_data, config, None)
196
+ mock_session.async_api_get_ota = AsyncMock(return_value=None)
197
+ mock_session.async_api_get = AsyncMock(
198
+ return_value={"availableFv": "2.0", "fv": "1.5"}
199
+ )
200
+ with patch("asyncio.sleep", new=AsyncMock()):
201
+ await box.async_ota_check()
202
+ assert box.available_firmware_version == "2.0"
203
+ assert box.firmware_version == "1.5"
204
+
205
+
206
+ async def test_async_ota_check_unwraps_device_key(mock_session, sample_data, config):
207
+ box = Box(mock_session, sample_data, config, None)
208
+ mock_session.async_api_get_ota = AsyncMock(return_value=None)
209
+ mock_session.async_api_get = AsyncMock(
210
+ return_value={"device": {"availableFv": "3.0", "fv": "2.5"}}
211
+ )
212
+ with patch("asyncio.sleep", new=AsyncMock()):
213
+ await box.async_ota_check()
214
+ assert box.available_firmware_version == "3.0"
215
+
216
+
217
+ async def test_async_ota_check_raises_on_none_info_response(
218
+ mock_session, sample_data, config
219
+ ):
220
+ box = Box(mock_session, sample_data, config, None)
221
+ mock_session.async_api_get_ota = AsyncMock(return_value=None)
222
+ mock_session.async_api_get = AsyncMock(return_value=None)
223
+ with patch("asyncio.sleep", new=AsyncMock()):
224
+ with pytest.raises(error.UnsupportedBoxResponse):
225
+ await box.async_ota_check()
226
+
227
+
228
+ async def test_async_ota_check_returns_silently_when_no_available_fv(
229
+ mock_session, sample_data, config
230
+ ):
231
+ box = Box(mock_session, sample_data, config, None)
232
+ mock_session.async_api_get_ota = AsyncMock(return_value=None)
233
+ mock_session.async_api_get = AsyncMock(return_value={"fv": "1.0"})
234
+ with patch("asyncio.sleep", new=AsyncMock()):
235
+ await box.async_ota_check()
236
+ assert box.available_firmware_version is None