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.
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/CONTRIBUTING.rst +8 -4
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/MANIFEST.in +0 -1
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/PKG-INFO +17 -5
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/__init__.py +1 -1
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/box.py +6 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/box_types.py +7 -3
- blebox_uniapi-2.5.6/blebox_uniapi/button.py +141 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/cover.py +29 -2
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/error.py +0 -2
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/session.py +2 -2
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi.egg-info/PKG-INFO +18 -6
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi.egg-info/SOURCES.txt +13 -1
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/setup.cfg +2 -1
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/setup.py +2 -1
- blebox_uniapi-2.5.6/tests/test_box.py +236 -0
- blebox_uniapi-2.5.6/tests/test_box_types.py +80 -0
- blebox_uniapi-2.5.6/tests/test_button.py +266 -0
- blebox_uniapi-2.5.6/tests/test_climate.py +386 -0
- blebox_uniapi-2.5.6/tests/test_cover.py +962 -0
- blebox_uniapi-2.5.6/tests/test_light.py +1369 -0
- blebox_uniapi-2.5.6/tests/test_light_unit.py +141 -0
- blebox_uniapi-2.5.6/tests/test_sensor.py +477 -0
- blebox_uniapi-2.5.6/tests/test_session.py +233 -0
- blebox_uniapi-2.5.6/tests/test_switch.py +447 -0
- blebox_uniapi-2.5.6/tests/test_unknown.py +59 -0
- blebox_uniapi-2.5.6/tests/test_update.py +70 -0
- blebox_uniapi-2.5.5/blebox_uniapi/button.py +0 -75
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/HISTORY.rst +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/LICENSE +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/README.rst +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/binary_sensor.py +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/climate.py +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/feature.py +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/jfollow.py +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/light.py +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/sensor.py +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/switch.py +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi/update.py +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi.egg-info/dependency_links.txt +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi.egg-info/not-zip-safe +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi.egg-info/requires.txt +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/blebox_uniapi.egg-info/top_level.txt +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/Makefile +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/authors.rst +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/conf.py +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/contributing.rst +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/history.rst +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/index.rst +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/installation.rst +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/make.bat +0 -0
- {blebox_uniapi-2.5.5 → blebox_uniapi-2.5.6}/docs/readme.rst +0 -0
- {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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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,22 +1,36 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: blebox_uniapi
|
|
3
|
-
Version: 2.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
|
-
|
|
@@ -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
|
-
|
|
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 ==
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
2
|
-
Name:
|
|
3
|
-
Version: 2.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
|
|
@@ -23,7 +23,7 @@ test_requirements = [
|
|
|
23
23
|
]
|
|
24
24
|
|
|
25
25
|
setup(
|
|
26
|
-
version="2.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
|