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.
Files changed (39) hide show
  1. {conson_xp-1.51.1.dist-info → conson_xp-2.0.0.dist-info}/METADATA +1 -11
  2. {conson_xp-1.51.1.dist-info → conson_xp-2.0.0.dist-info}/RECORD +20 -38
  3. xp/__init__.py +1 -1
  4. xp/cli/commands/__init__.py +0 -4
  5. xp/cli/commands/term/term_commands.py +1 -1
  6. xp/cli/main.py +0 -3
  7. xp/models/homekit/homekit_config.py +4 -0
  8. xp/models/protocol/conbus_protocol.py +30 -25
  9. xp/models/term/accessory_state.py +1 -1
  10. xp/services/protocol/__init__.py +2 -3
  11. xp/services/protocol/conbus_event_protocol.py +5 -5
  12. xp/services/term/homekit_accessory_driver.py +171 -0
  13. xp/services/term/homekit_service.py +187 -10
  14. xp/term/homekit.py +146 -14
  15. xp/term/homekit.tcss +4 -4
  16. xp/term/widgets/room_list.py +61 -3
  17. xp/utils/dependencies.py +34 -154
  18. xp/cli/commands/homekit/__init__.py +0 -3
  19. xp/cli/commands/homekit/homekit.py +0 -120
  20. xp/cli/commands/homekit/homekit_start_commands.py +0 -44
  21. xp/services/homekit/__init__.py +0 -1
  22. xp/services/homekit/homekit_cache_service.py +0 -313
  23. xp/services/homekit/homekit_conbus_service.py +0 -99
  24. xp/services/homekit/homekit_config_validator.py +0 -327
  25. xp/services/homekit/homekit_conson_validator.py +0 -130
  26. xp/services/homekit/homekit_dimminglight.py +0 -189
  27. xp/services/homekit/homekit_dimminglight_service.py +0 -155
  28. xp/services/homekit/homekit_hap_service.py +0 -351
  29. xp/services/homekit/homekit_lightbulb.py +0 -125
  30. xp/services/homekit/homekit_lightbulb_service.py +0 -91
  31. xp/services/homekit/homekit_module_service.py +0 -60
  32. xp/services/homekit/homekit_outlet.py +0 -175
  33. xp/services/homekit/homekit_outlet_service.py +0 -127
  34. xp/services/homekit/homekit_service.py +0 -371
  35. xp/services/protocol/protocol_factory.py +0 -84
  36. xp/services/protocol/telegram_protocol.py +0 -270
  37. {conson_xp-1.51.1.dist-info → conson_xp-2.0.0.dist-info}/WHEEL +0 -0
  38. {conson_xp-1.51.1.dist-info → conson_xp-2.0.0.dist-info}/entry_points.txt +0 -0
  39. {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 = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
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 toggle_accessory(self, action_key: str) -> bool:
343
+ def select_accessory(self, action_key: str) -> Optional[str]:
261
344
  """
262
- Toggle accessory by action key.
345
+ Get accessory ID for action key.
263
346
 
264
- Sends the toggle_action telegram for the accessory mapped to the given key.
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
- action_key: Action key (a-z).
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._conbus_protocol.send_raw_telegram(state.toggle_action)
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
- and toggle control via action keys.
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
- ("r", "refresh_all", "Refresh"),
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. Sets up automatic screen
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.connect()
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
- Intercepts a-z keys to toggle accessories.
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.lower()
98
- if len(key) == 1 and "a" <= key <= "z":
99
- if self.homekit_service.toggle_accessory(key):
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 on_unmount(self) -> None:
115
- """Clean up service when app unmounts."""
116
- self.homekit_service.cleanup()
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: $background;
49
- color: $success;
48
+ background: $success;
49
+ color: $background;
50
50
  }
51
51
 
52
52
  DataTable:focus > .datatable--cursor {
53
- background: $background;
54
- color: $success;
53
+ background: $success;
54
+ color: $background;
55
55
  }
56
56
 
57
57
  /* Footer styling */
@@ -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
- self.table.add_row()
103
- self.table.add_row(Text(state.room_name, style="bold"))
104
- self.table.add_row()
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