blebox-uniapi 2.5.6__tar.gz → 2.5.8__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 (51) hide show
  1. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/PKG-INFO +1 -1
  2. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/__init__.py +1 -1
  3. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/box.py +4 -1
  4. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/box_types.py +37 -1
  5. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/climate.py +38 -7
  6. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/sensor.py +100 -20
  7. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi.egg-info/PKG-INFO +1 -1
  8. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/setup.cfg +1 -1
  9. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/setup.py +1 -1
  10. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/tests/test_box.py +18 -0
  11. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/tests/test_climate.py +39 -1
  12. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/tests/test_sensor.py +207 -0
  13. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/CONTRIBUTING.rst +0 -0
  14. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/HISTORY.rst +0 -0
  15. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/LICENSE +0 -0
  16. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/MANIFEST.in +0 -0
  17. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/README.rst +0 -0
  18. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/binary_sensor.py +0 -0
  19. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/button.py +0 -0
  20. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/cover.py +0 -0
  21. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/error.py +0 -0
  22. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/feature.py +0 -0
  23. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/jfollow.py +0 -0
  24. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/light.py +0 -0
  25. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/session.py +0 -0
  26. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/switch.py +0 -0
  27. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi/update.py +0 -0
  28. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi.egg-info/SOURCES.txt +0 -0
  29. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi.egg-info/dependency_links.txt +0 -0
  30. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi.egg-info/not-zip-safe +0 -0
  31. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi.egg-info/requires.txt +0 -0
  32. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/blebox_uniapi.egg-info/top_level.txt +0 -0
  33. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/docs/Makefile +0 -0
  34. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/docs/authors.rst +0 -0
  35. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/docs/conf.py +0 -0
  36. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/docs/contributing.rst +0 -0
  37. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/docs/history.rst +0 -0
  38. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/docs/index.rst +0 -0
  39. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/docs/installation.rst +0 -0
  40. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/docs/make.bat +0 -0
  41. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/docs/readme.rst +0 -0
  42. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/docs/usage.rst +0 -0
  43. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/tests/test_box_types.py +0 -0
  44. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/tests/test_button.py +0 -0
  45. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/tests/test_cover.py +0 -0
  46. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/tests/test_light.py +0 -0
  47. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/tests/test_light_unit.py +0 -0
  48. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/tests/test_session.py +0 -0
  49. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/tests/test_switch.py +0 -0
  50. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/tests/test_unknown.py +0 -0
  51. {blebox_uniapi-2.5.6 → blebox_uniapi-2.5.8}/tests/test_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blebox_uniapi
3
- Version: 2.5.6
3
+ Version: 2.5.8
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
@@ -2,4 +2,4 @@
2
2
 
3
3
  __author__ = """BleBox"""
4
4
  __email__ = "opensource@blebox.eu"
5
- __version__ = "2.5.6"
5
+ __version__ = "2.5.8"
@@ -387,7 +387,10 @@ class Box:
387
387
  await self._session.async_api_get_ota("/api/ota/check")
388
388
  for _ in range(3):
389
389
  await asyncio.sleep(1)
390
- response = await self._session.async_api_get("/info")
390
+ try:
391
+ response = await self._session.async_api_get("/api/device/state")
392
+ except HttpError:
393
+ response = await self._session.async_api_get("/info")
391
394
  if response is None:
392
395
  raise UnsupportedBoxResponse("Device returned non-JSON response")
393
396
  response = response.get("device", response)
@@ -191,6 +191,7 @@ BOX_TYPE_CONF: dict[str, dict[int, dict[str, Any]]] = {
191
191
  "minimum": "thermo.minimumTemp",
192
192
  "maximum": "thermo.maximumTemp",
193
193
  "temperature": lambda x: f"sensors[?id == `{x}`]|[0]|value",
194
+ "temperature.state": lambda x: f"sensors[?id == `{x}`]|[0]|state",
194
195
  "state": "thermo.state",
195
196
  "mode": "thermo.mode",
196
197
  "safetySensorId": "thermo.safetyTempSensor.sensorId",
@@ -203,6 +204,7 @@ BOX_TYPE_CONF: dict[str, dict[int, dict[str, Any]]] = {
203
204
  "temperature",
204
205
  {
205
206
  "temperature": lambda x: f"sensors[?id == `{x}`]|[0]|value",
207
+ "temperature.state": lambda x: f"sensors[?id == `{x}`]|[0]|state",
206
208
  },
207
209
  ]
208
210
  ],
