boneIO 1.4.0.dev4__py3-none-any.whl → 1.4.0.dev5__py3-none-any.whl

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 (36) hide show
  1. boneio/components/irrigation/controller.py +101 -4
  2. boneio/core/manager/irrigation.py +23 -2
  3. boneio/core/manager/manager.py +1 -1
  4. boneio/core/messaging/lox.py +148 -43
  5. boneio/integration/homeassistant.py +84 -0
  6. boneio/integration/lox_template.py +172 -48
  7. boneio/modbus/devices/hvac/eht-topventil-plus.json +243 -0
  8. boneio/schema/remote_outputs.yaml +3 -1
  9. boneio/schema/schema.yaml +22 -1
  10. boneio/version.py +1 -1
  11. boneio/webui/frontend-dist/assets/{ConfigEditor-BBQ49dou.js → ConfigEditor-A8cWujvP.js} +1 -1
  12. boneio/webui/frontend-dist/assets/{aiConfig-BwEuqxu2.js → aiConfig-DxFilBWK.js} +2 -2
  13. boneio/webui/frontend-dist/assets/{aiWizardPrompt-CEIyLZ8l.js → aiWizardPrompt-CrWwS7zb.js} +1 -1
  14. boneio/webui/frontend-dist/assets/{index-D-R9QzIP.css → index-BtCN44rd.css} +1 -1
  15. boneio/webui/frontend-dist/assets/{index-CfQ35RrC.js → index-ChujUEJh.js} +62 -6
  16. boneio/webui/frontend-dist/assets/{useTranslation-ChbxfQ44.js → useTranslation-CxUU9LHE.js} +2 -2
  17. boneio/webui/frontend-dist/index.html +4 -4
  18. boneio/webui/frontend-dist/schema/config.schema.json +21 -3
  19. boneio/webui/frontend-dist/schema/irrigation.schema.json +9 -0
  20. boneio/webui/frontend-dist/schema/modbus_devices.schema.json +3 -0
  21. boneio/webui/frontend-dist/schema/remote_outputs.schema.json +0 -3
  22. boneio/webui/frontend-dist/schema/template.schema.json +9 -0
  23. boneio/webui/frontend-dist/sw.js +1 -1
  24. boneio/webui/routes/irrigation.py +217 -0
  25. boneio/webui/schema/config.schema.json +21 -3
  26. boneio/webui/schema/irrigation.schema.json +9 -0
  27. boneio/webui/schema/modbus_devices.schema.json +3 -0
  28. boneio/webui/schema/remote_outputs.schema.json +0 -3
  29. boneio/webui/schema/template.schema.json +9 -0
  30. {boneio-1.4.0.dev4.dist-info → boneio-1.4.0.dev5.dist-info}/METADATA +1 -1
  31. {boneio-1.4.0.dev4.dist-info → boneio-1.4.0.dev5.dist-info}/RECORD +35 -35
  32. boneio/core/messaging/loxone.py +0 -181
  33. {boneio-1.4.0.dev4.dist-info → boneio-1.4.0.dev5.dist-info}/WHEEL +0 -0
  34. {boneio-1.4.0.dev4.dist-info → boneio-1.4.0.dev5.dist-info}/entry_points.txt +0 -0
  35. {boneio-1.4.0.dev4.dist-info → boneio-1.4.0.dev5.dist-info}/licenses/LICENSE +0 -0
  36. {boneio-1.4.0.dev4.dist-info → boneio-1.4.0.dev5.dist-info}/top_level.txt +0 -0
@@ -75,6 +75,7 @@ class IrrigationController:
75
75
  repeat: int = 0,
76
76
  auto_advance: bool = True,
77
77
  reverse: bool = False,
78
+ pause_timeout_s: int = 1800,
78
79
  ) -> None:
79
80
  """Initialize irrigation controller.
80
81
 
@@ -95,6 +96,8 @@ class IrrigationController:
95
96
  repeat: Number of cycle repeats.
96
97
  auto_advance: Automatically advance to next zone.
97
98
  reverse: Run zones in reverse order.
99
+ pause_timeout_s: Auto-shutdown after this many seconds in PAUSED state.
100
+ Default 1800 (30 minutes). Set 0 to disable.
98
101
  """
99
102
  self.id = id
100
103
  self.name = name
@@ -119,6 +122,8 @@ class IrrigationController:
119
122
  self._skip_next_run = False
120
123
 
