conson-xp 1.51.1__py3-none-any.whl → 2.0.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.1.dist-info → conson_xp-2.0.0.dist-info}/METADATA +1 -11
- {conson_xp-1.51.1.dist-info → conson_xp-2.0.0.dist-info}/RECORD +20 -38
- xp/__init__.py +1 -1
- xp/cli/commands/__init__.py +0 -4
- xp/cli/commands/term/term_commands.py +1 -1
- xp/cli/main.py +0 -3
- xp/models/homekit/homekit_config.py +4 -0
- xp/models/protocol/conbus_protocol.py +30 -25
- xp/models/term/accessory_state.py +1 -1
- xp/services/protocol/__init__.py +2 -3
- xp/services/protocol/conbus_event_protocol.py +5 -5
- xp/services/term/homekit_accessory_driver.py +171 -0
- xp/services/term/homekit_service.py +187 -10
- xp/term/homekit.py +146 -14
- xp/term/homekit.tcss +4 -4
- xp/term/widgets/room_list.py +61 -3
- xp/utils/dependencies.py +34 -154
- xp/cli/commands/homekit/__init__.py +0 -3
- xp/cli/commands/homekit/homekit.py +0 -120
- xp/cli/commands/homekit/homekit_start_commands.py +0 -44
- xp/services/homekit/__init__.py +0 -1
- xp/services/homekit/homekit_cache_service.py +0 -313
- xp/services/homekit/homekit_conbus_service.py +0 -99
- xp/services/homekit/homekit_config_validator.py +0 -327
- xp/services/homekit/homekit_conson_validator.py +0 -130
- xp/services/homekit/homekit_dimminglight.py +0 -189
- xp/services/homekit/homekit_dimminglight_service.py +0 -155
- xp/services/homekit/homekit_hap_service.py +0 -351
- xp/services/homekit/homekit_lightbulb.py +0 -125
- xp/services/homekit/homekit_lightbulb_service.py +0 -91
- xp/services/homekit/homekit_module_service.py +0 -60
- xp/services/homekit/homekit_outlet.py +0 -175
- xp/services/homekit/homekit_outlet_service.py +0 -127
- xp/services/homekit/homekit_service.py +0 -371
- xp/services/protocol/protocol_factory.py +0 -84
- xp/services/protocol/telegram_protocol.py +0 -270
- {conson_xp-1.51.1.dist-info → conson_xp-2.0.0.dist-info}/WHEEL +0 -0
- {conson_xp-1.51.1.dist-info → conson_xp-2.0.0.dist-info}/entry_points.txt +0 -0
- {conson_xp-1.51.1.dist-info → conson_xp-2.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -18,6 +18,7 @@ from xp.models.term.connection_state import ConnectionState
|
|
|
18
18
|
from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
|
|
19
19
|
from xp.services.telegram.telegram_output_service import TelegramOutputService
|
|
20
20
|
from xp.services.telegram.telegram_service import TelegramService
|
|
21
|
+
from xp.services.term.homekit_accessory_driver import HomekitAccessoryDriver
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class HomekitService:
|
|
@@ -50,6 +51,7 @@ class HomekitService:
|
|
|
50
51
|
homekit_config: HomekitConfig,
|
|
51
52
|
conson_config: ConsonModuleListConfig,
|
|
52
53
|
telegram_service: TelegramService,
|
|
54
|
+
accessory_driver: HomekitAccessoryDriver,
|
|
53
55
|
) -> None:
|
|
54
56
|
"""
|
|
55
57
|
Initialize the HomeKit service.
|
|
@@ -59,12 +61,14 @@ class HomekitService:
|
|
|
59
61
|
homekit_config: HomekitConfig for accessory configuration.
|
|
60
62
|
conson_config: ConsonModuleListConfig for module configuration.
|
|
61
63
|
telegram_service: TelegramService for parsing telegrams.
|
|
64
|
+
accessory_driver: HomekitAccessoryDriver for pyhap integration.
|
|
62
65
|
"""
|
|
63
66
|
self.logger = logging.getLogger(__name__)
|
|
64
67
|
self._conbus_protocol = conbus_protocol
|
|
65
68
|
self._homekit_config = homekit_config
|
|
66
69
|
self._conson_config = conson_config
|
|
67
70
|
self._telegram_service = telegram_service
|
|
71
|
+
self._accessory_driver = accessory_driver
|
|
68
72
|
self._connection_state = ConnectionState.DISCONNECTED
|
|
69
73
|
self._state_machine = ConnectionState.create_state_machine()
|
|
70
74
|
|
|
@@ -74,6 +78,9 @@ class HomekitService:
|
|
|
74
78
|
# Action key to accessory ID mapping
|
|
75
79
|
self._action_map: Dict[str, str] = {}
|
|
76
80
|
|
|
81
|
+
# Set up HomeKit callback
|
|
82
|
+
self._accessory_driver.set_callback(self._on_homekit_set)
|
|
83
|
+
|
|
77
84
|
# Connect to protocol signals
|
|
78
85
|
self._connect_signals()
|
|
79
86
|
|
|
@@ -82,7 +89,7 @@ class HomekitService:
|
|
|
82
89
|
|
|
83
90
|
def _initialize_accessory_states(self) -> None:
|
|
84
91
|
"""Initialize accessory states from HomekitConfig and ConsonModuleListConfig."""
|
|
85
|
-
action_keys = "
|
|
92
|
+
action_keys = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
86
93
|
action_index = 0
|
|
87
94
|
sort_order = 0
|
|
88
95
|
|
|
@@ -152,6 +159,44 @@ class HomekitService:
|
|
|
152
159
|
return accessory
|
|
153
160
|
return None
|
|
154
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 _find_accessory_config_by_id(
|
|
184
|
+
self, accessory_id: str
|
|
185
|
+
) -> Optional[HomekitAccessoryConfig]:
|
|
186
|
+
"""
|
|
187
|
+
Find accessory config by accessory ID.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
accessory_id: Accessory ID (e.g., "A12_1").
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
HomekitAccessoryConfig if found, None otherwise.
|
|
194
|
+
"""
|
|
195
|
+
state = self._accessory_states.get(accessory_id)
|
|
196
|
+
if not state:
|
|
197
|
+
return None
|
|
198
|
+
return self._find_accessory_config_by_output(state.serial_number, state.output)
|
|
199
|
+
|
|
155
200
|
def _connect_signals(self) -> None:
|
|
156
201
|
"""Connect to protocol signals."""
|
|
157
202
|
self._conbus_protocol.on_connection_made.connect(self._on_connection_made)
|
|
@@ -242,6 +287,44 @@ class HomekitService:
|
|
|
242
287
|
self.on_connection_state_changed.emit(self._connection_state)
|
|
243
288
|
self.on_status_message.emit("Disconnected")
|
|
244
289
|
|
|
290
|
+
async def start(self) -> None:
|
|
291
|
+
"""Start the service and AccessoryDriver."""
|
|
292
|
+
self.connect()
|
|
293
|
+
await self._accessory_driver.start()
|
|
294
|
+
|
|
295
|
+
async def stop(self) -> None:
|
|
296
|
+
"""Stop the AccessoryDriver and cleanup."""
|
|
297
|
+
await self._accessory_driver.stop()
|
|
298
|
+
self.cleanup()
|
|
299
|
+
|
|
300
|
+
def _on_homekit_set(self, accessory_name: str, is_on: bool) -> None:
|
|
301
|
+
"""
|
|
302
|
+
Handle HomeKit app toggle request.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
accessory_name: Accessory name from HomeKit.
|
|
306
|
+
is_on: True for on, False for off.
|
|
307
|
+
"""
|
|
308
|
+
config = self._find_accessory_config(accessory_name)
|
|
309
|
+
if config:
|
|
310
|
+
action = config.on_action if is_on else config.off_action
|
|
311
|
+
self.send_action(action)
|
|
312
|
+
self.on_status_message.emit(
|
|
313
|
+
f"HomeKit: {accessory_name} {'ON' if is_on else 'OFF'}"
|
|
314
|
+
)
|
|
315
|
+
else:
|
|
316
|
+
self.logger.warning(f"No config found for accessory: {accessory_name}")
|
|
317
|
+
|
|
318
|
+
def send_action(self, action: str) -> None:
|
|
319
|
+
"""
|
|
320
|
+
Send an action telegram to the conbus protocol.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
action: The action string to send (e.g., "E00L00I00").
|
|
324
|
+
"""
|
|
325
|
+
self._conbus_protocol.send_raw_telegram(f"{action}M")
|
|
326
|
+
self._conbus_protocol.send_raw_telegram(f"{action}B")
|
|
327
|
+
|
|
245
328
|
def toggle_connection(self) -> None:
|
|
246
329
|
"""
|
|
247
330
|
Toggle connection state between connected and disconnected.
|
|
@@ -257,31 +340,111 @@ class HomekitService:
|
|
|
257
340
|
else:
|
|
258
341
|
self.connect()
|
|
259
342
|
|
|
260
|
-
def
|
|
343
|
+
def select_accessory(self, action_key: str) -> Optional[str]:
|
|
261
344
|
"""
|
|
262
|
-
|
|
345
|
+
Get accessory ID for action key.
|
|
263
346
|
|
|
264
|
-
|
|
347
|
+
Args:
|
|
348
|
+
action_key: Action key (a-z0-9).
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Accessory ID if found, None otherwise.
|
|
352
|
+
"""
|
|
353
|
+
return self._action_map.get(action_key)
|
|
354
|
+
|
|
355
|
+
def toggle_selected(self, accessory_id: str) -> bool:
|
|
356
|
+
"""
|
|
357
|
+
Toggle accessory by ID.
|
|
265
358
|
|
|
266
359
|
Args:
|
|
267
|
-
|
|
360
|
+
accessory_id: Accessory ID (e.g., "A12_1").
|
|
268
361
|
|
|
269
362
|
Returns:
|
|
270
363
|
True if toggle was sent, False otherwise.
|
|
271
364
|
"""
|
|
272
|
-
accessory_id = self._action_map.get(action_key)
|
|
273
|
-
if not accessory_id:
|
|
274
|
-
return False
|
|
275
|
-
|
|
276
365
|
state = self._accessory_states.get(accessory_id)
|
|
277
366
|
if not state or not state.toggle_action:
|
|
278
367
|
self.logger.warning(f"No toggle_action for accessory {accessory_id}")
|
|
279
368
|
return False
|
|
280
369
|
|
|
281
|
-
self.
|
|
370
|
+
self.send_action(state.toggle_action)
|
|
282
371
|
self.on_status_message.emit(f"Toggling {state.accessory_name}")
|
|
283
372
|
return True
|
|
284
373
|
|
|
374
|
+
def turn_on_selected(self, accessory_id: str) -> bool:
|
|
375
|
+
"""
|
|
376
|
+
Turn on accessory by ID.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
accessory_id: Accessory ID (e.g., "A12_1").
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
True if on command was sent, False otherwise.
|
|
383
|
+
"""
|
|
384
|
+
config = self._find_accessory_config_by_id(accessory_id)
|
|
385
|
+
state = self._accessory_states.get(accessory_id)
|
|
386
|
+
if not config or not state:
|
|
387
|
+
self.logger.warning(f"No config for accessory {accessory_id}")
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
self.send_action(config.on_action)
|
|
391
|
+
self.on_status_message.emit(f"Turning ON {state.accessory_name}")
|
|
392
|
+
return True
|
|
393
|
+
|
|
394
|
+
def turn_off_selected(self, accessory_id: str) -> bool:
|
|
395
|
+
"""
|
|
396
|
+
Turn off accessory by ID.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
accessory_id: Accessory ID (e.g., "A12_1").
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
True if off command was sent, False otherwise.
|
|
403
|
+
"""
|
|
404
|
+
config = self._find_accessory_config_by_id(accessory_id)
|
|
405
|
+
state = self._accessory_states.get(accessory_id)
|
|
406
|
+
if not config or not state:
|
|
407
|
+
self.logger.warning(f"No config for accessory {accessory_id}")
|
|
408
|
+
return False
|
|
409
|
+
|
|
410
|
+
self.send_action(config.off_action)
|
|
411
|
+
self.on_status_message.emit(f"Turning OFF {state.accessory_name}")
|
|
412
|
+
return True
|
|
413
|
+
|
|
414
|
+
def increase_dimmer(self, accessory_id: str) -> bool:
|
|
415
|
+
"""
|
|
416
|
+
Increase dimmer level for accessory.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
accessory_id: Accessory ID (e.g., "A12_1").
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
True if command was sent, False otherwise.
|
|
423
|
+
"""
|
|
424
|
+
state = self._accessory_states.get(accessory_id)
|
|
425
|
+
if not state:
|
|
426
|
+
return False
|
|
427
|
+
# TODO: Implement dimmer control
|
|
428
|
+
self.on_status_message.emit(f"Dimmer+ {state.accessory_name} (not implemented)")
|
|
429
|
+
return False
|
|
430
|
+
|
|
431
|
+
def decrease_dimmer(self, accessory_id: str) -> bool:
|
|
432
|
+
"""
|
|
433
|
+
Decrease dimmer level for accessory.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
accessory_id: Accessory ID (e.g., "A12_1").
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
True if command was sent, False otherwise.
|
|
440
|
+
"""
|
|
441
|
+
state = self._accessory_states.get(accessory_id)
|
|
442
|
+
if not state:
|
|
443
|
+
return False
|
|
444
|
+
# TODO: Implement dimmer control
|
|
445
|
+
self.on_status_message.emit(f"Dimmer- {state.accessory_name} (not implemented)")
|
|
446
|
+
return False
|
|
447
|
+
|
|
285
448
|
def refresh_all(self) -> None:
|
|
286
449
|
"""
|
|
287
450
|
Refresh all module states.
|
|
@@ -406,6 +569,13 @@ class HomekitService:
|
|
|
406
569
|
# Update dimming state for dimmable modules
|
|
407
570
|
if state.is_dimmable():
|
|
408
571
|
state.dimming_state = "-" if not is_on else ""
|
|
572
|
+
|
|
573
|
+
# Sync to HomeKit
|
|
574
|
+
config = self._find_accessory_config_by_output(
|
|
575
|
+
serial_number, state.output
|
|
576
|
+
)
|
|
577
|
+
if config:
|
|
578
|
+
self._accessory_driver.update_state(config.name, is_on)
|
|
409
579
|
else:
|
|
410
580
|
state.output_state = "?"
|
|
411
581
|
|
|
@@ -463,6 +633,13 @@ class HomekitService:
|
|
|
463
633
|
if state.is_dimmable():
|
|
464
634
|
state.dimming_state = "-" if not is_on else ""
|
|
465
635
|
|
|
636
|
+
# Sync to HomeKit
|
|
637
|
+
config = self._find_accessory_config_by_output(
|
|
638
|
+
state.serial_number, state.output
|
|
639
|
+
)
|
|
640
|
+
if config:
|
|
641
|
+
self._accessory_driver.update_state(config.name, is_on)
|
|
642
|
+
|
|
466
643
|
state.last_update = datetime.now()
|
|
467
644
|
self.on_module_state_changed.emit(state)
|
|
468
645
|
|
xp/term/homekit.py
CHANGED
|
@@ -4,6 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
from typing import Any, Optional
|
|
5
5
|
|
|
6
6
|
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.widgets import DataTable
|
|
7
8
|
|
|
8
9
|
from xp.services.term.homekit_service import HomekitService
|
|
9
10
|
from xp.term.widgets.room_list import RoomListWidget
|
|
@@ -14,11 +15,13 @@ class HomekitApp(App[None]):
|
|
|
14
15
|
"""
|
|
15
16
|
Textual app for HomeKit accessory monitoring.
|
|
16
17
|
|
|
17
|
-
Displays rooms and accessories with real-time state updates
|
|
18
|
-
|
|
18
|
+
Displays rooms and accessories with real-time state updates.
|
|
19
|
+
Select accessory with action key, then perform action on selection.
|
|
19
20
|
|
|
20
21
|
Attributes:
|
|
21
22
|
homekit_service: HomekitService for accessory state operations.
|
|
23
|
+
selected_accessory_id: Currently selected accessory ID.
|
|
24
|
+
_last_cursor_row: Last cursor row for direction detection.
|
|
22
25
|
CSS_PATH: Path to CSS stylesheet file.
|
|
23
26
|
BINDINGS: Keyboard bindings for app actions.
|
|
24
27
|
TITLE: Application title displayed in header.
|
|
@@ -32,7 +35,12 @@ class HomekitApp(App[None]):
|
|
|
32
35
|
BINDINGS = [
|
|
33
36
|
("Q", "quit", "Quit"),
|
|
34
37
|
("C", "toggle_connection", "Connect"),
|
|
35
|
-
("
|
|
38
|
+
("R", "refresh_all", "Refresh"),
|
|
39
|
+
("space", "toggle_selected", "Toggle"),
|
|
40
|
+
("full_stop", "turn_on_selected", "On"),
|
|
41
|
+
("minus", "turn_off_selected", "Off"),
|
|
42
|
+
("plus", "dim_up", "Dim+"),
|
|
43
|
+
("quotation_mark", "dim_down", "Dim-"),
|
|
36
44
|
]
|
|
37
45
|
|
|
38
46
|
def __init__(self, homekit_service: HomekitService) -> None:
|
|
@@ -44,6 +52,8 @@ class HomekitApp(App[None]):
|
|
|
44
52
|
"""
|
|
45
53
|
super().__init__()
|
|
46
54
|
self.homekit_service: HomekitService = homekit_service
|
|
55
|
+
self.selected_accessory_id: Optional[str] = None
|
|
56
|
+
self._last_cursor_row: int = 0
|
|
47
57
|
self.room_list_widget: Optional[RoomListWidget] = None
|
|
48
58
|
self.footer_widget: Optional[StatusFooterWidget] = None
|
|
49
59
|
|
|
@@ -68,14 +78,14 @@ class HomekitApp(App[None]):
|
|
|
68
78
|
"""
|
|
69
79
|
Initialize app after UI is mounted.
|
|
70
80
|
|
|
71
|
-
Delays connection by 0.5s to let UI render first.
|
|
72
|
-
refresh every second to update elapsed times.
|
|
81
|
+
Delays connection by 0.5s to let UI render first. Starts the AccessoryDriver and
|
|
82
|
+
sets up automatic screen refresh every second to update elapsed times.
|
|
73
83
|
"""
|
|
74
84
|
import asyncio
|
|
75
85
|
|
|
76
86
|
# Delay connection to let UI render
|
|
77
87
|
await asyncio.sleep(0.5)
|
|
78
|
-
self.homekit_service.
|
|
88
|
+
await self.homekit_service.start()
|
|
79
89
|
|
|
80
90
|
# Set up periodic refresh to update elapsed times
|
|
81
91
|
self.set_interval(1.0, self._refresh_last_update_column)
|
|
@@ -87,17 +97,114 @@ class HomekitApp(App[None]):
|
|
|
87
97
|
|
|
88
98
|
def on_key(self, event: Any) -> None:
|
|
89
99
|
"""
|
|
90
|
-
Handle key press events for action keys.
|
|
100
|
+
Handle key press events for selection and action keys.
|
|
91
101
|
|
|
92
|
-
|
|
102
|
+
Selection keys (a-z0-9): Select accessory row.
|
|
103
|
+
Action keys (on selected accessory):
|
|
104
|
+
- Space: Toggle
|
|
105
|
+
- . : Turn ON
|
|
106
|
+
- - : Turn OFF
|
|
107
|
+
- + : Dim up
|
|
108
|
+
- " : Dim down
|
|
93
109
|
|
|
94
110
|
Args:
|
|
95
111
|
event: Key press event.
|
|
96
112
|
"""
|
|
97
|
-
key = event.key
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
key = event.key
|
|
114
|
+
|
|
115
|
+
# Selection keys (a-z0-9)
|
|
116
|
+
if len(key) == 1 and (("a" <= key <= "z") or ("0" <= key <= "9")):
|
|
117
|
+
accessory_id = self.homekit_service.select_accessory(key)
|
|
118
|
+
if accessory_id:
|
|
119
|
+
self.selected_accessory_id = accessory_id
|
|
120
|
+
self._select_row(key)
|
|
100
121
|
event.prevent_default()
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Action keys (require selection)
|
|
125
|
+
if not self.selected_accessory_id:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
if key == "space":
|
|
129
|
+
self.homekit_service.toggle_selected(self.selected_accessory_id)
|
|
130
|
+
event.prevent_default()
|
|
131
|
+
elif key in ("full_stop", "."):
|
|
132
|
+
self.homekit_service.turn_on_selected(self.selected_accessory_id)
|
|
133
|
+
event.prevent_default()
|
|
134
|
+
elif key in ("minus", "-"):
|
|
135
|
+
self.homekit_service.turn_off_selected(self.selected_accessory_id)
|
|
136
|
+
event.prevent_default()
|
|
137
|
+
elif key in ("plus", "+"):
|
|
138
|
+
self.homekit_service.increase_dimmer(self.selected_accessory_id)
|
|
139
|
+
event.prevent_default()
|
|
140
|
+
elif key in ("quotation_mark", '"'):
|
|
141
|
+
self.homekit_service.decrease_dimmer(self.selected_accessory_id)
|
|
142
|
+
event.prevent_default()
|
|
143
|
+
|
|
144
|
+
def _select_row(self, action_key: str) -> None:
|
|
145
|
+
"""
|
|
146
|
+
Select row in RoomListWidget by action key.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
action_key: Action key to select.
|
|
150
|
+
"""
|
|
151
|
+
if self.room_list_widget:
|
|
152
|
+
self.room_list_widget.select_by_action_key(action_key)
|
|
153
|
+
|
|
154
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Handle row highlight changes from arrow key navigation.
|
|
157
|
+
|
|
158
|
+
Updates selected_accessory_id when cursor moves via arrow keys.
|
|
159
|
+
Skips non-accessory rows (layout rows) automatically.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
event: Row highlighted event from DataTable.
|
|
163
|
+
"""
|
|
164
|
+
if not self.room_list_widget or not event.row_key:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
accessory_id = self.room_list_widget.get_accessory_id_for_row(event.row_key)
|
|
168
|
+
if accessory_id:
|
|
169
|
+
self.selected_accessory_id = accessory_id
|
|
170
|
+
self._last_cursor_row = event.cursor_row
|
|
171
|
+
else:
|
|
172
|
+
# Non-accessory row (layout), skip to next valid row
|
|
173
|
+
self._skip_to_accessory_row(event.cursor_row)
|
|
174
|
+
|
|
175
|
+
def _skip_to_accessory_row(self, current_row: int) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Skip cursor to the nearest accessory row.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
current_row: Current cursor row index.
|
|
181
|
+
"""
|
|
182
|
+
if not self.room_list_widget or not self.room_list_widget.table:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
table = self.room_list_widget.table
|
|
186
|
+
row_count = table.row_count
|
|
187
|
+
|
|
188
|
+
# Determine direction based on last position
|
|
189
|
+
direction = 1 if current_row >= self._last_cursor_row else -1
|
|
190
|
+
|
|
191
|
+
# Search for next accessory row in direction
|
|
192
|
+
next_row = current_row + direction
|
|
193
|
+
while 0 <= next_row < row_count:
|
|
194
|
+
row_key = self.room_list_widget.get_row_key_at_index(next_row)
|
|
195
|
+
if row_key and self.room_list_widget.get_accessory_id_for_row(row_key):
|
|
196
|
+
table.move_cursor(row=next_row)
|
|
197
|
+
return
|
|
198
|
+
next_row += direction
|
|
199
|
+
|
|
200
|
+
# If not found in direction, try opposite direction
|
|
201
|
+
next_row = current_row - direction
|
|
202
|
+
while 0 <= next_row < row_count:
|
|
203
|
+
row_key = self.room_list_widget.get_row_key_at_index(next_row)
|
|
204
|
+
if row_key and self.room_list_widget.get_accessory_id_for_row(row_key):
|
|
205
|
+
table.move_cursor(row=next_row)
|
|
206
|
+
return
|
|
207
|
+
next_row -= direction
|
|
101
208
|
|
|
102
209
|
def action_toggle_connection(self) -> None:
|
|
103
210
|
"""
|
|
@@ -111,6 +218,31 @@ class HomekitApp(App[None]):
|
|
|
111
218
|
"""Refresh all module data on 'r' key press."""
|
|
112
219
|
self.homekit_service.refresh_all()
|
|
113
220
|
|
|
114
|
-
def
|
|
115
|
-
"""
|
|
116
|
-
self.
|
|
221
|
+
def action_toggle_selected(self) -> None:
|
|
222
|
+
"""Toggle selected accessory."""
|
|
223
|
+
if self.selected_accessory_id:
|
|
224
|
+
self.homekit_service.toggle_selected(self.selected_accessory_id)
|
|
225
|
+
|
|
226
|
+
def action_turn_on_selected(self) -> None:
|
|
227
|
+
"""Turn on selected accessory."""
|
|
228
|
+
if self.selected_accessory_id:
|
|
229
|
+
self.homekit_service.turn_on_selected(self.selected_accessory_id)
|
|
230
|
+
|
|
231
|
+
def action_turn_off_selected(self) -> None:
|
|
232
|
+
"""Turn off selected accessory."""
|
|
233
|
+
if self.selected_accessory_id:
|
|
234
|
+
self.homekit_service.turn_off_selected(self.selected_accessory_id)
|
|
235
|
+
|
|
236
|
+
def action_dim_up(self) -> None:
|
|
237
|
+
"""Increase dimmer on selected accessory."""
|
|
238
|
+
if self.selected_accessory_id:
|
|
239
|
+
self.homekit_service.increase_dimmer(self.selected_accessory_id)
|
|
240
|
+
|
|
241
|
+
def action_dim_down(self) -> None:
|
|
242
|
+
"""Decrease dimmer on selected accessory."""
|
|
243
|
+
if self.selected_accessory_id:
|
|
244
|
+
self.homekit_service.decrease_dimmer(self.selected_accessory_id)
|
|
245
|
+
|
|
246
|
+
async def on_unmount(self) -> None:
|
|
247
|
+
"""Stop AccessoryDriver and clean up service when app unmounts."""
|
|
248
|
+
await self.homekit_service.stop()
|
xp/term/homekit.tcss
CHANGED
|
@@ -45,13 +45,13 @@ DataTable > .datatable--header {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
DataTable > .datatable--cursor {
|
|
48
|
-
background: $
|
|
49
|
-
color: $
|
|
48
|
+
background: $success;
|
|
49
|
+
color: $background;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
DataTable:focus > .datatable--cursor {
|
|
53
|
-
background: $
|
|
54
|
-
color: $
|
|
53
|
+
background: $success;
|
|
54
|
+
color: $background;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/* Footer styling */
|
xp/term/widgets/room_list.py
CHANGED
|
@@ -41,6 +41,9 @@ class RoomListWidget(Static):
|
|
|
41
41
|
self.service = service
|
|
42
42
|
self.table: Optional[DataTable] = None
|
|
43
43
|
self._row_keys: dict[str, Any] = {} # Map accessory_id to row key
|
|
44
|
+
self._row_to_accessory: dict[Any, str] = {} # Map row key to accessory_id
|
|
45
|
+
self._row_index_to_key: list[Any] = [] # Map row index to row key
|
|
46
|
+
self._action_to_row: dict[str, Any] = {} # Map action key to row key
|
|
44
47
|
self._current_room: str = ""
|
|
45
48
|
|
|
46
49
|
def compose(self) -> ComposeResult:
|
|
@@ -93,15 +96,23 @@ class RoomListWidget(Static):
|
|
|
93
96
|
|
|
94
97
|
self.table.clear()
|
|
95
98
|
self._row_keys.clear()
|
|
99
|
+
self._row_to_accessory.clear()
|
|
100
|
+
self._row_index_to_key.clear()
|
|
101
|
+
self._action_to_row.clear()
|
|
96
102
|
self._current_room = ""
|
|
97
103
|
|
|
98
104
|
for state in accessory_states:
|
|
99
105
|
# Add room header row if new room
|
|
100
106
|
if state.room_name != self._current_room:
|
|
101
107
|
self._current_room = state.room_name
|
|
102
|
-
|
|
103
|
-
self.
|
|
104
|
-
|
|
108
|
+
# Add layout rows (empty and header) - not selectable
|
|
109
|
+
self._row_index_to_key.extend(
|
|
110
|
+
[
|
|
111
|
+
self.table.add_row(),
|
|
112
|
+
self.table.add_row(Text(state.room_name, style="bold")),
|
|
113
|
+
self.table.add_row(),
|
|
114
|
+
]
|
|
115
|
+
)
|
|
105
116
|
|
|
106
117
|
self._add_accessory_row(state)
|
|
107
118
|
|
|
@@ -160,6 +171,10 @@ class RoomListWidget(Static):
|
|
|
160
171
|
Text(self._format_last_update(state.last_update), justify="center"),
|
|
161
172
|
)
|
|
162
173
|
self._row_keys[accessory_id] = row_key
|
|
174
|
+
self._row_to_accessory[row_key] = accessory_id
|
|
175
|
+
self._row_index_to_key.append(row_key)
|
|
176
|
+
if state.action:
|
|
177
|
+
self._action_to_row[state.action] = row_key
|
|
163
178
|
|
|
164
179
|
def _format_dim(self, state: AccessoryState) -> str:
|
|
165
180
|
"""
|
|
@@ -230,3 +245,46 @@ class RoomListWidget(Static):
|
|
|
230
245
|
justify="center",
|
|
231
246
|
),
|
|
232
247
|
)
|
|
248
|
+
|
|
249
|
+
def select_by_action_key(self, action_key: str) -> None:
|
|
250
|
+
"""
|
|
251
|
+
Select and highlight row by action key.
|
|
252
|
+
|
|
253
|
+
Moves the table cursor to the row corresponding to the action key.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
action_key: Action key (a-z0-9) to select.
|
|
257
|
+
"""
|
|
258
|
+
if not self.table:
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
row_key = self._action_to_row.get(action_key)
|
|
262
|
+
if row_key is not None:
|
|
263
|
+
row_index = self.table.get_row_index(row_key)
|
|
264
|
+
self.table.move_cursor(row=row_index)
|
|
265
|
+
|
|
266
|
+
def get_accessory_id_for_row(self, row_key: Any) -> Optional[str]:
|
|
267
|
+
"""
|
|
268
|
+
Get accessory ID for a row key.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
row_key: DataTable row key.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Accessory ID if found, None otherwise.
|
|
275
|
+
"""
|
|
276
|
+
return self._row_to_accessory.get(row_key)
|
|
277
|
+
|
|
278
|
+
def get_row_key_at_index(self, index: int) -> Optional[Any]:
|
|
279
|
+
"""
|
|
280
|
+
Get row key at a given index.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
index: Row index.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Row key if valid index, None otherwise.
|
|
287
|
+
"""
|
|
288
|
+
if 0 <= index < len(self._row_index_to_key):
|
|
289
|
+
return self._row_index_to_key[index]
|
|
290
|
+
return None
|