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.
- {conson_xp-1.51.0.dist-info → conson_xp-1.52.0.dist-info}/METADATA +2 -1
- {conson_xp-1.51.0.dist-info → conson_xp-1.52.0.dist-info}/RECORD +19 -13
- xp/__init__.py +1 -1
- xp/cli/commands/term/term_commands.py +23 -0
- xp/models/homekit/homekit_config.py +6 -0
- xp/models/term/__init__.py +2 -0
- xp/models/term/accessory_state.py +50 -0
- xp/services/homekit/homekit_config_validator.py +1 -1
- xp/services/term/homekit_accessory_driver.py +168 -0
- xp/services/term/homekit_service.py +582 -0
- xp/services/term/state_monitor_service.py +1 -1
- xp/term/homekit.py +116 -0
- xp/term/homekit.tcss +86 -0
- xp/term/widgets/room_list.py +232 -0
- xp/term/widgets/status_footer.py +6 -3
- xp/utils/dependencies.py +31 -0
- {conson_xp-1.51.0.dist-info → conson_xp-1.52.0.dist-info}/WHEEL +0 -0
- {conson_xp-1.51.0.dist-info → conson_xp-1.52.0.dist-info}/entry_points.txt +0 -0
- {conson_xp-1.51.0.dist-info → conson_xp-1.52.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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("
|
|
302
|
+
self.on_status_message.emit("Waiting for action")
|
|
303
303
|
|
|
304
304
|
def _on_failed(self, failure: Exception) -> None:
|
|
305
305
|
"""
|