121
124
  self._zone_timer_cancel = None
125
+ self._pause_timer_cancel = None
126
+ self._pause_timeout_s = max(0, pause_timeout_s)
122
127
  self._run_start_utc: datetime | None = None
123
128
  self._active_zone_idx: int | None = None
124
129
  self._active_zone_remaining_s: int | None = None
@@ -181,6 +186,10 @@ class IrrigationController:
181
186
  def _schedule_skip_cmd_topic(self, idx: int) -> str:
182
187
  return f"{self._topic_prefix}/cmd/{IRRIGATION}/{self.id}/schedule/{idx}/skip/set"
183
188
 
189
+ def _event_topic(self) -> str:
190
+ """MQTT topic for HA event entity notifications."""
191
+ return f"{self._topic_prefix}/{IRRIGATION}/{self.id}/event"
192
+
184
193
  def _state_key(self, suffix: str) -> str:
185
194
  return f"{self.id}/{suffix}"
186
195
 
@@ -223,6 +232,24 @@ class IrrigationController:
223
232
  _LOGGER.debug("Irrigation MQTT publish: topic='%s' payload=%s retain=%s", topic, payload, retain)
224
233
  self._message_bus.send_message(topic=topic, payload=payload, retain=retain)
225
234
 
235
+ def _publish_event(self, event_type: str, **attributes: Any) -> None:
236
+ """Publish an event to the HA event entity topic.
237
+
238
+ The payload follows the HA MQTT event entity format:
239
+ ``{"event_type": "...", ...extra attributes}``.
240
+
241
+ Events are NOT retained — they are instantaneous notifications.
242
+
243
+ Args:
244
+ event_type: One of the registered event types
245
+ (interlock_fault, cycle_complete, standby_blocked).
246
+ **attributes: Additional key-value pairs included in the event payload.
247
+ """
248
+ import json
249
+
250
+ payload = {"event_type": event_type, **attributes}
251
+ self._publish(self._event_topic(), json.dumps(payload), retain=False)
252
+
226
253
  def _ordered_zones(self) -> list[tuple[int, IrrigationZone]]:
227
254
  indexed = list(enumerate(self._zones))
228
255
  if self._reverse:
@@ -352,6 +379,7 @@ class IrrigationController:
352
379
  "".join(traceback.format_stack(limit=5)),
353
380
  )
354
381
  self.stop_schedules()
382
+ self._cancel_pause_timer()
355
383
  await self._stop_current_zone()
356
384
  await self._handle_pump_stop_sequence()
357
385
  self._state = ControllerState.IDLE
@@ -370,11 +398,13 @@ class IrrigationController:
370
398
  self._cancel_zone_timer()
371
399
  await self._stop_current_zone()
372
400
  self._state = ControllerState.PAUSED
401
+ self._arm_pause_timer()
373
402
  await self.publish_all_states()
374
403
 
375
404
  async def resume(self) -> None:
376
405
  if self._state != ControllerState.PAUSED or self._active_zone_idx is None:
377
406
  return
407
+ self._cancel_pause_timer()
378
408
  remaining = self._active_zone_remaining_s or self._current_zone_duration_seconds()
379
409
  await self._start_zone(self._active_zone_idx, override_duration=remaining)
380
410
 
@@ -400,6 +430,11 @@ class IrrigationController:
400
430
  )
401
431
  if self._standby:
402
432
  _LOGGER.info("Irrigation %s is in standby mode, not starting", self.id)
433
+ self._publish_event(
434
+ "standby_blocked",
435
+ controller=self.id,
436
+ message=f"Irrigation '{self.name}' start blocked: standby mode is active",
437
+ )
403
438
  return
404
439
 
405
440
  if self._state in (ControllerState.RUNNING, ControllerState.PAUSED):
@@ -428,6 +463,12 @@ class IrrigationController:
428
463
  )
429
464
  if self._standby:
430
465
  _LOGGER.info("Irrigation %s is in standby mode, not starting zone", self.id)
466
+ self._publish_event(
467
+ "standby_blocked",
468
+ controller=self.id,
469
+ zone=zone_id,
470
+ message=f"Irrigation '{self.name}' zone '{zone_id}' blocked: standby mode is active",
471
+ )
431
472
  return
432
473
 
433
474
  match_idx = None
@@ -551,6 +592,11 @@ class IrrigationController:
551
592
  await self._start_cycle_from_eligible()
