diff --git a/blebox_uniapi/box.py b/blebox_uniapi/box.py index 6187e71..bcbf32d 100644 --- a/blebox_uniapi/box.py +++ b/blebox_uniapi/box.py @@ -37,6 +37,8 @@ class Box: _name: str _data_path: str _last_real_update: Optional[float] + _last_data: Optional[Any] + api_session: ApiHost info: dict config: dict @@ -49,6 +51,7 @@ def __init__( config, extended_state, ) -> None: + self._last_data = None self._last_real_update = None self._sem = asyncio.BoundedSemaphore() self._session = api_session @@ -243,6 +246,38 @@ async def async_update_data(self) -> None: await self._async_api(True, "GET", self._data_path) def _update_last_data(self, new_data: Optional[dict]) -> None: + # Note: on certain more complex devices that inlcude multiple features + # like switches and sensors (e.g. SwitchboxD) it may happen that activating + # single feature would result only in partial update of the self._last_data. + # We can know that by comparing keys of both states. + # + # Notable example of device exhibiting this behavior is SwitchboxD (20200831) + # It has two relays (switches) and power measurement capabilities (sensor). + # Toggling the relay does return state of all relays, but does not return + # sensor information (partial state). Accepting the new state as-is would + # break the update of sensory information. + # + # Note that SwitchboxD is just an example. It is possible that APIs of other + # box types also exhibit this kind of behavior. + if ( + isinstance(self._last_data, dict) and + isinstance(new_data, dict) and + self._last_data.keys() != new_data.keys() + ): + # ... In such a case we need to merge both states instead of overwriting + # the old one as-is. + # + # The only risk is that if certain features are somehow coupled inside + # the device, we will have inconsistent information about the device. + # However, this should be eventually consistent as new updates arrive. + # In the end, it is better to have an inconsistent state, rather than + # have broken one (e.g. missing keys) that results in broken features. + # + # Refs: + # - https://github.com/blebox/blebox_uniapi/pull/152 + # - https://github.com/blebox/blebox_uniapi/issues/137 + new_data = {**self._last_data, **new_data} + self._last_data = new_data for feature_set in self._features.values(): for feature in feature_set: @@ -255,7 +290,7 @@ async def async_api_command(self, command: str, value: Any = None) -> None: def follow(self, data: dict, path: str) -> Any: """ - Return payloadu from device response json. + Return payload from device response json. :param self: :param data: :param path: @@ -268,8 +303,7 @@ def follow(self, data: dict, path: str) -> Any: current_tree = data for chunk in results: - with_string_value = re.compile("^\\[(.*)='(.*)'\\]$") - + with_string_value = re.compile(r"^\[(.*)='(.*)']$") match = with_string_value.match(chunk) if match: name = match.group(1) @@ -288,7 +322,7 @@ def follow(self, data: dict, path: str) -> Any: continue # pragma: no cover - with_int_value = re.compile("^\\[(.*)=(\\d+)\\]$") + with_int_value = re.compile(r"^\[(.*)=(\d+)]$") match = with_int_value.match(chunk) if match: name = match.group(1) @@ -311,7 +345,7 @@ def follow(self, data: dict, path: str) -> Any: raise JPathFailed(f"with: {name}={value}", path, data) continue # pragma: no cover - with_index = re.compile("^\\[(\\d+)\\]$") + with_index = re.compile(r"^\[(\d+)]$") match = with_index.match(chunk) if match: index = int(match.group(1)) diff --git a/blebox_uniapi/box_types.py b/blebox_uniapi/box_types.py index adfed6d..46e3481 100644 --- a/blebox_uniapi/box_types.py +++ b/blebox_uniapi/box_types.py @@ -245,17 +245,6 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]: } }, "switchBox": { - 20220114: { - "api_path": "/state", - "extended_state_path": "/state/extended", - "api": { - "on": lambda x=None: ("GET", f"/s/{x}/1", None), - "off": lambda x=None: ("GET", f"/s/{x}/0", None), - }, - "switches": [ - ["relay", {"state": lambda x: f"relays/[relay={x}]/state"}, "relay"] - ], - }, 20180604: { "model": "switchBox", "api_path": "/api/relay/state", @@ -265,6 +254,7 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]: "off": lambda x=None: ("GET", "/s/0", None), }, "switches": [["0.relay", {"state": "[relay=0]/state"}, "relay"]], + # note: does not support power measurement }, 20190808: { "api_path": "/api/relay/extended/state", @@ -287,11 +277,107 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]: ] ], }, + 20200229: { + "extended_state_path": "/state/extended", + "api_path": "/state/extended", + "api": { + "on": lambda x=None: ("GET", "/s/1", None), + "off": lambda x=None: ("GET", "/s/0", None), + }, + "switches": [ + ["0.relay", {"state": lambda x: f"relays/[relay={x}]/state"}, "relay"] + ], + "sensors": [ + [ + "switchBox.energy", + { + "energy": "powerMeasuring/powerConsumption/[0]/value", + "periodS": "powerMeasuring/powerConsumption/[0]/periodS", + "measurment_enabled": "powerMeasuring/enabled", + }, + ] + ], + }, + 20200831: { + "extended_state_path": "/state/extended", + "api_path": "/state/extended", + "api": { + "on": lambda x=None: ("GET", "/s/1", None), + "off": lambda x=None: ("GET", "/s/0", None), + }, + "switches": [ + ["0.relay", {"state": lambda x: f"relays/[relay={x}]/state"}, "relay"] + ], + "sensors": [ + [ + "switchBox.energy", + { + "energy": "powerMeasuring/powerConsumption/[0]/value", + "periodS": "powerMeasuring/powerConsumption/[0]/periodS", + "measurment_enabled": "powerMeasuring/enabled", + }, + ] + ], + }, + 20220114: { + "api_path": "/state/extended", + "extended_state_path": "/state/extended", + "api": { + # note: old control api (i.e. /s/0, /s/1, /s/2) still supported but + # now deprecated. switchBox has now API consistent with switchBoxD + "on": lambda x=None: ("GET", f"/s/{x}/1", None), + "off": lambda x=None: ("GET", f"/s/{x}/0", None), + }, + "switches": [ + ["relay", {"state": lambda x: f"relays/[relay={x}]/state"}, "relay"] + ], + "sensors": [ + [ + "switchBox.energy", + { + "energy": "powerMeasuring/powerConsumption/[0]/value", + "periodS": "powerMeasuring/powerConsumption/[0]/periodS", + "measurment_enabled": "powerMeasuring/enabled", + }, + ] + ], + }, }, - # switchBoxD "switchBoxD": { 20190808: { - "extended_state_path": "/state/extended", # tylko dla testów do usunięcia nie w tym api + "extended_state_path": "/api/relay/extended/state", + "api_path": "/api/relay/extended/state", + "api": { + "on": lambda x: ("GET", f"/s/{int(x)}/1", None), + "off": lambda x=None: ("GET", f"/s/{int(x)}/0", None), + }, + "switches": [ + [ + "0.relay", + {"state": lambda x: f"relays/[relay={x}]/state"}, + "relay", + 0, + ], + [ + "1.relay", + {"state": lambda x: f"relays/[relay={x}]/state"}, + "relay", + 1, + ], + ], + "sensors": [ + [ + "switchBox.energy", + { + "energy": "powerMeasuring/powerConsumption/[0]/value", + "periodS": "powerMeasuring/powerConsumption/[0]/periodS", + "measurment_enabled": "powerMeasuring/enabled", + }, + ] + ], + }, + 20200229: { + "extended_state_path": "/state/extended", "api_path": "/state/extended", "api": { "on": lambda x: ("GET", f"/s/{int(x)}/1", None), @@ -321,7 +407,39 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]: }, ] ], - } + }, + 20200831: { + "extended_state_path": "/state/extended", + "api_path": "/state/extended", + "api": { + "on": lambda x: ("GET", f"/s/{int(x)}/1", None), + "off": lambda x=None: ("GET", f"/s/{int(x)}/0", None), + }, + "switches": [ + [ + "0.relay", + {"state": lambda x: f"relays/[relay={x}]/state"}, + "relay", + 0, + ], + [ + "1.relay", + {"state": lambda x: f"relays/[relay={x}]/state"}, + "relay", + 1, + ], + ], + "sensors": [ + [ + "switchBox.energy", + { + "energy": "powerMeasuring/powerConsumption/[0]/value", + "periodS": "powerMeasuring/powerConsumption/[0]/periodS", + "measurment_enabled": "powerMeasuring/enabled", + }, + ] + ], + }, }, # tempSensor "tempSensor": { diff --git a/tests/test_switch.py b/tests/test_switch.py index 76905ff..9528e03 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -286,7 +286,7 @@ class TestSwitchBoxD(DefaultBoxTest): "fv": "0.200", "hv": "0.7", "id": "1afe34e750b8", - "apiLevel": "20190808" + "apiLevel": "20200831" } } """