conson-xp 1.50.1__py3-none-any.whl → 1.51.1__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.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
@@ -46,6 +46,9 @@ from xp.services.conbus.conbus_datapoint_service import (
46
46
  from xp.services.conbus.conbus_discover_service import ConbusDiscoverService
47
47
  from xp.services.conbus.conbus_event_list_service import ConbusEventListService
48
48
  from xp.services.conbus.conbus_event_raw_service import ConbusEventRawService
49
+ from xp.services.conbus.conbus_export_actiontable_service import (
50
+ ConbusActiontableExportService,
51
+ )
49
52
  from xp.services.conbus.conbus_export_service import ConbusExportService
50
53
  from xp.services.conbus.conbus_output_service import ConbusOutputService
51
54
  from xp.services.conbus.conbus_raw_service import ConbusRawService
@@ -74,8 +77,10 @@ from xp.services.telegram.telegram_discover_service import TelegramDiscoverServi
74
77
  from xp.services.telegram.telegram_link_number_service import LinkNumberService
75
78
  from xp.services.telegram.telegram_output_service import TelegramOutputService
76
79
  from xp.services.telegram.telegram_service import TelegramService
80
+ from xp.services.term.homekit_service import HomekitService
77
81
  from xp.services.term.protocol_monitor_service import ProtocolMonitorService
78
82
  from xp.services.term.state_monitor_service import StateMonitorService
83
+ from xp.term.homekit import HomekitApp
79
84
  from xp.term.protocol import ProtocolMonitorApp
80
85
  from xp.term.state import StateMonitorApp
81
86
  from xp.utils.logging import LoggerService
@@ -217,6 +222,15 @@ class ServiceContainer:
217
222
  scope=punq.Scope.singleton,
218
223
  )
219
224
 
225
+ self.container.register(
226
+ ConbusActiontableExportService,
227
+ factory=lambda: ConbusActiontableExportService(
228
+ download_service=self.container.resolve(ActionTableDownloadService),
229
+ module_list=self.container.resolve(ConsonModuleListConfig),
230
+ ),
231
+ scope=punq.Scope.singleton,
232
+ )
233
+
220
234
  # Terminal UI
221
235
  self.container.register(
222
236
  ProtocolMonitorService,
@@ -253,6 +267,25 @@ class ServiceContainer:
253
267
  scope=punq.Scope.singleton,
254
268
  )
255
269
 
270
+ self.container.register(
271
+ HomekitService,
272
+ factory=lambda: HomekitService(
273
+ conbus_protocol=self.container.resolve(ConbusEventProtocol),
274
+ homekit_config=self.container.resolve(HomekitConfig),
275
+ conson_config=self.container.resolve(ConsonModuleListConfig),
276
+ telegram_service=self.container.resolve(TelegramService),
277
+ ),
278
+ scope=punq.Scope.singleton,
279
+ )
280
+
281
+ self.container.register(
282
+ HomekitApp,
283
+ factory=lambda: HomekitApp(
284
+ homekit_service=self.container.resolve(HomekitService)
285
+ ),
286
+ scope=punq.Scope.singleton,
287
+ )
288
+
256
289
  self.container.register(
257
290
  ConbusEventRawService,
258
291
  factory=lambda: ConbusEventRawService(