552
593
  return
553
594
 
595
+ self._publish_event(
596
+ "cycle_complete",
597
+ controller=self.id,
598
+ message=f"Irrigation '{self.name}' cycle completed",
599
+ )
554
600
  await self.shutdown()
555
601
 
556
602
  def _ordered_zone_indices(self) -> list[int]:
@@ -718,8 +764,10 @@ class IrrigationController:
718
764
  async def _handle_interlock_fault(self, zone_id: str, source_id: str | None) -> None:
719
765
  """Handle interlock-blocked activation.
720
766
 
721
- Shuts down the controller and publishes a fault notification via MQTT
722
- so that Home Assistant (or other consumers) can alert the user.
767
+ Shuts down the controller and publishes both a retained fault status
768
+ message and a non-retained HA event notification via the event entity.
769
+ Home Assistant automations can listen to the event entity to trigger
770
+ mobile push notifications, Telegram messages, etc.
723
771
 
724
772
  Args:
725
773
  zone_id: ID of the zone that was being started.
@@ -734,13 +782,15 @@ class IrrigationController:
734
782
  source_info,
735
783
  )
736
784
 
737
- # Publish fault notification on MQTT
785
+ fault_message = f"Irrigation '{self.name}' stopped: output blocked by interlock (zone: {zone_id})"
786
+
787
+ # Publish retained fault status on MQTT (legacy topic)
738
788
  fault_payload = {
739
789
  "fault": "interlock_blocked",
740
790
  "controller": self.id,
741
791
  "zone": zone_id,
742
792
  "source": source_id or "",
743
- "message": f"Irrigation '{self.name}' stopped: output blocked by interlock (zone: {zone_id})",
793
+ "message": fault_message,
744
794
  }
745
795
  self._publish(
746
796
  f"{self._topic_prefix}/{IRRIGATION}/{self.id}/fault",
@@ -748,6 +798,15 @@ class IrrigationController:
748
798
  retain=False,
749
799
  )
750
800
 
801
+ # Publish HA event entity notification (non-retained, instantaneous)
802
+ self._publish_event(
803
+ "interlock_fault",
804
+ controller=self.id,
805
+ zone=zone_id,
806
+ source=source_id or "",
807
+ message=fault_message,
808
+ )
809
+
751
810
  await self.shutdown()
752
811
  await self.publish_all_states()
753
812
 
@@ -799,6 +858,44 @@ class IrrigationController:
799
858
  async def _zone_timer_callback(self, _timestamp: datetime) -> None:
800
859
  await self._advance_to_next_zone(force=False)
801
860
 
861
+ def _cancel_pause_timer(self) -> None:
862
+ """Cancel the pause timeout timer if active."""
863
+ if self._pause_timer_cancel is not None:
864
+ self._pause_timer_cancel()
865
+ self._pause_timer_cancel = None
866
+
867
+ def _arm_pause_timer(self) -> None:
868
+ """Arm an auto-shutdown timer for the PAUSED state.
869
+
870
+ If pause_timeout_s is 0, no timer is set (pause lasts indefinitely).
871
+ Otherwise, the controller will auto-shutdown after the configured timeout.
872
+ """
873
+ self._cancel_pause_timer()
874
+ if self._pause_timeout_s <= 0:
875
+ return
876
+ point = utcnow() + timedelta(seconds=self._pause_timeout_s)
877
+ self._pause_timer_cancel = async_track_point_in_time(
878
+ loop=self._event_bus._loop,
879
+ job=self._pause_timeout_callback,
880
+ point_in_time=point,
881
+ )
882
+ _LOGGER.info(
883
+ "Irrigation %s: pause timeout armed for %d seconds",
884
+ self.id,
885
+ self._pause_timeout_s,
886
+ )
887
+
888
+ async def _pause_timeout_callback(self, _timestamp: datetime) -> None:
889
+ """Handle pause timeout — auto-shutdown the controller."""
890
+ if self._state != ControllerState.PAUSED:
891
+ return
892
+ _LOGGER.warning(
893
+ "Irrigation %s: pause timeout expired after %d seconds, shutting down",
894
+ self.id,
895
+ self._pause_timeout_s,
896
+ )
897
+ await self.shutdown()
898
+
802
899
  async def handle_main_command(self, payload: str) -> None:
803
900
  cmd = payload.strip()
804
901
  upper = cmd.upper()
@@ -11,11 +11,13 @@ from boneio.const import NEXT_VALVE, ON, PAUSE, RESUME
11
11
  from boneio.core.utils.timeperiod import parse_time_to_seconds
12
12
  from boneio.integration.homeassistant import (
13
13
  ha_irrigation_button_message,
14
+ ha_irrigation_event_message,
14
15
  ha_irrigation_main_switch_message,
15
16
  ha_irrigation_number_message,
16
17
  ha_irrigation_select_message,
17
18
  ha_irrigation_switch_message,
18
19
  ha_irrigation_timestamp_sensor_message,
20
+ ha_irrigation_valve_message,
19
21
  )
20
22
 
21
23
  if TYPE_CHECKING:
@@ -90,6 +92,7 @@ class IrrigationManager:
90
92
  repeat=int(cfg.get("repeat", 0)),
91
93
  auto_advance=bool(cfg.get("auto_advance", True)),
92
94
  reverse=bool(cfg.get("reverse", False)),
95
+ pause_timeout_s=int(parse_time_to_seconds(cfg.get("pause_timeout"), 1800)),
93
96
  )
94
97
 
95
98
  def _build_zone(self, zone_cfg: dict[str, Any]) -> IrrigationZone | None:
@@ -280,9 +283,10 @@ class IrrigationManager:
280
283
  (f"{ctrl.id}_next_valve", "button"),
281
284
  (f"{ctrl.id}_pause", "button"),
282
285
  (f"{ctrl.id}_resume", "button"),
286
+ (f"{ctrl.id}_event", "event"),
283
287
  ]
284
288
  for zone in ctrl.zones:
285
- discovery_ids.append((f"{ctrl.id}_zone_{zone.id}", "switch"))
289
+ discovery_ids.append((f"{ctrl.id}_zone_{zone.id}", "valve"))
286
290
  discovery_ids.append((f"{ctrl.id}_zone_{zone.id}_enabled", "switch"))
287
291
  discovery_ids.append((f"{ctrl.id}_zone_{zone.id}_duration", "number"))
288
292
  for idx in range(len(ctrl._schedule)):
@@ -506,10 +510,16 @@ class IrrigationManager:
506
510
  )
507
511
 
508
512
  for zone in ctrl.zones:
513
+ # Remove stale switch discovery (migration from switch → valve)
509
514
  self._manager.publish_ha_discovery(
510
515
  id=f"{ctrl.id}_zone_{zone.id}",
511
516
  ha_type="switch",
512
- payload=ha_irrigation_switch_message(
517
+ payload="",
518
+ )
519
+ self._manager.publish_ha_discovery(
520
+ id=f"{ctrl.id}_zone_{zone.id}",
521
+ ha_type="valve",
522
+ payload=ha_irrigation_valve_message(
513
523
  ctrl.id,
514
524
  ctrl.name,
515
525
  suffix=f"zone/{zone.id}",
@@ -584,3 +594,14 @@ class IrrigationManager:
584
594
  config_helper=cfg,
585
595
  ),
586
596
  )
597
+
598
+ # Event entity — fires on interlock faults, cycle completions, etc.
599
+ self._manager.publish_ha_discovery(
600
+ id=f"{ctrl.id}_event",
601
+ ha_type="event",
602
+ payload=ha_irrigation_event_message(
603
+ ctrl.id,
604
+ ctrl.name,
605
+ config_helper=cfg,
606
+ ),
607
+ )
@@ -611,7 +611,7 @@ class Manager:
611
611
  self,
612
612
  id: str,
613
613
  ha_type: str,
614
- payload: dict,
614
+ payload: dict[str, Any] | str,
615
615
  ) -> None:
616
616
  """Publish a pre-built HA autodiscovery payload.