@@ -530,7 +532,7 @@ BOX_TYPE_CONF: dict[str, dict[int, dict[str, Any]]] = {
530
532
  {
531
533
  "temperature": "tempSensor.sensors[?id == `0`]|[0]|value",
532
534
  "trend": "tempSensor.sensors[?id == `0`]|[0]|trend",
533
- "state": "tempSensor.sensors[?id == `0`]|[0]|state",
535
+ "temperature.state": "tempSensor.sensors[?id == `0`]|[0]|state",
534
536
  "elapsed": "tempSensor.sensors[?id == `0`]|[0]|elapsedTimeS",
535
537
  },
536
538
  ]
@@ -682,7 +684,9 @@ BOX_TYPE_CONF: dict[str, dict[int, dict[str, Any]]] = {
682
684
  "multiSensor",
683
685
  {
684
686
  "temperature": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value",
687
+ "temperature.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state",
685
688
  "wind": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value",
689
+ "wind.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state",
686
690
  },
687
691
  ]
688
692
  ],
@@ -704,8 +708,11 @@ BOX_TYPE_CONF: dict[str, dict[int, dict[str, Any]]] = {
704
708
  "multiSensor",
705
709
  {
706
710
  "temperature": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value",
711
+ "temperature.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state",
707
712
  "wind": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value",
713
+ "wind.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state",
708
714
  "humidity": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value",
715
+ "humidity.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state",
709
716
  },
710
717
  ]
711
718
  ],
@@ -727,9 +734,13 @@ BOX_TYPE_CONF: dict[str, dict[int, dict[str, Any]]] = {
727
734
  "multiSensor",
728
735
  {
729
736
  "illuminance": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value",
737
+ "illuminance.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state",
730
738
  "temperature": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value",
739
+ "temperature.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state",
731
740
  "wind": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'wind']|[0]|value",
741
+ "wind.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'wind']|[0]|state",
732
742
  "humidity": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value",
743
+ "humidity.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state",
733
744
  },
734
745
  ]
735
746
  ],
@@ -751,22 +762,47 @@ BOX_TYPE_CONF: dict[str, dict[int, dict[str, Any]]] = {
751
762
  "multiSensor",
752
763
  {
753
764
  "frequency": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'frequency']|[0]|value",
765
+ "frequency.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'frequency']|[0]|state",
754
766
  "current": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'current']|[0]|value",
767
+ "current.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'current']|[0]|state",
755
768
  "voltage": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'voltage']|[0]|value",
769
+ "voltage.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'voltage']|[0]|state",
756
770
  "apparentPower": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'apparentPower']|[0]|value",
771
+ "apparentPower.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'apparentPower']|[0]|state",
757
772
  "activePower": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'activePower']|[0]|value",
773
+ "activePower.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'activePower']|[0]|state",
758
774
  "reactivePower": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reactivePower']|[0]|value",
775
+ "reactivePower.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reactivePower']|[0]|state",
759
776
  "reverseActiveEnergy": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reverseActiveEnergy']|[0]|value",
777
+ "reverseActiveEnergy.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reverseActiveEnergy']|[0]|state",
760
778
  "forwardActiveEnergy": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'forwardActiveEnergy']|[0]|value",
779
+ "forwardActiveEnergy.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'forwardActiveEnergy']|[0]|state",
761
780
  "forwardReactiveEnergy": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'forwardReactiveEnergy']|[0]|value",
781
+ "forwardReactiveEnergy.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'forwardReactiveEnergy']|[0]|state",
762
782
  "reverseReactiveEnergy": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reverseReactiveEnergy']|[0]|value",
783
+ "reverseReactiveEnergy.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reverseReactiveEnergy']|[0]|state",
763
784
  "illuminance": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'illuminance']|[0]|value",
785
+ "illuminance.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'illuminance']|[0]|state",
764
786
  "temperature": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'temperature']|[0]|value",
787
+ "temperature.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'temperature']|[0]|state",
765
788
  "wind": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'wind']|[0]|value",
789
+ "wind.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'wind']|[0]|state",
766
790
  "humidity": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'humidity']|[0]|value",
