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.
xp/term/homekit.py ADDED
@@ -0,0 +1,116 @@
1
+ """HomeKit TUI Application."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Optional
5
+
6
+ from textual.app import App, ComposeResult
7
+
8
+ from xp.services.term.homekit_service import HomekitService
9
+ from xp.term.widgets.room_list import RoomListWidget
10
+ from xp.term.widgets.status_footer import StatusFooterWidget
11
+
12
+
13
+ class HomekitApp(App[None]):
14
+ """
15
+ Textual app for HomeKit accessory monitoring.
16
+
17
+ Displays rooms and accessories with real-time state updates
18
+ and toggle control via action keys.
19
+
20
+ Attributes:
21
+ homekit_service: HomekitService for accessory state operations.
22
+ CSS_PATH: Path to CSS stylesheet file.
23
+ BINDINGS: Keyboard bindings for app actions.
24
+ TITLE: Application title displayed in header.
25
+ ENABLE_COMMAND_PALETTE: Disable Textual's command palette feature.
26
+ """
27
+
28
+ CSS_PATH = Path(__file__).parent / "homekit.tcss"
29
+ TITLE = "HomeKit"
30
+ ENABLE_COMMAND_PALETTE = False
31
+
32
+ BINDINGS = [
33
+ ("Q", "quit", "Quit"),
34
+ ("C", "toggle_connection", "Connect"),
35
+ ("r", "refresh_all", "Refresh"),
36
+ ]
37
+
38
+ def __init__(self, homekit_service: HomekitService) -> None:
39
+ """
40
+ Initialize the HomeKit app.
41
+
42
+ Args:
43
+ homekit_service: HomekitService for accessory state operations.
44
+ """
45
+ super().__init__()
46
+ self.homekit_service: HomekitService = homekit_service
47
+ self.room_list_widget: Optional[RoomListWidget] = None
48
+ self.footer_widget: Optional[StatusFooterWidget] = None
49
+
50
+ def compose(self) -> ComposeResult:
51
+ """
52
+ Compose the app layout with widgets.
53
+
54
+ Yields:
55
+ RoomListWidget and StatusFooterWidget.
56
+ """
57
+ self.room_list_widget = RoomListWidget(
58
+ service=self.homekit_service, id="room-list"
59
+ )
60
+ yield self.room_list_widget
61
+
62
+ self.footer_widget = StatusFooterWidget(
63
+ service=self.homekit_service, id="footer-container"
64
+ )
65
+ yield self.footer_widget
66
+
67
+ async def on_mount(self) -> None:
68
+ """
69
+ Initialize app after UI is mounted.
70
+
71
+ Delays connection by 0.5s to let UI render first. Starts the AccessoryDriver and
72
+ sets up automatic screen refresh every second to update elapsed times.
73
+ """
74
+ import asyncio
75
+
76
+ # Delay connection to let UI render
77
+ await asyncio.sleep(0.5)
78
+ await self.homekit_service.start()
79
+
80
+ # Set up periodic refresh to update elapsed times
81
+ self.set_interval(1.0, self._refresh_last_update_column)
82
+
83
+ def _refresh_last_update_column(self) -> None:
84
+ """Refresh only the last_update column to show elapsed time."""
85
+ if self.room_list_widget:
86
+ self.room_list_widget.refresh_last_update_times()
87
+
88
+ def on_key(self, event: Any) -> None:
89
+ """
90
+ Handle key press events for action keys.
91
+
92
+ Intercepts a-z keys to toggle accessories.
93
+
94
+ Args:
95
+ event: Key press event.
96
+ """
97
+ key = event.key.lower()
98
+ if len(key) == 1 and "a" <= key <= "z":
99
+ if self.homekit_service.toggle_accessory(key):
100
+ event.prevent_default()
101
+
102
+ def action_toggle_connection(self) -> None:
103
+ """
104
+ Toggle connection on 'c' key press.
105
+
106
+ Connects if disconnected/failed, disconnects if connected/connecting.
107
+ """
108
+ self.homekit_service.toggle_connection()
109
+
110
+ def action_refresh_all(self) -> None:
111
+ """Refresh all module data on 'r' key press."""
112
+ self.homekit_service.refresh_all()
113
+
114
+ async def on_unmount(self) -> None:
115
+ """Stop AccessoryDriver and clean up service when app unmounts."""
116
+ await self.homekit_service.stop()
xp/term/homekit.tcss ADDED
@@ -0,0 +1,86 @@
1
+ /* HomeKit TUI Styling */
2
+
3
+ /* Color overrides */
4
+ $success: #00ff00;
5
+
6
+ /* App-level styling */
7
+ Screen {
8
+ background: $background;
9
+ }
10
+
11
+ /* Room List Widget */
12
+ RoomListWidget {
13
+ border: solid $success;
14
+ border-title-align: left;
15
+ width: 1fr;
16
+ height: 1fr;
17
+ background: $background;
18
+ padding: 1;
19
+ }
20
+
21
+ RoomListWidget:focus {
22
+ background: $background;
23
+ background-tint: transparent;
24
+ }
25
+
26
+ #rooms-table {
27
+ background: $background !important;
28
+ width: 100%;
29
+ height: 1fr;
30
+ }
31
+
32
+ #rooms-table:focus {
33
+ background: $background !important;
34
+ background-tint: transparent;
35
+ }
36
+
37
+ DataTable {
38
+ background: $background;
39
+ color: $success;
40
+ }
41
+
42
+ DataTable > .datatable--header {
43
+ background: $background;
44
+ color: $success;
45
+ }
46
+
47
+ DataTable > .datatable--cursor {
48
+ background: $background;
49
+ color: $success;
50
+ }
51
+
52
+ DataTable:focus > .datatable--cursor {
53
+ background: $background;
54
+ color: $success;
55
+ }
56
+
57
+ /* Footer styling */
58
+ #footer-container {
59
+ dock: bottom;
60
+ height: 1;
61
+ background: $background;
62
+ }
63
+
64
+ Footer {
65
+ width: auto;
66
+ background: $background;
67
+ color: $text;
68
+ }
69
+
70
+ #status-text {
71
+ dock: right;
72
+ width: auto;
73
+ padding: 0 3;
74
+ background: $background;
75
+ color: $text;
76
+ text-align: right;
77
+ }
78
+
79
+ #status-line {
80
+ dock: right;
81
+ width: auto;
82
+ padding: 0 1;
83
+ background: $background;
84
+ color: $text;
85
+ text-align: right;
86
+ }
@@ -0,0 +1,232 @@
1
+ """Room List Widget for displaying HomeKit accessories table."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any, List, Optional
5
+
6
+ from rich.text import Text
7
+ from textual.app import ComposeResult
8
+ from textual.widgets import DataTable, Static
9
+
10
+ from xp.models.term.accessory_state import AccessoryState
11
+ from xp.services.term.homekit_service import HomekitService
12
+
13
+
14
+ class RoomListWidget(Static):
15
+ """
16
+ Widget displaying HomeKit accessories in a data table.
17
+
18
+ Shows room/accessory hierarchy with real-time state updates from HomekitService.
19
+ Table displays: room/accessory, action, state, dim, module, serial, type, status, output, updated.
20
+
21
+ Attributes:
22
+ service: HomekitService for accessory state updates.
23
+ table: DataTable widget displaying accessory information.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ service: Optional[HomekitService] = None,
29
+ *args: Any,
30
+ **kwargs: Any,
31
+ ) -> None:
32
+ """
33
+ Initialize the Room List widget.
34
+
35
+ Args:
36
+ service: Optional HomekitService for signal subscriptions.
37
+ args: Additional positional arguments for Static.
38
+ kwargs: Additional keyword arguments for Static.
39
+ """
40
+ super().__init__(*args, **kwargs)
41
+ self.service = service
42
+ self.table: Optional[DataTable] = None
43
+ self._row_keys: dict[str, Any] = {} # Map accessory_id to row key
44
+ self._current_room: str = ""
45
+
46
+ def compose(self) -> ComposeResult:
47
+ """
48
+ Compose the widget layout.
49
+
50
+ Yields:
51
+ DataTable widget.
52
+ """
53
+ self.table = DataTable(id="rooms-table", cursor_type="row")
54
+ yield self.table
55
+
56
+ def on_mount(self) -> None:
57
+ """Initialize table and subscribe to service signals when widget mounts."""
58
+ self.border_title = "Rooms"
59
+
60
+ if self.table:
61
+ self.table.add_column("room / accessory", key="name", width=35)
62
+ self.table.add_column("action", key="action", width=8)
63
+ self.table.add_column("state", key="state", width=7)
64
+ self.table.add_column("dim", key="dim", width=6)
65
+ self.table.add_column("module", key="module", width=8)
66
+ self.table.add_column("serial", key="serial", width=12)
67
+ self.table.add_column("type", key="type", width=10)
68
+ self.table.add_column("status", key="status", width=8)
69
+ self.table.add_column("output", key="output", width=7)
70
+ self.table.add_column("updated", key="updated", width=10)
71
+
72
+ if self.service:
73
+ self.service.on_room_list_updated.connect(self.update_accessory_list)
74
+ self.service.on_module_state_changed.connect(self.update_accessory_state)
75
+
76
+ def on_unmount(self) -> None:
77
+ """Unsubscribe from service signals when widget unmounts."""
78
+ if self.service:
79
+ self.service.on_room_list_updated.disconnect(self.update_accessory_list)
80
+ self.service.on_module_state_changed.disconnect(self.update_accessory_state)
81
+
82
+ def update_accessory_list(self, accessory_states: List[AccessoryState]) -> None:
83
+ """
84
+ Update entire accessory list from service.
85
+
86
+ Clears existing table and repopulates with all accessories grouped by room.
87
+
88
+ Args:
89
+ accessory_states: List of all accessory states.
90
+ """
91
+ if not self.table:
92
+ return
93
+
94
+ self.table.clear()
95
+ self._row_keys.clear()
96
+ self._current_room = ""
97
+
98
+ for state in accessory_states:
99
+ # Add room header row if new room
100
+ if state.room_name != self._current_room:
101
+ 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()
105
+
106
+ self._add_accessory_row(state)
107
+
108
+ def update_accessory_state(self, state: AccessoryState) -> None:
109
+ """
110
+ Update individual accessory state in table.
111
+
112
+ Updates existing row if accessory exists, otherwise adds new row.
113
+
114
+ Args:
115
+ state: Updated accessory state.
116
+ """
117
+ if not self.table:
118
+ return
119
+
120
+ accessory_id = f"{state.module_name}_{state.output}"
121
+
122
+ if accessory_id in self._row_keys:
123
+ row_key = self._row_keys[accessory_id]
124
+ self.table.update_cell(
125
+ row_key, "state", Text(state.output_state, justify="center")
126
+ )
127
+ self.table.update_cell(
128
+ row_key, "dim", Text(self._format_dim(state), justify="center")
129
+ )
130
+ self.table.update_cell(row_key, "status", state.error_status)
131
+ self.table.update_cell(
132
+ row_key,
133
+ "updated",
134
+ Text(self._format_last_update(state.last_update), justify="center"),
135
+ )
136
+ else:
137
+ self._add_accessory_row(state)
138
+
139
+ def _add_accessory_row(self, state: AccessoryState) -> None:
140
+ """
141
+ Add an accessory row to the table.
142
+
143
+ Args:
144
+ state: Accessory state to add.
145
+ """
146
+ if not self.table:
147
+ return
148
+
149
+ accessory_id = f"{state.module_name}_{state.output}"
150
+ row_key = self.table.add_row(
151
+ f" - {state.accessory_name}",
152
+ Text(state.action, justify="center"),
153
+ Text(state.output_state, justify="center"),
154
+ Text(self._format_dim(state), justify="center"),
155
+ state.module_name,
156
+ state.serial_number,
157
+ state.module_type,
158
+ state.error_status,
159
+ Text(str(state.output), justify="right"),
160
+ Text(self._format_last_update(state.last_update), justify="center"),
161
+ )
162
+ self._row_keys[accessory_id] = row_key
163
+
164
+ def _format_dim(self, state: AccessoryState) -> str:
165
+ """
166
+ Format dimming state for display.
167
+
168
+ Shows percentage if dimmable and ON, "-" if dimmable and OFF, empty otherwise.
169
+
170
+ Args:
171
+ state: Accessory state.
172
+
173
+ Returns:
174
+ Formatted dimming string.
175
+ """
176
+ if not state.is_dimmable():
177
+ return ""
178
+ if state.output_state == "OFF":
179
+ return "-"
180
+ return state.dimming_state or ""
181
+
182
+ def _format_last_update(self, last_update: Optional[datetime]) -> str:
183
+ """
184
+ Format last update timestamp for display.
185
+
186
+ Shows elapsed time in HH:MM:SS format or "--:--:--" if never updated.
187
+
188
+ Args:
189
+ last_update: Last update timestamp or None.
190
+
191
+ Returns:
192
+ Formatted time string.
193
+ """
194
+ if last_update is None:
195
+ return "--:--:--"
196
+
197
+ elapsed = datetime.now() - last_update
198
+ total_seconds = int(elapsed.total_seconds())
199
+
200
+ hours = total_seconds // 3600
201
+ minutes = (total_seconds % 3600) // 60
202
+ seconds = total_seconds % 60
203
+
204
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
205
+
206
+ def refresh_last_update_times(self) -> None:
207
+ """
208
+ Refresh only the last_update column for all accessories.
209
+
210
+ Updates the elapsed time display without querying the service.
211
+ """
212
+ if not self.table or not self.service:
213
+ return
214
+
215
+ for accessory_id, row_key in self._row_keys.items():
216
+ state = next(
217
+ (
218
+ s
219
+ for s in self.service.accessory_states
220
+ if f"{s.module_name}_{s.output}" == accessory_id
221
+ ),
222
+ None,
223
+ )
224
+ if state:
225
+ self.table.update_cell(
226
+ row_key,
227
+ "updated",
228
+ Text(
229
+ self._format_last_update(state.last_update),
230
+ justify="center",
231
+ ),
232
+ )
@@ -7,6 +7,7 @@ from textual.containers import Horizontal
7
7
  from textual.widgets import Footer, Static
8
8
 
9
9
  from xp.models.term.connection_state import ConnectionState
10
+ from xp.services.term.homekit_service import HomekitService
10
11
  from xp.services.term.protocol_monitor_service import ProtocolMonitorService
11
12
  from xp.services.term.state_monitor_service import StateMonitorService
12
13
 
@@ -19,14 +20,16 @@ class StatusFooterWidget(Horizontal):
19
20
  the current connection state. Subscribes directly to service signals.
20
21
 
21
22
  Attributes:
22
- service: ProtocolMonitorService or StateMonitorService for connection state and status updates.
23
+ service: ProtocolMonitorService, StateMonitorService, or HomekitService for connection state and status updates.
23
24
  status_widget: Static widget displaying colored status dot.
24
25
  status_text_widget: Static widget displaying status messages.
25
26
  """
