Skip to content

Commit 0698cce

Browse files
authored
Merge pull request #330 from plugwise/mdi_sense
Add sense hysteresis based switch action
2 parents 106a731 + f1abce9 commit 0698cce

File tree

8 files changed

+1044
-29
lines changed

8 files changed

+1044
-29
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 0.45.0 - 2025-09-03
4+
5+
- PR [330](https://github.com/plugwise/python-plugwise-usb/pull/330): Add sense hysteresis based switch action
6+
37
## 0.44.14 - 2025-08-31
48

59
- PR [329](https://github.com/plugwise/python-plugwise-usb/pull/329): Improve EnergyLogs caching: store only data from MAX_LOG_HOURS (24)

plugwise_usb/api.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class NodeFeature(str, Enum):
5555
RELAY_LOCK = "relay_lock"
5656
SWITCH = "switch"
5757
SENSE = "sense"
58+
SENSE_HYSTERESIS = "sense_hysteresis"
5859
TEMPERATURE = "temperature"
5960

6061

@@ -86,6 +87,7 @@ class NodeType(Enum):
8687
NodeFeature.MOTION_CONFIG,
8788
NodeFeature.TEMPERATURE,
8889
NodeFeature.SENSE,
90+
NodeFeature.SENSE_HYSTERESIS,
8991
NodeFeature.SWITCH,
9092
)
9193

@@ -260,12 +262,48 @@ class EnergyStatistics:
260262
day_production_reset: datetime | None = None
261263

262264

265+
@dataclass(frozen=True)
266+
class SenseHysteresisConfig:
267+
"""Configuration of sense hysteresis switch.
268+
269+
Description: Configuration settings for sense hysteresis.
270+
When value is scheduled to be changed the returned value is the optimistic value
271+
272+
Attributes:
273+
humidity_enabled: bool | None: enable humidity hysteresis
274+
humidity_upper_bound: float | None: upper humidity switching threshold (%RH)
275+
humidity_lower_bound: float | None: lower humidity switching threshold (%RH)
276+
humidity_direction: bool | None: True = switch ON when humidity rises; False = switch OFF when humidity rises
277+
temperature_enabled: bool | None: enable temperature hysteresis
278+
temperature_upper_bound: float | None: upper temperature switching threshold (°C)
279+
temperature_lower_bound: float | None: lower temperature switching threshold (°C)
280+
temperature_direction: bool | None: True = switch ON when temperature rises; False = switch OFF when temperature rises
281+
dirty: bool: Settings changed, device update pending
282+
283+
Notes:
284+
Disabled sentinel values are hardware-specific (temperature=17099 for -1°C, humidity=2621 for -1%) and are handled in the node layer; the public API exposes floats in SI units.
285+
286+
"""
287+
288+
humidity_enabled: bool | None = None
289+
humidity_upper_bound: float | None = None
290+
humidity_lower_bound: float | None = None
291+
humidity_direction: bool | None = None
292+
temperature_enabled: bool | None = None
293+
temperature_upper_bound: float | None = None
294+
temperature_lower_bound: float | None = None
295+
temperature_direction: bool | None = None
296+
dirty: bool = False
297+
298+
263299
@dataclass
264300
class SenseStatistics:
265301
"""Sense statistics collection."""
266302

267303
temperature: float | None = None
268304
humidity: float | None = None
305+
temperature_hysteresis_state: bool | None = None
306+
humidity_hysteresis_state: bool | None = None
269307

270308

271309
class PlugwiseNode(Protocol):

plugwise_usb/messages/requests.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,6 +1404,57 @@ async def send(self) -> NodeAckResponse | None:
14041404
)
14051405

14061406