791
+ "humidity.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'humidity']|[0]|state",
767
792
  "openStatus": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'openStatus']|[0]|value",
793
+ "openStatus.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'openStatus']|[0]|state",
768
794
  "co2": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'co2']|[0]|value",
795
+ "co2.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'co2']|[0]|state",
769
796
  "co2Definition": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'co2Definition']|[0]|value",
797
+ "co2Definition.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'co2Definition']|[0]|state",
798
+ "gaugePressure": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'gaugePressure']|[0]|value",
799
+ "gaugePressure.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'gaugePressure']|[0]|state",
800
+ "liquidHeight": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'liquidHeight']|[0]|value",
801
+ "liquidHeight.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'liquidHeight']|[0]|state",
802
+ "fillLevel": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'fillLevel']|[0]|value",
803
+ "fillLevel.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'fillLevel']|[0]|state",
804
+ "volume": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'volume']|[0]|value",
805
+ "volume.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'volume']|[0]|state",
770
806
  },
771
807
  ]
772
808
  ],
@@ -1,9 +1,19 @@
1
+ from enum import IntEnum
1
2
  from .sensor import Temperature
2
3
  from typing import Optional, Any, Union
3
4
  from .feature import Feature
4
5
  from blebox_uniapi.jfollow import follow
5
6
 
6
7
 
8
+ class BleboxThermoState(IntEnum):
9
+ """Possible states of a thermo regulator, as reported by the device."""
10
+
11
+ OFF = 0
12
+ ON = 1
13
+ BOOST = 3
14
+ SAFETY_OFF = 4
15
+
16
+
7
17
  class Climate(Temperature):
8
18
  _is_on: Optional[bool]
9
19
  _desired: Union[float, int, None]
@@ -90,12 +100,18 @@ class Climate(Temperature):
90
100
  raw = self.raw_value("state")
91
101
  if raw is not None: # no reading
92
102
  alias = self._alias
93
- return product.expect_int(alias, raw, 3, 0) in (
94
- 1,
95
- 3,
96
- ) # 1: On, 3: Boost(thermoBox max temp of mode)
103
+ return product.expect_int(alias, raw, max(BleboxThermoState), 0) in (
104
+ BleboxThermoState.ON,
105
+ BleboxThermoState.BOOST,
106
+ )
97
107
  return None
98
108
 
109
+ def _read_safety_off(self) -> bool:
110
+ """Return True when the safety system forced the output off."""
111
+ if self._product.last_data is None:
112
+ return False
113
+ return self.raw_value("state") == BleboxThermoState.SAFETY_OFF
114
+
99
115
  def _read_operating_state(self) -> Optional[int]:
100
116
  """Return current operating state"""
101
117
  if self._product.last_data is not None:
@@ -107,13 +123,13 @@ class Climate(Temperature):
107
123
  return None
108
124
 
109
125
  def _read_is_heating(self) -> Optional[bool]:
110
- if not self._product.last_data:
126
+ if not self._product.last_data or self.current is None or self.desired is None:
111
127
  return None
112
128
 
113
129
  return self.is_on and (self.current < self.desired)
114
130
 
115
131
  def _read_is_cooling(self) -> Optional[bool]:
116
- if not self._product.last_data:
132
+ if not self._product.last_data or self.current is None or self.desired is None:
117
133
  return None
118
134
 
119
135
  return self.is_on and (self.current > self.desired)
@@ -131,7 +147,22 @@ class Climate(Temperature):
131
147
  def after_update(self) -> None:
132
148
  self._is_on = self._read_is_on()
133
149
  self._desired = self._read_temperature("desired")
134
- self._current = self._read_temperature("temperature")
150
+
151
+ state = self._read_state("temperature")
152
+ if self._state_is_error(state):
153
+ self._error = True
154
+ self._current = None
155
+ else:
156
+ self._error = False
157
+ if self._state_is_initializing(state):
158
+ self._current = None
159
+ else:
160
+ self._current = self._read_temperature("temperature")
161
+
162
+ if self._read_safety_off():
163
+ self._error = True
164
+
165
+ self._native_value = self._current
135
166
  self._is_heating = self._read_is_heating()
136
167
  self._is_cooling = self._read_is_cooling()