26
27
 
27
28
  def __init__(
28
29
  self,
29
- service: Optional[Union[ProtocolMonitorService, StateMonitorService]] = None,
30
+ service: Optional[
31
+ Union[ProtocolMonitorService, StateMonitorService, HomekitService]
32
+ ] = None,
30
33
  *args: Any,
31
34
  **kwargs: Any,
32
35
  ) -> None:
@@ -34,7 +37,7 @@ class StatusFooterWidget(Horizontal):
34
37
  Initialize the Status Footer widget.
35
38
 
36
39
  Args:
37
- service: Optional ProtocolMonitorService or StateMonitorService for signal subscriptions.
40
+ service: Optional ProtocolMonitorService, StateMonitorService, or HomekitService for signal subscriptions.
38
41
  args: Additional positional arguments for Horizontal.
39
42
  kwargs: Additional keyword arguments for Horizontal.
40
43
  """
xp/utils/dependencies.py CHANGED
@@ -77,8 +77,11 @@ from xp.services.telegram.telegram_discover_service import TelegramDiscoverServi
77
77
  from xp.services.telegram.telegram_link_number_service import LinkNumberService
78
78
  from xp.services.telegram.telegram_output_service import TelegramOutputService
79
79
  from xp.services.telegram.telegram_service import TelegramService
80
+ from xp.services.term.homekit_accessory_driver import HomekitAccessoryDriver
81
+ from xp.services.term.homekit_service import HomekitService
80
82
  from xp.services.term.protocol_monitor_service import ProtocolMonitorService
81
83
  from xp.services.term.state_monitor_service import StateMonitorService
84
+ from xp.term.homekit import HomekitApp
82
85
  from xp.term.protocol import ProtocolMonitorApp
83
86
  from xp.term.state import StateMonitorApp
84
87
  from xp.utils.logging import LoggerService
@@ -265,6 +268,34 @@ class ServiceContainer:
265
268
  scope=punq.Scope.singleton,
266
269
  )
267
270
 
271
+ self.container.register(
272
+ HomekitAccessoryDriver,
273
+ factory=lambda: HomekitAccessoryDriver(
274
+ homekit_config=self.container.resolve(HomekitConfig),
275
+ ),
276
+ scope=punq.Scope.singleton,
277
+ )
278
+
279
+ self.container.register(
280
+ HomekitService,
281
+ factory=lambda: HomekitService(
282
+ conbus_protocol=self.container.resolve(ConbusEventProtocol),
283
+ homekit_config=self.container.resolve(HomekitConfig),
284
+ conson_config=self.container.resolve(ConsonModuleListConfig),
285
+ telegram_service=self.container.resolve(TelegramService),
286
+ accessory_driver=self.container.resolve(HomekitAccessoryDriver),
287
+ ),
288
+ scope=punq.Scope.singleton,
289
+ )
290
+
291
+ self.container.register(
292
+ HomekitApp,
293
+ factory=lambda: HomekitApp(
294
+ homekit_service=self.container.resolve(HomekitService)
295
+ ),
296
+ scope=punq.Scope.singleton,
297
+ )
298
+
268
299
  self.container.register(
269
300
  ConbusEventRawService,
270
301
  factory=lambda: ConbusEventRawService(