conson-xp 1.51.0__py3-none-any.whl → 1.52.0__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.
@@ -0,0 +1,582 @@
1
+ """HomeKit Service for terminal interface."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import Dict, List, Optional
6
+
7
+ from psygnal import Signal
8
+
9
+ from xp.models.config.conson_module_config import ConsonModuleListConfig
10
+ from xp.models.homekit.homekit_config import HomekitAccessoryConfig, HomekitConfig
11
+ from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
12
+ from xp.models.telegram.datapoint_type import DataPointType
13
+ from xp.models.telegram.module_type_code import ModuleTypeCode
14
+ from xp.models.telegram.system_function import SystemFunction
15
+ from xp.models.telegram.telegram_type import TelegramType
16
+ from xp.models.term.accessory_state import AccessoryState
17
+ from xp.models.term.connection_state import ConnectionState
18
+ from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
19
+ from xp.services.telegram.telegram_output_service import TelegramOutputService
20
+ from xp.services.telegram.telegram_service import TelegramService
21
+ from xp.services.term.homekit_accessory_driver import HomekitAccessoryDriver
22
+
23
+
24
+ class HomekitService:
25
+ """
26
+ Service for HomeKit accessory monitoring in terminal interface.
27
+
28
+ Wraps ConbusEventProtocol, HomekitConfig, and ConsonModuleListConfig to provide
29
+ high-level accessory state tracking for the TUI.
30
+
31
+ Attributes:
32
+ on_connection_state_changed: Signal emitted when connection state changes.
33
+ on_room_list_updated: Signal emitted when accessory list refreshed from config.
34
+ on_module_state_changed: Signal emitted when individual accessory state updates.
35
+ on_module_error: Signal emitted when module error occurs.
36
+ on_status_message: Signal emitted for status messages.
37
+ connection_state: Property returning current connection state.
38
+ server_info: Property returning server connection info (IP:port).
39
+ accessory_states: Property returning list of all accessory states.
40
+ """
41
+
42
+ on_connection_state_changed: Signal = Signal(ConnectionState)
43
+ on_room_list_updated: Signal = Signal(list)
44
+ on_module_state_changed: Signal = Signal(AccessoryState)
45
+ on_module_error: Signal = Signal(str, str)
46
+ on_status_message: Signal = Signal(str)
47
+
48
+ def __init__(
49
+ self,
50
+ conbus_protocol: ConbusEventProtocol,
51
+ homekit_config: HomekitConfig,
52
+ conson_config: ConsonModuleListConfig,
53
+ telegram_service: TelegramService,
54
+ accessory_driver: HomekitAccessoryDriver,
55
+ ) -> None:
56
+ """
57
+ Initialize the HomeKit service.
58
+
59
+ Args:
60
+ conbus_protocol: ConbusEventProtocol instance.
61
+ homekit_config: HomekitConfig for accessory configuration.
62
+ conson_config: ConsonModuleListConfig for module configuration.
63
+ telegram_service: TelegramService for parsing telegrams.
64
+ accessory_driver: HomekitAccessoryDriver for pyhap integration.
65
+ """
66
+ self.logger = logging.getLogger(__name__)
67
+ self._conbus_protocol = conbus_protocol
68
+ self._homekit_config = homekit_config
69
+ self._conson_config = conson_config
70
+ self._telegram_service = telegram_service
71
+ self._accessory_driver = accessory_driver
72
+ self._connection_state = ConnectionState.DISCONNECTED
73
+ self._state_machine = ConnectionState.create_state_machine()
74
+
75
+ # Accessory states keyed by unique identifier (e.g., "A12_1")
76
+ self._accessory_states: Dict[str, AccessoryState] = {}
77
+
78
+ # Action key to accessory ID mapping
79
+ self._action_map: Dict[str, str] = {}
80
+
81
+ # Set up HomeKit callback
82
+ self._accessory_driver.set_callback(self._on_homekit_set)
83
+
84
+ # Connect to protocol signals
85
+ self._connect_signals()
86
+
87
+ # Initialize accessory states from config
88
+ self._initialize_accessory_states()
89
+
90
+ def _initialize_accessory_states(self) -> None:
91
+ """Initialize accessory states from HomekitConfig and ConsonModuleListConfig."""
92
+ action_keys = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
93
+ action_index = 0
94
+ sort_order = 0
95
+
96
+ for room in self._homekit_config.bridge.rooms:
97
+ for accessory_name in room.accessories:
98
+ accessory_config = self._find_accessory_config(accessory_name)
99
+ if not accessory_config:
100
+ self.logger.warning(
101
+ f"Accessory config not found for {accessory_name}"
102
+ )
103
+ continue
104
+
105
+ module_config = self._conson_config.find_module(
106
+ accessory_config.serial_number
107
+ )
108
+ if not module_config:
109
+ self.logger.warning(
110
+ f"Module config not found for {accessory_config.serial_number}"
111
+ )
112
+ continue
113
+
114
+ # Create unique identifier
115
+ accessory_id = (
116
+ f"{module_config.name}_{accessory_config.output_number + 1}"
117
+ )
118
+
119
+ # Assign action key
120
+ action_key = (
121
+ action_keys[action_index] if action_index < len(action_keys) else ""
122
+ )
123
+ action_index += 1
124
+ sort_order += 1
125
+
126
+ state = AccessoryState(
127
+ room_name=room.name,
128
+ accessory_name=accessory_config.description
129
+ or accessory_config.name,
130
+ action=action_key,
131
+ output_state="?",
132
+ dimming_state="",
133
+ module_name=module_config.name,
134
+ serial_number=accessory_config.serial_number,
135
+ module_type=module_config.module_type,
136
+ error_status="OK",
137
+ output=accessory_config.output_number + 1, # 1-based
138
+ sort=sort_order,
139
+ last_update=None,
140
+ toggle_action=accessory_config.toggle_action,
141
+ )
142
+
143
+ self._accessory_states[accessory_id] = state
144
+ if action_key:
145
+ self._action_map[action_key] = accessory_id
146
+
147
+ def _find_accessory_config(self, name: str) -> Optional[HomekitAccessoryConfig]:
148
+ """
149
+ Find accessory config by name.
150
+
151
+ Args:
152
+ name: Accessory name to find.
153
+
154
+ Returns:
155
+ HomekitAccessoryConfig if found, None otherwise.
156
+ """
157
+ for accessory in self._homekit_config.accessories:
158
+ if accessory.name == name:
159
+ return accessory
160
+ return None
161
+
162
+ def _find_accessory_config_by_output(
163
+ self, serial_number: str, output: int
164
+ ) -> Optional[HomekitAccessoryConfig]:
165
+ """
166
+ Find accessory config by serial number and output.
167
+
168
+ Args:
169
+ serial_number: Module serial number.
170
+ output: Output number (1-based).
171
+
172
+ Returns:
173
+ HomekitAccessoryConfig if found, None otherwise.
174
+ """
175
+ for accessory in self._homekit_config.accessories:
176
+ if (
177
+ accessory.serial_number == serial_number
178
+ and accessory.output_number == output - 1
179
+ ):
180
+ return accessory
181
+ return None
182
+
183
+ def _connect_signals(self) -> None:
184
+ """Connect to protocol signals."""
185
+ self._conbus_protocol.on_connection_made.connect(self._on_connection_made)
186
+ self._conbus_protocol.on_connection_failed.connect(self._on_connection_failed)
187
+ self._conbus_protocol.on_telegram_received.connect(self._on_telegram_received)
188
+ self._conbus_protocol.on_timeout.connect(self._on_timeout)
189
+ self._conbus_protocol.on_failed.connect(self._on_failed)
190
+
191
+ def _disconnect_signals(self) -> None:
192
+ """Disconnect from protocol signals."""
193
+ self._conbus_protocol.on_connection_made.disconnect(self._on_connection_made)
194
+ self._conbus_protocol.on_connection_failed.disconnect(
195
+ self._on_connection_failed
196
+ )
197
+ self._conbus_protocol.on_telegram_received.disconnect(
198
+ self._on_telegram_received
199
+ )
200
+ self._conbus_protocol.on_timeout.disconnect(self._on_timeout)
201
+ self._conbus_protocol.on_failed.disconnect(self._on_failed)
202
+
203
+ @property
204
+ def connection_state(self) -> ConnectionState:
205
+ """
206
+ Get current connection state.
207
+
208
+ Returns:
209
+ Current connection state.
210
+ """
211
+ return self._connection_state
212
+
213
+ @property
214
+ def server_info(self) -> str:
215
+ """
216
+ Get server connection info (IP:port).
217
+
218
+ Returns:
219
+ Server address in format "IP:port".
220
+ """
221
+ return f"{self._conbus_protocol.cli_config.ip}:{self._conbus_protocol.cli_config.port}"
222
+
223
+ @property
224
+ def accessory_states(self) -> List[AccessoryState]:
225
+ """
226
+ Get all accessory states.
227
+
228
+ Returns:
229
+ List of all accessory states.
230
+ """
231
+ accessories = list(self._accessory_states.values())
232
+ # Sort modules by link_number
233
+ accessories.sort(key=lambda a: a.sort)
234
+ return accessories
235
+
236
+ def connect(self) -> None:
237
+ """Initiate connection to server."""
238
+ if not self._state_machine.can_transition("connect"):
239
+ self.logger.warning(
240
+ f"Cannot connect: current state is {self._connection_state.value}"
241
+ )
242
+ return
243
+
244
+ if self._state_machine.transition("connecting", ConnectionState.CONNECTING):
245
+ self._connection_state = ConnectionState.CONNECTING
246
+ self.on_connection_state_changed.emit(self._connection_state)
247
+ self.on_status_message.emit(f"Connecting to {self.server_info}...")
248
+
249
+ self._conbus_protocol.connect()
250
+
251
+ def disconnect(self) -> None:
252
+ """Disconnect from server."""
253
+ if not self._state_machine.can_transition("disconnect"):
254
+ self.logger.warning(
255
+ f"Cannot disconnect: current state is {self._connection_state.value}"
256
+ )
257
+ return
258
+
259
+ if self._state_machine.transition(
260
+ "disconnecting", ConnectionState.DISCONNECTING
261
+ ):
262
+ self._connection_state = ConnectionState.DISCONNECTING
263
+ self.on_connection_state_changed.emit(self._connection_state)
264
+ self.on_status_message.emit("Disconnecting...")
265
+
266
+ self._conbus_protocol.disconnect()
267
+
268
+ if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
269
+ self._connection_state = ConnectionState.DISCONNECTED
270
+ self.on_connection_state_changed.emit(self._connection_state)
271
+ self.on_status_message.emit("Disconnected")
272
+
273
+ async def start(self) -> None:
274
+ """Start the service and AccessoryDriver."""
275
+ self.connect()
276
+ await self._accessory_driver.start()
277
+
278
+ async def stop(self) -> None:
279
+ """Stop the AccessoryDriver and cleanup."""
280
+ await self._accessory_driver.stop()
281
+ self.cleanup()
282
+
283
+ def _on_homekit_set(self, accessory_name: str, is_on: bool) -> None:
284
+ """
285
+ Handle HomeKit app toggle request.
286
+
287
+ Args:
288
+ accessory_name: Accessory name from HomeKit.
289
+ is_on: True for on, False for off.
290
+ """
291
+ config = self._find_accessory_config(accessory_name)
292
+ if config:
293
+ action = config.on_action if is_on else config.off_action
294
+ self._conbus_protocol.send_raw_telegram(action)
295
+ self.on_status_message.emit(
296
+ f"HomeKit: {accessory_name} {'ON' if is_on else 'OFF'}"
297
+ )
298
+ else:
299
+ self.logger.warning(f"No config found for accessory: {accessory_name}")
300
+
301
+ def toggle_connection(self) -> None:
302
+ """
303
+ Toggle connection state between connected and disconnected.
304
+
305
+ Disconnects if currently connected or connecting. Connects if currently
306
+ disconnected or failed.
307
+ """
308
+ if self._connection_state in (
309
+ ConnectionState.CONNECTED,
310
+ ConnectionState.CONNECTING,
311
+ ):
312
+ self.disconnect()
313
+ else:
314
+ self.connect()
315
+
316
+ def toggle_accessory(self, action_key: str) -> bool:
317
+ """
318
+ Toggle accessory by action key.
319
+
320
+ Sends the toggle_action telegram for the accessory mapped to the given key.
321
+
322
+ Args:
323
+ action_key: Action key (a-z).
324
+
325
+ Returns:
326
+ True if toggle was sent, False otherwise.
327
+ """
328
+ accessory_id = self._action_map.get(action_key)
329
+ if not accessory_id:
330
+ return False
331
+
332
+ state = self._accessory_states.get(accessory_id)
333
+ if not state or not state.toggle_action:
334
+ self.logger.warning(f"No toggle_action for accessory {accessory_id}")
335
+ return False
336
+
337
+ self._conbus_protocol.send_raw_telegram(state.toggle_action)
338
+ self.on_status_message.emit(f"Toggling {state.accessory_name}")
339
+ return True
340
+
341
+ def refresh_all(self) -> None:
342
+ """
343
+ Refresh all module states.
344
+
345
+ Queries module_output_state datapoint for eligible modules (XP24, XP33LR,
346
+ XP33LED). Updates outputs column and last_update timestamp for each queried
347
+ module.
348
+ """
349
+ self.on_status_message.emit("Refreshing module states...")
350
+
351
+ # Eligible module types that support output state queries
352
+ eligible_types = {"XP24", "XP33LR", "XP33LED"}
353
+
354
+ # Track already queried serial numbers to avoid duplicates
355
+ queried_serials: set[str] = set()
356
+
357
+ for state in self._accessory_states.values():
358
+ if (
359
+ state.module_type in eligible_types
360
+ and state.serial_number not in queried_serials
361
+ ):
362
+ self._query_module_output_state(state.serial_number)
363
+ queried_serials.add(state.serial_number)
364
+ self.logger.debug(
365
+ f"Querying output state for {state.module_name} ({state.module_type})"
366
+ )
367
+
368
+ def _query_module_output_state(self, serial_number: str) -> None:
369
+ """
370
+ Query module output state datapoint.
371
+
372
+ Args:
373
+ serial_number: Module serial number to query.
374
+ """
375
+ self._conbus_protocol.send_telegram(
376
+ telegram_type=TelegramType.SYSTEM,
377
+ serial_number=serial_number,
378
+ system_function=SystemFunction.READ_DATAPOINT,
379
+ data_value=str(DataPointType.MODULE_OUTPUT_STATE.value),
380
+ )
381
+
382
+ def _on_connection_made(self) -> None:
383
+ """Handle connection made event."""
384
+ if self._state_machine.transition("connected", ConnectionState.CONNECTED):
385
+ self._connection_state = ConnectionState.CONNECTED
386
+ self.on_connection_state_changed.emit(self._connection_state)
387
+ self.on_status_message.emit(f"Connected to {self.server_info}")
388
+
389
+ # Emit initial accessory list
390
+ self.on_room_list_updated.emit(self.accessory_states)
391
+
392
+ def _on_connection_failed(self, failure: Exception) -> None:
393
+ """
394
+ Handle connection failed event.
395
+
396
+ Args:
397
+ failure: Exception that caused the failure.
398
+ """
399
+ if self._state_machine.transition("failed", ConnectionState.FAILED):
400
+ self._connection_state = ConnectionState.FAILED
401
+ self.on_connection_state_changed.emit(self._connection_state)
402
+ self.on_status_message.emit(f"Connection failed: {failure}")
403
+
404
+ def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
405
+ """
406
+ Handle telegram received event.
407
+
408
+ Routes telegrams to appropriate handlers based on type.
409
+
410
+ Args:
411
+ event: Telegram received event.
412
+ """
413
+ if event.telegram_type == TelegramType.REPLY:
414
+ self._handle_reply_telegram(event)
415
+ elif event.telegram_type == TelegramType.EVENT:
416
+ self._handle_event_telegram(event)
417
+
418
+ def _handle_reply_telegram(self, event: TelegramReceivedEvent) -> None:
419
+ """
420
+ Handle reply telegram for datapoint queries.
421
+
422
+ Args:
423
+ event: Telegram received event.
424
+ """
425
+ serial_number = event.serial_number
426
+ if not serial_number:
427
+ return
428
+
429
+ # Parse the reply telegram
430
+ reply_telegram = self._telegram_service.parse_reply_telegram(event.frame)
431
+ if not reply_telegram:
432
+ return
433
+
434
+ # Check if this is a module output state response
435
+ if (
436
+ reply_telegram.system_function == SystemFunction.READ_DATAPOINT
437
+ and reply_telegram.datapoint_type == DataPointType.MODULE_OUTPUT_STATE
438
+ ):
439
+ self._update_outputs_from_reply(serial_number, reply_telegram.data_value)
440
+
441
+ def _update_outputs_from_reply(self, serial_number: str, data_value: str) -> None:
442
+ """
443
+ Update accessory outputs from module output state reply.
444
+
445
+ Args:
446
+ serial_number: Module serial number.
447
+ data_value: Output state data value from reply.
448
+ """
449
+ # Parse output state bits using TelegramOutputService
450
+ outputs = TelegramOutputService.format_output_state(data_value)
451
+ output_list = outputs.split() if outputs else []
452
+
453
+ # Update all accessories for this serial_number
454
+ for state in self._accessory_states.values():
455
+ if state.serial_number == serial_number:
456
+ output_index = state.output - 1 # Convert to 0-based
457
+
458
+ if output_index < len(output_list):
459
+ is_on = output_list[output_index] == "1"
460
+ state.output_state = "ON" if is_on else "OFF"
461
+
462
+ # Update dimming state for dimmable modules
463
+ if state.is_dimmable():
464
+ state.dimming_state = "-" if not is_on else ""
465
+
466
+ # Sync to HomeKit
467
+ config = self._find_accessory_config_by_output(
468
+ serial_number, state.output
469
+ )
470
+ if config:
471
+ self._accessory_driver.update_state(config.name, is_on)
472
+ else:
473
+ state.output_state = "?"
474
+
475
+ state.last_update = datetime.now()
476
+ self.on_module_state_changed.emit(state)
477
+
478
+ def _handle_event_telegram(self, event: TelegramReceivedEvent) -> None:
479
+ """
480
+ Handle event telegram for output state changes.
481
+
482
+ Args:
483
+ event: Telegram received event.
484
+ """
485
+ event_telegram = self._telegram_service.parse_event_telegram(event.frame)
486
+ if not event_telegram:
487
+ return
488
+
489
+ # Determine output number based on module type
490
+ output_number = None
491
+
492
+ if event_telegram.module_type == ModuleTypeCode.XP24.value:
493
+ if 80 <= event_telegram.input_number <= 83:
494
+ output_number = event_telegram.input_number - 80
495
+ else:
496
+ return
497
+
498
+ elif event_telegram.module_type in (
499
+ ModuleTypeCode.XP33.value,
500
+ ModuleTypeCode.XP33LR.value,
501
+ ModuleTypeCode.XP33LED.value,
502
+ ):
503
+ if 80 <= event_telegram.input_number <= 82:
504
+ output_number = event_telegram.input_number - 80
505
+ else:
506
+ return
507
+ else:
508
+ return
509
+
510
+ # Find accessories matching link number and output
511
+ output_1_based = output_number + 1
512
+ for state in self._accessory_states.values():
513
+ module_config = self._conson_config.find_module(state.serial_number)
514
+ if not module_config:
515
+ continue
516
+
517
+ if (
518
+ module_config.link_number == event_telegram.link_number
519
+ and state.output == output_1_based
520
+ ):
521
+ # Update output state (M=ON, B=OFF)
522
+ is_on = event_telegram.is_button_press
523
+ state.output_state = "ON" if is_on else "OFF"
524
+
525
+ # Update dimming state for dimmable modules
526
+ if state.is_dimmable():
527
+ state.dimming_state = "-" if not is_on else ""
528
+
529
+ # Sync to HomeKit
530
+ config = self._find_accessory_config_by_output(
531
+ state.serial_number, state.output
532
+ )
533
+ if config:
534
+ self._accessory_driver.update_state(config.name, is_on)
535
+
536
+ state.last_update = datetime.now()
537
+ self.on_module_state_changed.emit(state)
538
+
539
+ self.logger.debug(
540
+ f"Updated {state.accessory_name} to {'ON' if is_on else 'OFF'}"
541
+ )
542
+
543
+ def _on_timeout(self) -> None:
544
+ """Handle timeout event."""
545
+ self.on_status_message.emit("Waiting for action")
546
+
547
+ def _on_failed(self, failure: Exception) -> None:
548
+ """
549
+ Handle protocol failure event.
550
+
551
+ Args:
552
+ failure: Exception that caused the failure.
553
+ """
554
+ if self._state_machine.transition("failed", ConnectionState.FAILED):
555
+ self._connection_state = ConnectionState.FAILED
556
+ self.on_connection_state_changed.emit(self._connection_state)
557
+ self.on_status_message.emit(f"Protocol error: {failure}")
558
+
559
+ def cleanup(self) -> None:
560
+ """Clean up service resources."""
561
+ self._disconnect_signals()
562
+ self.logger.debug("HomekitService cleaned up")
563
+
564
+ def __enter__(self) -> "HomekitService":
565
+ """
566
+ Context manager entry.
567
+
568
+ Returns:
569
+ Self for context manager.
570
+ """
571
+ return self
572
+
573
+ def __exit__(self, _exc_type: object, _exc_val: object, _exc_tb: object) -> None:
574
+ """
575
+ Context manager exit.
576
+
577
+ Args:
578
+ _exc_type: Exception type.
579
+ _exc_val: Exception value.
580
+ _exc_tb: Exception traceback.
581
+ """
582
+ self.cleanup()
@@ -299,7 +299,7 @@ class StateMonitorService:
299
299
 
300
300
  def _on_timeout(self) -> None:
301
301
  """Handle timeout event."""
302
- self.on_status_message.emit("Connection timeout")
302
+ self.on_status_message.emit("Waiting for action")
303
303
 
304
304
  def _on_failed(self, failure: Exception) -> None:
305
305
  """