617
617
 
@@ -1,4 +1,11 @@
1
- """Lox UDP Client for BoneIO."""
1
+ """Lox UDP Client for BoneIO.
2
+
3
+ Implements the Loxone UDP protocol for bidirectional communication
4
+ between BoneIO and a Loxone Miniserver:
5
+
6
+ - Receiving commands from Miniserver (e.g. "OUT_04=ON")
7
+ - Sending state feedback to Miniserver (e.g. "OUT_04=ON", "cover1=50")
8
+ """
2
9
 
3
10
  from __future__ import annotations
4
11
 
@@ -16,6 +23,11 @@ if TYPE_CHECKING:
16
23
 
17
24
  _LOGGER = logging.getLogger(__name__)
18
25
 
26
+ # Entity types whose state changes should be forwarded to Loxone
27
+ _LOX_ENTITY_TYPES = frozenset(
28
+ ("output", "cover", "sensor", "event", "binary_sensor", "input")
29
+ )
30
+
19
31
 
20
32
  class LoxUDPProtocol(asyncio.DatagramProtocol):
21
33
  """Protocol for handling Lox UDP communication."""
@@ -25,9 +37,14 @@ class LoxUDPProtocol(asyncio.DatagramProtocol):
25
37
  self._loop = asyncio.get_running_loop()