137
168
  self._havc_action = self._read_operating_state()
@@ -1,5 +1,5 @@
1
1
  import datetime
2
- import numbers
2
+ from enum import IntEnum
3
3
  from functools import partial
4
4
 
5
5
  from .feature import Feature
@@ -67,10 +67,9 @@ class SensorFactory:
67
67
  # note: methods for sensor readings are provided as template
68
68
  # functions (lambdas) in the box config. We need to "materialize"
69
69
  # them to make sure they are properly indexed by sensor ID
70
- materialized_methods = {
71
- **methods,
72
- device_class: methods[device_class](sensor_id),
73
- }
70
+ materialized_methods = Feature.resolve_access_method_paths(
71
+ methods, sensor_id
72
+ )
74
73
 
75
74
  feature = constructor(
76
75
  product=product,
@@ -97,12 +96,26 @@ class SensorFactory:
97
96
  return []
98
97
 
99
98
 
99
+ class BleboxSensorState(IntEnum):
100
+ """Possible states of a sensor reading, not all valid for every device type."""
101
+
102
+ IDLE = 0
103
+ INITIALIZING = 1
104
+ ACTIVE = 2
105
+ ERROR = 3
106
+ ABOVE_RANGE = 4
107
+ BELOW_RANGE = 5
108
+ NOT_CONFIGURED = 6
109
+
110
+
100
111
  class BaseSensor(Feature):
101
112
  _unit: str
102
113
  _device_class: str
103
- _native_value: Union[float, int, str]
114
+ _native_value: Optional[Union[float, int, str]] = None
104
115
  _sensor_type: Optional[str]
105
116
  _sensor_id: Optional[int]
117
+ _error: bool = False
118
+ _needs_configuration: bool = False
106
119
 
107
120
  def __init__(
108
121
  self,
@@ -130,6 +143,31 @@ class BaseSensor(Feature):
130
143
  def native_value(self):
131
144
  return self._native_value
132
145
 
146
+ @property
147
+ def is_error(self) -> bool:
148
+ return self._error
149
+
150
+ @property
151
+ def needs_configuration(self) -> bool:
152
+ return self._needs_configuration
153
+
154
+ def _read_state(self, name: str) -> Optional[int]:
155
+ if self._product.last_data is None:
156
+ return None
157
+ raw = self.raw_value(f"{name}.state")
158
+ state = int(raw) if isinstance(raw, (int, float)) else None
159
+ self._needs_configuration = state == BleboxSensorState.NOT_CONFIGURED
160
+ return state
161
+
162
+ @staticmethod
163
+ def _state_is_error(state: Optional[int]) -> bool:
164
+ # note: unconfigured probe reports garbage values
165
+ return state in (BleboxSensorState.ERROR, BleboxSensorState.NOT_CONFIGURED)
166
+
167
+ @staticmethod
168
+ def _state_is_initializing(state: Optional[int]) -> bool:
169
+ return state == BleboxSensorState.INITIALIZING
170
+
133
171
  @property
134
172
  def sensor_id(self):
135
173
  return self._sensor_id
@@ -170,6 +208,10 @@ class BaseSensor(Feature):
170
208
  @SensorFactory.register("openStatus", unit="")
171
209
  @SensorFactory.register("co2", unit="ppm")
172
210
  @SensorFactory.register("co2Definition", unit="")
211
+ @SensorFactory.register("gaugePressure", unit="mbar")
212
+ @SensorFactory.register("liquidHeight", unit="cm")
213
+ @SensorFactory.register("fillLevel", unit="percentage", scale=10)
214
+ @SensorFactory.register("volume", unit="L")
173
215
  class GenericSensor(BaseSensor):
174
216
  def __init__(
175
217
  # base sensor params
@@ -201,9 +243,22 @@ class GenericSensor(BaseSensor):
201
243
  if product.last_data is None:
202
244
  return
203
245
 
246
+ state = self._read_state(self._device_class)
247
+ if self._state_is_error(state):
248
+ self._error = True
249
+ self._native_value = None
250
+ return
251
+
252
+ self._error = False
253
+
254
+ if self._state_is_initializing(state):
255
+ self._native_value = None
256
+ return
257
+
204
258
  raw = self.raw_value(self._device_class)
205
- if not isinstance(raw, numbers.Number):
206
- raw = float("nan")
259
+ if not isinstance(raw, (int, float)):
260
+ self._native_value = None
261
+ return
207
262
 
208
263
  native = raw / self._scale
209
264
  if self._precision:
@@ -234,7 +289,7 @@ class PowerConsumption(GenericSensor):
234
289
 
235
290
  @SensorFactory.register("temperature")
236
291
  class Temperature(BaseSensor):
237
- _current: Union[float, int, None]
292
+ _current: Optional[Union[float, int]]
238
293
 
239
294
  def __init__(
240
295
  self,
@@ -249,21 +304,35 @@ class Temperature(BaseSensor):
249
304
  self._device_class = "temperature"
250
305
 
251
306
  @property
252
- def current(self) -> Union[float, int, None]:
307
+ def current(self) -> Optional[Union[float, int]]:
253
308
  return self._current
254
309
 
255
- def _read_temperature(self, field: str) -> Union[float, int, None]:
310
+ def _read_temperature(self, field: str) -> Optional[Union[float, int]]:
256
311
  product = self._product
257
312
  if product.last_data is not None:
258
313
  raw = self.raw_value(field)
259
- if raw is not None:
260
- alias = self._alias
261
- return round(product.expect_int(alias, raw, 12500, -5500) / 100.0, 1)
314
+ if isinstance(raw, (int, float)):
315
+ return round(raw / 100.0, 1)
262
316
  return None
263
317
 
264
318
  def after_update(self) -> None:
265
- self._current = self._read_temperature("temperature")
266
- self._native_value = self._read_temperature("temperature")
319
+ state = self._read_state("temperature")
320
+ if self._state_is_error(state):
321
+ self._error = True
322
+ self._current = None
323
+ self._native_value = None
324
+ return
325
+
326
+ self._error = False
327
+
328
+ if self._state_is_initializing(state):
329
+ self._current = None
330
+ self._native_value = None
331
+ return
332
+
333
+ current = self._read_temperature("temperature")
334
+ self._current = current
335
+ self._native_value = current
267
336
 
268
337
 
269
338
  @SensorFactory.register("airSensor")
@@ -282,14 +351,25 @@ class AirQuality(BaseSensor):
282
351
  self._unit = "concentration_of_mp"
283
352
  self._device_class = alias
284
353
 
285
- def _pm_value(self, name: str) -> Optional[int]:
354
+ def _pm_value(self, name: str) -> Optional[Union[int, float]]:
286
355
  product = self._product
287
356
  if product.last_data is not None:
288
357
  raw = self.raw_value(name)
289
- if raw is not None:
290
- alias = self._alias
291
- return product.expect_int(alias, raw, 3000, 0)
358
+ if isinstance(raw, (int, float)):
359
+ return raw
292
360
  return None
293
361
 
294
362
  def after_update(self) -> None:
363
+ state = self._read_state(self.device_class)
364
+ if self._state_is_error(state):
365
+ self._error = True
366
+ self._native_value = None
367
+ return
368
+
369
+ self._error = False
370
+
371
+ if self._state_is_initializing(state):
372
+ self._native_value = None
373
+ return
374
+
295
375
  self._native_value = self._pm_value(f"{self.device_class}.value")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blebox_uniapi
3
- Version: 2.5.6
3
+ Version: 2.5.8
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
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 2.5.6
2
+ current_version = 2.5.8
3
3
  commit = True
4
4
  tag = True
5
5
  tag_name = v{new_version}
@@ -23,7 +23,7 @@ test_requirements = [
23
23
  ]
24
24
 
25
25
  setup(
26
- version="2.5.6",
26
+ version="2.5.8",
27
27
  author="BleBox",
28
28
  author_email="opensource@blebox.eu",
29
29
  python_requires=">=3.9",
@@ -234,3 +234,21 @@ async def test_async_ota_check_returns_silently_when_no_available_fv(
234
234
  with patch("asyncio.sleep", new=AsyncMock()):
235
235
  await box.async_ota_check()
236
236
  assert box.available_firmware_version is None
237
+
238
+
239
+ async def test_async_ota_check_falls_back_to_info(mock_session, sample_data, config):
240
+ box = Box(mock_session, sample_data, config, None)
241
+ mock_session.async_api_get_ota = AsyncMock(return_value=None)
242
+ mock_session.async_api_get = AsyncMock(
243
+ side_effect=[
244
+ error.HttpError("Request to /api/device/state failed with HTTP 404"),
245
+ {"device": {"availableFv": "2.0", "fv": "1.5"}},
246
+ ]
247
+ )
248
+ with patch("asyncio.sleep", new=AsyncMock()):
249
+ await box.async_ota_check()
250
+ mock_session.async_api_get.assert_has_calls(
251
+ [mock.call("/api/device/state"), mock.call("/info")]
252
+ )
253
+ assert box.available_firmware_version == "2.0"
254
+ assert box.firmware_version == "1.5"
@@ -39,7 +39,6 @@ class BleBoxClimateEntity(CommonEntity, ClimateDevice):
39
39
  def __init__(self, feature):
40
40
  super().__init__(feature)
41
41
  ClimateDevice.__init__(self)
42
- pass
43
42
 
44
43
  """Representation of a BleBox climate feature."""
45
44
 
@@ -294,6 +293,45 @@ class TestSauna(DefaultBoxTest):
294
293
  entity = (await self.async_entities(aioclient_mock))[0]
295
294
  assert entity.device_info["name"] == "My ThermoBox"
296
295
 
296
+ async def test_thermo_sensor_error_state(self, aioclient_mock):
297
+ """A broken probe (state=3, out-of-range value) must not prevent setup."""
298
+ self.DEVICE_INFO = self.DEVICE_INFO_THERMO
299
+ self.DEVICE_EXTENDED_INFO = jmerge(
300
+ self.DEVICE_EXTENDED_INFO_THERMO,
301
+ '{ "sensors": [ { "value": 18349, "state": 3 } ] }',
302
+ )
303
+ await self.allow_get_info(aioclient_mock)
304
+ entity = (await self.async_entities(aioclient_mock))[0]
305
+
306
+ assert entity.current_temperature is None
307
+ assert entity._feature.is_error is True
308
+
309
+ async def test_thermo_sensor_initializing_state(self, aioclient_mock):
310
+ """state=1 (measurement in progress) must not surface a value or an error."""
311
+ self.DEVICE_INFO = self.DEVICE_INFO_THERMO
312
+ self.DEVICE_EXTENDED_INFO = jmerge(
313
+ self.DEVICE_EXTENDED_INFO_THERMO,
314
+ '{ "sensors": [ { "value": 18349, "state": 1 } ] }',
315
+ )
316
+ await self.allow_get_info(aioclient_mock)
317
+ entity = (await self.async_entities(aioclient_mock))[0]
318
+
319
+ assert entity.current_temperature is None
320
+ assert entity._feature.is_error is False
321
+
322
+ async def test_thermo_safety_off_state(self, aioclient_mock):
323
+ """state=4 (output blocked by safety system) must not prevent setup."""
324
+ self.DEVICE_INFO = self.DEVICE_INFO_THERMO
325
+ self.DEVICE_EXTENDED_INFO = jmerge(
326
+ self.DEVICE_EXTENDED_INFO_THERMO,
327
+ '{ "thermo": { "state": 4 } }',
328
+ )
329
+ await self.allow_get_info(aioclient_mock)
330
+ entity = (await self.async_entities(aioclient_mock))[0]
331
+
332
+ assert entity._feature.is_on is False
333
+ assert entity._feature.is_error is True
334
+
297
335
  async def test_device_info(self, aioclient_mock):
298
336
  await self.allow_get_info(aioclient_mock, self.DEVICE_INFO)
299
337
  entity = (await self.async_entities(aioclient_mock))[0]
@@ -283,6 +283,73 @@ class TestTempSensor(DefaultBoxTest):
283
283
 
284
284
  assert entity.native_value == 1.2
285
285
 
286
+ STATE_SENSOR_ERROR = json.loads(
287
+ """
288
+ {
289
+ "tempSensor": {
290
+ "sensors": [
291
+ {
292
+ "type": "temperature",
293
+ "id": 0,
294
+ "value": 18349,
295
+ "trend": 3,
296
+ "state": 3,
297
+ "elapsedTimeS": 0
298
+ }
299
+ ]
300
+ }
301
+ }
302
+ """
303
+ )
304
+
305
+ async def test_update_with_sensor_error_state_ignores_out_of_range_value(
306
+ self, aioclient_mock
307
+ ):
308
+ """Regression test: a broken probe must not prevent device setup."""
309
+
310
+ entity = await self.updated(aioclient_mock, self.STATE_SENSOR_ERROR)
311
+
312
+ assert entity.native_value is None
313
+ assert entity._feature.is_error is True
314
+
315
+ async def test_valid_state_trusts_out_of_nominal_range_value(self, aioclient_mock):
316
+ """state == 2 means the value is valid, even outside the nominal range."""
317
+
318
+ state = jmerge(
319
+ self.STATE_DEFAULT,
320
+ '{ "tempSensor": { "sensors": [ { "value": 18349, "state": 2 } ] } }',
321
+ )
322
+
323
+ entity = await self.updated(aioclient_mock, state)
324
+
325
+ assert entity._feature.is_error is False
326
+ assert entity.native_value == 183.5
327
+
328
+ async def test_initializing_state_reports_unknown_not_error(self, aioclient_mock):
329
+ """state == 1 (measurement in progress) must not surface a value or an error."""
330
+
331
+ state = jmerge(
332
+ self.STATE_DEFAULT,
333
+ '{ "tempSensor": { "sensors": [ { "value": 18349, "state": 1 } ] } }',
334
+ )
335
+
336
+ entity = await self.updated(aioclient_mock, state)
337
+
338
+ assert entity._feature.is_error is False
339
+ assert entity.native_value is None
340
+
341
+ async def test_update_recovers_after_sensor_error_clears(self, aioclient_mock):
342
+ """Entity value is restored once the sensor reports a valid state again."""
343
+
344
+ entity = await self.updated(aioclient_mock, self.STATE_SENSOR_ERROR)
345
+ assert entity._feature.is_error is True
346
+
347
+ product = entity._feature.product
348
+ product._update_last_data(self.STATE_DEFAULT)
349
+
350
+ assert entity._feature.is_error is False
351
+ assert entity.native_value == 25.2
352
+
286
353
 
287
354
  class TestMultiSensorEnergy(DefaultBoxTest):
288
355
  """Tests for multiSensor energy/current sensors (apiLevel 20230606)."""
@@ -351,6 +418,146 @@ class TestMultiSensorEnergy(DefaultBoxTest):
351
418
  assert entity._feature.unit == unit
352
419
  assert entity.native_value == value
353
420
 
421
+ async def test_sensor_error_state_reports_error(self, aioclient_mock):
422
+ """state == 3 is the only value that marks the sensor as errored."""
423
+
424
+ state_json = jmerge(
425
+ self.STATE_DEFAULT,
426
+ '{ "multiSensor": { "sensors": [ { "state": 3 } ] } }',
427
+ )
428
+
429
+ entity = await self.updated(aioclient_mock, state_json, index=0)
430
+
431
+ assert entity.native_value is None
432
+ assert entity._feature.is_error is True
433
+
434
+ async def test_sensor_initializing_state_reports_unknown_not_error(
435
+ self, aioclient_mock
436
+ ):
437
+ """state == 1 (measurement in progress) must not surface a value or an error."""
438
+
439
+ state_json = jmerge(
440
+ self.STATE_DEFAULT,
441
+ '{ "multiSensor": { "sensors": [ { "state": 1 } ] } }',
442
+ )
443
+
444
+ entity = await self.updated(aioclient_mock, state_json, index=0)
445
+
446
+ assert entity._feature.is_error is False
447
+ assert entity.native_value is None
448
+
449
+ @pytest.mark.parametrize("state", [0, 2, 4, 5])
450
+ async def test_sensor_valid_state_reports_value(self, aioclient_mock, state):
451
+ """States other than 1 (init) and 3 (error) trust the reported value,
452
+ including 4/5 (out of range), where value is the measurement limit."""
453
+
454
+ state_json = jmerge(
455
+ self.STATE_DEFAULT,
456
+ f'{{ "multiSensor": {{ "sensors": [ {{ "state": {state} }} ] }} }}',
457
+ )
458
+
459
+ entity = await self.updated(aioclient_mock, state_json, index=0)
460
+
461
+ assert entity._feature.is_error is False
462
+ assert entity.native_value == 1500
463
+
464
+
465
+ class TestMultiSensorTank(DefaultBoxTest):
466
+ """Tests for multiSensor tank level sensors (apiLevel 20230606)."""
467
+
468
+ DEVCLASS = "sensors"
469
+ ENTITY_CLASS = BleBoxSensorEntity
470
+
471
+ DEV_INFO_PATH = "state"
472
+
473
+ DEVICE_INFO = json.loads(
474
+ """
475
+ {
476
+ "device": {
477
+ "deviceName": "My tankSensor",
478
+ "type": "multiSensor",
479
+ "product": "tankSensor",
480
+ "hv": "tS-1.0",
481
+ "fv": "0.1",
482
+ "universe": 0,
483
+ "apiLevel": "20230606",
484
+ "id": "aa00112233bb",
485
+ "ip": "172.0.0.1",
486
+ "availableFv": null
487
+ }
488
+ }
489
+ """
490
+ )
491
+
492
+ def patch_version(apiLevel):
493
+ return f'{{ "device": {{ "apiLevel": {apiLevel} }} }}'
494
+
495
+ DEVICE_INFO_FUTURE = jmerge(DEVICE_INFO, patch_version(future_date()))
496
+ DEVICE_INFO_LATEST = jmerge(
497
+ DEVICE_INFO, patch_version(get_latest_api_level("multiSensor"))
498
+ )
499
+ DEVICE_INFO_UNSUPPORTED = jmerge(DEVICE_INFO, patch_version(20180603))
500
+ DEVICE_INFO_UNSPECIFIED_API = None
501
+
502
+ STATE_DEFAULT = json.loads(
503
+ """
504
+ {
505
+ "multiSensor": {
506
+ "sensors": [
507
+ {"type": "gaugePressure", "id": 0, "value": 98, "state": 2},
508
+ {"type": "liquidHeight", "id": 1, "value": 100, "state": 2},
509
+ {"type": "fillLevel", "id": 2, "value": 205, "state": 2},
510
+ {"type": "volume", "id": 3, "value": 2000, "state": 2}
511
+ ]
512
+ }
513
+ }
514
+ """
515
+ )
516
+
517
+ DEVICE_EXTENDED_INFO = STATE_DEFAULT
518
+ DEVICE_EXTENDED_INFO_PATH = "/state/extended"
519
+
520
+ @pytest.mark.parametrize(
521
+ "index,unit,value",
522
+ [
523
+ (0, "mbar", 98),
524
+ (1, "cm", 100),
525
+ (2, "percentage", 20.5),
526
+ (3, "L", 2000),
527
+ ],
528
+ )
529
+ async def test_sensor_value(self, aioclient_mock, index, unit, value):
530
+ entity = await self.updated(aioclient_mock, self.STATE_DEFAULT, index=index)
531
+ assert entity._feature.unit == unit
532
+ assert entity.native_value == value
533
+ assert entity._feature.needs_configuration is False
534
+
535
+ @pytest.mark.parametrize("value", [None, 9999])
536
+ async def test_not_configured_state_reports_error(self, aioclient_mock, value):
537
+ """state == 6 (probe not configured) carries a meaningless or null value."""
538
+
539
+ state_json = jmerge(
540
+ self.STATE_DEFAULT,
541
+ json.dumps({"multiSensor": {"sensors": [{"state": 6, "value": value}]}}),
542
+ )
543
+
544
+ entity = await self.updated(aioclient_mock, state_json, index=0)
545
+
546
+ assert entity.native_value is None
547
+ assert entity._feature.is_error is True
548
+ assert entity._feature.needs_configuration is True
549
+
550
+ async def test_error_state_does_not_need_configuration(self, aioclient_mock):
551
+ state_json = jmerge(
552
+ self.STATE_DEFAULT,
553
+ '{ "multiSensor": { "sensors": [ { "state": 3 } ] } }',
554
+ )
555
+
556
+ entity = await self.updated(aioclient_mock, state_json, index=0)
557
+
558
+ assert entity._feature.is_error is True
559
+ assert entity._feature.needs_configuration is False
560
+
354
561
 
355
562
  class TestAirSensor(DefaultBoxTest):
356
563
  """Tests for sensors representing BleBox airSensor."""
File without changes
File without changes
File without changes
File without changes