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.
- {conson_xp-1.50.1.dist-info → conson_xp-1.51.1.dist-info}/METADATA +2 -1
- {conson_xp-1.50.1.dist-info → conson_xp-1.51.1.dist-info}/RECORD +21 -16
- xp/__init__.py +1 -1
- xp/cli/commands/conbus/conbus_export_commands.py +8 -2
- xp/cli/commands/term/term_commands.py +23 -0
- xp/models/homekit/homekit_config.py +2 -0
- xp/models/term/__init__.py +2 -0
- xp/models/term/accessory_state.py +50 -0
- xp/services/conbus/actiontable/actiontable_download_service.py +2 -0
- xp/services/conbus/conbus_export_actiontable_service.py +47 -27
- xp/services/homekit/homekit_config_validator.py +1 -1
- xp/services/term/homekit_service.py +512 -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 +33 -0
- {conson_xp-1.50.1.dist-info → conson_xp-1.51.1.dist-info}/WHEEL +0 -0
- {conson_xp-1.50.1.dist-info → conson_xp-1.51.1.dist-info}/entry_points.txt +0 -0
- {conson_xp-1.50.1.dist-info → conson_xp-1.51.1.dist-info}/licenses/LICENSE +0 -0
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
|
+
)
|
xp/term/widgets/status_footer.py
CHANGED
|
@@ -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
|
|
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[
|
|
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
|
|
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(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|