26
38
 
27
39
  def connection_made(self, transport: asyncio.DatagramTransport) -> None:
40
+ """Handle connection established."""
28
41
  self.transport = transport
29
42
 
30
43
  def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
44
+ """Handle incoming datagram.
45
+
46
+ Expected message format: "device_id=value" or just "device_id".
47
+ """
31
48
  try:
32
49
  message = data.decode("utf-8").strip()
33
50
  _LOGGER.debug("Received Lox UDP message: %s from %s", message, addr)
@@ -45,7 +62,20 @@ class LoxUDPProtocol(asyncio.DatagramProtocol):
45
62
 
46
63
 
47
64
  class LoxUDPClient(MessageBus):
48
- """Represent a Lox UDP client."""
65
+ """Represent a Lox UDP client.
66
+
67
+ This message bus translates between BoneIO's internal MQTT-style
68
+ topic/payload model and the simple "key=value" UDP protocol
69
+ expected by Loxone Miniserver.
70
+
71
+ Outgoing (BoneIO → Miniserver):
72
+ topic "boneio/{serial}/output/relay1" + payload {"state": "ON"}
73
+ → UDP datagram "relay1=ON" sent to Miniserver
74
+
75
+ Incoming (Miniserver → BoneIO):
76
+ UDP datagram "relay1=ON"
77
+ → routed to Manager as "boneio/{serial}/cmd/output/relay1/set" = "ON"
78
+ """
49
79
 
50
80
  def __init__(
51
81
  self,
@@ -54,7 +84,14 @@ class LoxUDPClient(MessageBus):
54
84
  send_port: int,
55
85
  listen_port: int,
56
86
  ) -> None:
57
- """Set up client."""
87
+ """Set up client.
88
+
89
+ Args:
90
+ config_helper: BoneIO configuration helper.
91
+ host: Loxone Miniserver IP address.
92
+ send_port: Port on Miniserver to send state feedback to.
93
+ listen_port: Port BoneIO listens on for commands from Miniserver.
94
+ """
58
95
  self._manager: Manager | None = None
59
96
  self._config_helper = config_helper
60
97
  self.host = host
@@ -71,64 +108,116 @@ class LoxUDPClient(MessageBus):
71
108
  retain: bool = False,
72
109
  qos: int = 0,
73
110
  ) -> None:
74
- """Send a message via UDP.
111
+ """Send a state update to Loxone Miniserver via UDP.
75
112
 
76
- Converts MQTT-style messages to simple UDP datagrams.
77
- Topic format: boneio/{serial}/{type}/{device_id}
78
- Payload: dict {"state": "ON"} or string "ON"
113
+ Converts BoneIO's MQTT-style topic/payload into a simple UDP
114
+ datagram in the format "device_id=state_value".
79
115
 