1407+
class SenseConfigureHysteresisRequest(PlugwiseRequest):
1408+
"""Configure a Sense Hysteresis Switching Setting.
1409+
1410+
temp_hum : configure temperature True or humidity False
1411+
lower_bound : lower bound of the hysteresis
1412+
upper_bound : upper bound of the hysteresis
1413+
direction : Switch active high or active low
1414+
1415+
Response message: NodeAckResponse
1416+
"""
1417+
1418+
_identifier = b"0104"
1419+
_reply_identifier = b"0100"
1420+
1421+
# pylint: disable=too-many-arguments
1422+
def __init__( # noqa: PLR0913
1423+
self,
1424+
send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]],
1425+
mac: bytes,
1426+
temp_hum: bool,
1427+
lower_bound: int,
1428+
upper_bound: int,
1429+
direction: bool,
1430+
):
1431+
"""Initialize SenseConfigureHysteresisRequest message object."""
1432+
super().__init__(send_fn, mac)
1433+
temp_hum_value = Int(1 if temp_hum else 0, length=2)
1434+
lower_bound_value = Int(lower_bound, length=4)
1435+
upper_bound_value = Int(upper_bound, length=4)
1436+
direction_value_1 = Int(1 if direction else 0, length=2)
1437+
direction_value_2 = Int(0 if direction else 1, length=2)
1438+
self._args += [
1439+
temp_hum_value,
1440+
lower_bound_value,
1441+
direction_value_1,
1442+
upper_bound_value,
1443+
direction_value_2,
1444+
]
1445+
1446+
async def send(self) -> NodeAckResponse | None:
1447+
"""Send request."""
1448+
result = await self._send_request()
1449+
if isinstance(result, NodeAckResponse):
1450+
return result
1451+
if result is None:
1452+
return None
1453+
raise MessageError(
1454+
f"Invalid response message. Received {result.__class__.__name__}, expected NodeAckResponse"
1455+
)
1456+
1457+
14071458
class ScanLightCalibrateRequest(PlugwiseRequest):
14081459
"""Calibrate light sensitivity.
14091460

plugwise_usb/nodes/helpers/firmware.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ class SupportedVersions(NamedTuple):
159159
NodeFeature.CIRCLEPLUS: 2.0,
160160
NodeFeature.INFO: 2.0,
161161
NodeFeature.SENSE: 2.0,
162+
NodeFeature.SENSE_HYSTERESIS: 2.0,
162163
NodeFeature.TEMPERATURE: 2.0,
163164
NodeFeature.HUMIDITY: 2.0,
164165
NodeFeature.ENERGY: 2.0,

plugwise_usb/nodes/scan.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -430,21 +430,13 @@ async def _run_awake_tasks(self) -> None:
430430
await self._scan_calibrate_light()
431431
await self.publish_feature_update_to_subscribers(
432432
NodeFeature.MOTION_CONFIG,
433-
self._motion_config,
433+
self.motion_config,
434434
)
435435

436436
async def _configure_scan_task(self) -> bool:
437437
"""Configure Scan device settings. Returns True if successful."""
438438
if not self._motion_config.dirty:
439439
return True
440-
if not await self.scan_configure():
441-
_LOGGER.debug("Motion Configuration for %s failed", self._mac_in_str)
442-
return False
443-
return True
444-
445-
async def scan_configure(self) -> bool:
446-
"""Configure Scan device settings. Returns True if successful."""
447-
# Default to medium
448440
request = ScanConfigureRequest(
449441
self._send,
450442
self._mac_in_bytes,
@@ -485,7 +477,7 @@ async def _scan_configure_update(self) -> None:
485477
await gather(
486478
self.publish_feature_update_to_subscribers(
487479
NodeFeature.MOTION_CONFIG,
488-
self._motion_config,
480+
self.motion_config,
489481
),
490482
self.save_cache(),
491483
)
@@ -542,7 +534,7 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any
542534
case NodeFeature.MOTION:
543535
states[NodeFeature.MOTION] = self._motion_state
544536
case NodeFeature.MOTION_CONFIG:
545-
states[NodeFeature.MOTION_CONFIG] = self._motion_config
537+
states[NodeFeature.MOTION_CONFIG] = self.motion_config
546538
case _:
547539
state_result = await super().get_state((feature,))
548540
if feature in state_result:

0 commit comments

Comments
 (0)