80
- Sent UDP message format: {device_id}={state_value}
116
+ Topic format: boneio/{serial}/{entity_type}/{device_id}
117
+ or: boneio/{serial}/{entity_type}/{device_id}/state
118
+ or: boneio/{serial}/{entity_type}/{device_id}/pos
119
+
120
+ Args:
121
+ topic: MQTT-style topic string.
122
+ payload: State payload (dict, string, bytes, int, or None).
123
+ retain: Ignored for UDP.
124
+ qos: Ignored for UDP.
81
125
  """
82
126
  if payload is None:
83
127
  return
84
128
 
85
- # Skip HA Discovery messages (they have complex payloads not relevant for Lox)
86
- if isinstance(payload, dict) and "config" in str(topic):
87
- return
129
+ topic_str = str(topic)
130
+ parts = topic_str.split("/")
88
131
 
89
- # Extract device_id from topic: boneio/{serial}/{type}/{device_id}
90
- parts = str(topic).split("/")
132
+ # Need at least: prefix/serial/entity_type/device_id
91
133
  if len(parts) < 4:
92
134
  return
93
135
 
94
- device_id = parts[-1]
95
- entity_type = parts[-2] if len(parts) >= 3 else ""
96
-
97
- # Skip command topics (cmd/), availability, and discovery
98
- if "cmd" in parts or device_id in ("state", "config", "set"):
136
+ # Skip HA Discovery messages (homeassistant/*/config topics)
137
+ if parts[0] == "homeassistant" or topic_str.endswith("/config"):
99
138
  return
100
139
 
101
- # Only send state for outputs, covers, and sensors
102
- if entity_type not in ("output", "cover", "sensor"):
140
+ # Skip command topics — we only send state feedback, not commands
141
+ if "cmd" in parts:
103
142
  return
104
143
 
105
- # Extract state value from payload
106
- if isinstance(payload, dict):
107
- # {"state": "ON"} → "ON"
108
- state_value = payload.get("state", payload.get("value", ""))
109
- if not state_value and payload:
110
- # Fallback: use first value
111
- state_value = str(next(iter(payload.values()), ""))
112
- elif isinstance(payload, bytes):
113
- state_value = payload.decode("utf-8")
144
+ # Determine entity_type and device_id based on topic structure
145
+ # Standard: boneio/{serial}/{entity_type}/{device_id}
146
+ # With suffix: boneio/{serial}/{entity_type}/{device_id}/state
147
+ # With suffix: boneio/{serial}/{entity_type}/{device_id}/pos
148
+ entity_type = parts[2] if len(parts) >= 3 else ""
149
+ suffix = parts[-1] if len(parts) >= 5 else ""
150
+
151
+ # For topics with suffix (state/pos), device_id is parts[-2]
152
+ if suffix in ("state", "pos", "set"):
153
+ device_id = parts[-2]
114
154
  else:
115
- state_value = str(payload)
155
+ device_id = parts[-1]
156
+
157
+ # Skip non-relevant entity types
158
+ if entity_type not in _LOX_ENTITY_TYPES:
159
+ return
116
160
 
161
+ # Skip "set" suffix — those are incoming commands, not state feedback
162
+ if suffix == "set":
163
+ return
164
+
165
+ # Extract state value from payload
166
+ state_value = self._extract_state_value(payload)
117
167
  if not state_value:
118
168
  return
119
169
 
170
+ self._send_udp(device_id, state_value)
171
+
172
+ @staticmethod
173
+ def _extract_state_value(
174
+ payload: str | int | bytes | dict[str, Any] | HomeAssistantDiscoveryMessage,
175
+ ) -> str:
176
+ """Extract a simple state string from various payload formats.
177
+
178
+ Args:
179
+ payload: The message payload in any supported format.
180
+
181
+ Returns:
182
+ State value as string, or empty string if extraction fails.
183
+ """
184
+ if isinstance(payload, dict):
185
+ # Try standard keys: {"state": "ON"}, {"value": "50"}
186
+ state_value = payload.get("state", payload.get("value", ""))
187
+ if state_value:
188
+ return str(state_value)
189
+ # Fallback: use first value from dict
190
+ if payload:
191
+ return str(next(iter(payload.values()), ""))
192
+ return ""
193
+ if isinstance(payload, bytes):
194
+ return payload.decode("utf-8")
195
+ return str(payload)
196
+
197
+ def _send_udp(self, device_id: str, state_value: str) -> None:
198
+ """Send a UDP datagram to Loxone Miniserver.
199
+
200
+ Args:
201
+ device_id: Entity identifier (e.g. "relay1", "cover1").
202
+ state_value: State value string (e.g. "ON", "50").
203
+ """
120
204
  try:
121
205
  udp_msg = f"{device_id}={state_value}".encode()
122
-
123
206
  if self._transport:
124
207
  self._transport.sendto(udp_msg, (self.host, self.send_port))
125
208
  _LOGGER.debug(
126
- "Sent Lox UDP message: %s to %s:%s", udp_msg.decode("utf-8"), self.host, self.send_port
209
+ "Sent Lox UDP message: %s to %s:%s",
210
+ udp_msg.decode("utf-8"),
211
+ self.host,
212
+ self.send_port,
127
213
  )
128
214
  else:
129
- _LOGGER.debug("Lox UDP transport not ready yet, dropping: %s", udp_msg.decode("utf-8"))
215
+ _LOGGER.debug(
216
+ "Lox UDP transport not ready, dropping: %s",
217
+ udp_msg.decode("utf-8"),
218
+ )
130
219
  except Exception as e:
131
- _LOGGER.error("Error sending Lox UDP message for topic %s: %s", topic, e)
220
+ _LOGGER.error("Error sending Lox UDP message: %s", e)
132
221
 
133
222
  @property
134
223
  def state(self) -> bool:
@@ -140,7 +229,8 @@ class LoxUDPClient(MessageBus):
140
229
  try:
141
230
  loop = asyncio.get_running_loop()
142
231
  transport, protocol = await loop.create_datagram_endpoint(
143
- lambda: LoxUDPProtocol(self._handle_incoming), local_addr=("0.0.0.0", self.listen_port)
232
+ lambda: LoxUDPProtocol(self._handle_incoming),
233
+ local_addr=("0.0.0.0", self.listen_port),
144
234
  )
145
235
  self._transport = transport
146
236
  self._state = True
@@ -167,16 +257,18 @@ class LoxUDPClient(MessageBus):
167
257
  self._transport.close()
168
258
 
169
259
  def set_manager(self, manager: Manager) -> None:
170
- """Set manager."""
260
+ """Set manager reference for routing incoming commands."""
171
261
  self._manager = manager
172
262
 
173
263
  async def announce_offline(self) -> None:
174
- """Announce offline status."""
264
+ """Announce offline status to Miniserver."""
175
265
  if self._transport:
176
266
  self._transport.sendto(b"boneio=offline", (self.host, self.send_port))
177
267
 
178
- async def subscribe_and_listen(self, topic: str, callback: Callable[[str, str], Awaitable[None]]) -> None:
179
- """Register a listener."""
268
+ async def subscribe_and_listen(
269
+ self, topic: str, callback: Callable[[str, str], Awaitable[None]]
270
+ ) -> None:
271
+ """Register a listener for a topic pattern."""
180
272
  self._listeners[topic] = callback
181
273
 
182
274
  async def unsubscribe_and_stop_listen(self, topic: str) -> None:
@@ -185,7 +277,15 @@ class LoxUDPClient(MessageBus):
185
277
  del self._listeners[topic]
186
278
 
187
279
  async def _handle_incoming(self, device: str, payload: str) -> None:
188
- """Handle incoming Lox messages and route them to Manager or listeners."""
280
+ """Handle incoming Lox messages and route them to Manager.
281
+
282
+ Resolves the device name to an entity type (output/cover) and
283
+ forwards the command to the Manager's receive_message method.
284
+
285
+ Args:
286
+ device: Device identifier from UDP message (e.g. "relay1").
287
+ payload: Command value from UDP message (e.g. "ON").
288
+ """
189
289
  _LOGGER.debug("Lox handle incoming: %s = %s", device, payload)
190
290
 
191
291
  if not self._manager:
@@ -195,7 +295,9 @@ class LoxUDPClient(MessageBus):
195
295
  cmd_prefix = self._config_helper.cmd_topic_prefix
196
296
  msg_type = None
197
297
 
198
- output = self._manager.outputs.get_output(device) or self._manager.outputs.get_output_group(device)
298
+ output = self._manager.outputs.get_output(
299
+ device
300
+ ) or self._manager.outputs.get_output_group(device)
199
301
  if output:
200
302
  msg_type = "output"
201
303
  else:
@@ -204,7 +306,9 @@ class LoxUDPClient(MessageBus):
204
306
  msg_type = "cover"
205
307
 
206
308
  if not msg_type:
207
- _LOGGER.warning("Lox UDP device %s not found in outputs/covers", device)
309
+ _LOGGER.warning(
310
+ "Lox UDP device %s not found in outputs/covers", device
311
+ )
208
312
  return
209
313
 
210
314
  full_topic = f"{cmd_prefix}{msg_type}/{device}/set"
@@ -212,7 +316,8 @@ class LoxUDPClient(MessageBus):
212
316
  # Check explicit listeners
213
317
  for listen_topic, callback in self._listeners.items():
214
318
  if listen_topic == full_topic or (
215
- listen_topic.endswith("/#") and full_topic.startswith(listen_topic[:-2])
319
+ listen_topic.endswith("/#")
320
+ and full_topic.startswith(listen_topic[:-2])
216
321
  ):
217
322
  await callback(full_topic, payload)
218
323