conson-xp 1.27.0__py3-none-any.whl → 1.29.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.27.0.dist-info → conson_xp-1.29.0.dist-info}/METADATA +2 -1
- {conson_xp-1.27.0.dist-info → conson_xp-1.29.0.dist-info}/RECORD +22 -16
- xp/__init__.py +1 -1
- xp/cli/commands/term/term_commands.py +21 -0
- xp/models/term/__init__.py +2 -0
- xp/models/term/module_state.py +30 -0
- xp/services/server/client_buffer_manager.py +69 -0
- xp/services/server/server_service.py +21 -11
- xp/services/server/xp24_server_service.py +1 -4
- xp/services/server/xp33_server_service.py +1 -4
- xp/services/telegram/telegram_output_service.py +33 -0
- xp/services/term/__init__.py +2 -1
- xp/services/term/state_monitor_service.py +413 -0
- xp/term/state.py +97 -0
- xp/term/state.tcss +86 -0
- xp/term/widgets/help_menu.py +3 -3
- xp/term/widgets/modules_list.py +224 -0
- xp/term/widgets/status_footer.py +5 -4
- xp/utils/dependencies.py +20 -0
- {conson_xp-1.27.0.dist-info → conson_xp-1.29.0.dist-info}/WHEEL +0 -0
- {conson_xp-1.27.0.dist-info → conson_xp-1.29.0.dist-info}/entry_points.txt +0 -0
- {conson_xp-1.27.0.dist-info → conson_xp-1.29.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""State Monitor Service for terminal interface."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from psygnal import Signal
|
|
8
|
+
|
|
9
|
+
from xp.models.homekit.homekit_conson_config import ConsonModuleListConfig
|
|
10
|
+
from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
|
|
11
|
+
from xp.models.telegram.datapoint_type import DataPointType
|
|
12
|
+
from xp.models.telegram.module_type_code import ModuleTypeCode
|
|
13
|
+
from xp.models.telegram.system_function import SystemFunction
|
|
14
|
+
from xp.models.telegram.telegram_type import TelegramType
|
|
15
|
+
from xp.models.term.connection_state import ConnectionState
|
|
16
|
+
from xp.models.term.module_state import ModuleState
|
|
17
|
+
from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
|
|
18
|
+
from xp.services.telegram.telegram_output_service import TelegramOutputService
|
|
19
|
+
from xp.services.telegram.telegram_service import TelegramService
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StateMonitorService:
|
|
23
|
+
"""Service for module state monitoring in terminal interface.
|
|
24
|
+
|
|
25
|
+
Wraps ConbusEventProtocol and ConsonModuleListConfig to provide
|
|
26
|
+
high-level module state tracking for the TUI.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
on_connection_state_changed: Signal emitted when connection state changes.
|
|
30
|
+
on_module_list_updated: Signal emitted when module list refreshed from config.
|
|
31
|
+
on_module_state_changed: Signal emitted when individual module state updates.
|
|
32
|
+
on_module_error: Signal emitted when module error occurs.
|
|
33
|
+
on_status_message: Signal emitted for status messages.
|
|
34
|
+
connection_state: Property returning current connection state.
|
|
35
|
+
server_info: Property returning server connection info (IP:port).
|
|
36
|
+
module_states: Property returning list of all module states.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
on_connection_state_changed: Signal = Signal(ConnectionState)
|
|
40
|
+
on_module_list_updated: Signal = Signal(list)
|
|
41
|
+
on_module_state_changed: Signal = Signal(ModuleState)
|
|
42
|
+
on_module_error: Signal = Signal(str, str)
|
|
43
|
+
on_status_message: Signal = Signal(str)
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
conbus_protocol: ConbusEventProtocol,
|
|
48
|
+
conson_config: ConsonModuleListConfig,
|
|
49
|
+
telegram_service: TelegramService,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Initialize the State Monitor service.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
conbus_protocol: ConbusEventProtocol instance.
|
|
55
|
+
conson_config: ConsonModuleListConfig for module configuration.
|
|
56
|
+
telegram_service: TelegramService for parsing telegrams.
|
|
57
|
+
"""
|
|
58
|
+
self.logger = logging.getLogger(__name__)
|
|
59
|
+
self._conbus_protocol = conbus_protocol
|
|
60
|
+
self._conson_config = conson_config
|
|
61
|
+
self._telegram_service = telegram_service
|
|
62
|
+
self._connection_state = ConnectionState.DISCONNECTED
|
|
63
|
+
self._state_machine = ConnectionState.create_state_machine()
|
|
64
|
+
self._module_states: Dict[str, ModuleState] = {}
|
|
65
|
+
|
|
66
|
+
# Connect to protocol signals
|
|
67
|
+
self._connect_signals()
|
|
68
|
+
|
|
69
|
+
# Initialize module states from config
|
|
70
|
+
self._initialize_module_states()
|
|
71
|
+
|
|
72
|
+
def _initialize_module_states(self) -> None:
|
|
73
|
+
"""Initialize module states from ConsonModuleListConfig."""
|
|
74
|
+
for module_config in self._conson_config.root:
|
|
75
|
+
# Map auto_report_status: PP → True, others → False
|
|
76
|
+
auto_report = module_config.auto_report_status == "PP"
|
|
77
|
+
|
|
78
|
+
module_state = ModuleState(
|
|
79
|
+
name=module_config.name,
|
|
80
|
+
serial_number=module_config.serial_number,
|
|
81
|
+
module_type=module_config.module_type,
|
|
82
|
+
link_number=module_config.link_number,
|
|
83
|
+
outputs="", # Empty initially
|
|
84
|
+
auto_report=auto_report,
|
|
85
|
+
error_status="OK",
|
|
86
|
+
last_update=None, # Not updated yet
|
|
87
|
+
)
|
|
88
|
+
self._module_states[module_config.serial_number] = module_state
|
|
89
|
+
|
|
90
|
+
def _connect_signals(self) -> None:
|
|
91
|
+
"""Connect to protocol signals."""
|
|
92
|
+
self._conbus_protocol.on_connection_made.connect(self._on_connection_made)
|
|
93
|
+
self._conbus_protocol.on_connection_failed.connect(self._on_connection_failed)
|
|
94
|
+
self._conbus_protocol.on_telegram_received.connect(self._on_telegram_received)
|
|
95
|
+
self._conbus_protocol.on_timeout.connect(self._on_timeout)
|
|
96
|
+
self._conbus_protocol.on_failed.connect(self._on_failed)
|
|
97
|
+
|
|
98
|
+
def _disconnect_signals(self) -> None:
|
|
99
|
+
"""Disconnect from protocol signals."""
|
|
100
|
+
self._conbus_protocol.on_connection_made.disconnect(self._on_connection_made)
|
|
101
|
+
self._conbus_protocol.on_connection_failed.disconnect(
|
|
102
|
+
self._on_connection_failed
|
|
103
|
+
)
|
|
104
|
+
self._conbus_protocol.on_telegram_received.disconnect(
|
|
105
|
+
self._on_telegram_received
|
|
106
|
+
)
|
|
107
|
+
self._conbus_protocol.on_timeout.disconnect(self._on_timeout)
|
|
108
|
+
self._conbus_protocol.on_failed.disconnect(self._on_failed)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def connection_state(self) -> ConnectionState:
|
|
112
|
+
"""Get current connection state.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Current connection state.
|
|
116
|
+
"""
|
|
117
|
+
return self._connection_state
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def server_info(self) -> str:
|
|
121
|
+
"""Get server connection info (IP:port).
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Server address in format "IP:port".
|
|
125
|
+
"""
|
|
126
|
+
return f"{self._conbus_protocol.cli_config.ip}:{self._conbus_protocol.cli_config.port}"
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def module_states(self) -> List[ModuleState]:
|
|
130
|
+
"""Get all module states.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
List of all module states.
|
|
134
|
+
"""
|
|
135
|
+
return list(self._module_states.values())
|
|
136
|
+
|
|
137
|
+
def connect(self) -> None:
|
|
138
|
+
"""Initiate connection to server."""
|
|
139
|
+
if not self._state_machine.can_transition("connect"):
|
|
140
|
+
self.logger.warning(
|
|
141
|
+
f"Cannot connect: current state is {self._connection_state.value}"
|
|
142
|
+
)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
if self._state_machine.transition("connecting", ConnectionState.CONNECTING):
|
|
146
|
+
self._connection_state = ConnectionState.CONNECTING
|
|
147
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
148
|
+
self.on_status_message.emit(f"Connecting to {self.server_info}...")
|
|
149
|
+
|
|
150
|
+
self._conbus_protocol.connect()
|
|
151
|
+
|
|
152
|
+
def disconnect(self) -> None:
|
|
153
|
+
"""Disconnect from server."""
|
|
154
|
+
if not self._state_machine.can_transition("disconnect"):
|
|
155
|
+
self.logger.warning(
|
|
156
|
+
f"Cannot disconnect: current state is {self._connection_state.value}"
|
|
157
|
+
)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
if self._state_machine.transition(
|
|
161
|
+
"disconnecting", ConnectionState.DISCONNECTING
|
|
162
|
+
):
|
|
163
|
+
self._connection_state = ConnectionState.DISCONNECTING
|
|
164
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
165
|
+
self.on_status_message.emit("Disconnecting...")
|
|
166
|
+
|
|
167
|
+
self._conbus_protocol.disconnect()
|
|
168
|
+
|
|
169
|
+
if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
|
|
170
|
+
self._connection_state = ConnectionState.DISCONNECTED
|
|
171
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
172
|
+
self.on_status_message.emit("Disconnected")
|
|
173
|
+
|
|
174
|
+
def toggle_connection(self) -> None:
|
|
175
|
+
"""Toggle connection state between connected and disconnected.
|
|
176
|
+
|
|
177
|
+
Disconnects if currently connected or connecting.
|
|
178
|
+
Connects if currently disconnected or failed.
|
|
179
|
+
"""
|
|
180
|
+
if self._connection_state in (
|
|
181
|
+
ConnectionState.CONNECTED,
|
|
182
|
+
ConnectionState.CONNECTING,
|
|
183
|
+
):
|
|
184
|
+
self.disconnect()
|
|
185
|
+
else:
|
|
186
|
+
self.connect()
|
|
187
|
+
|
|
188
|
+
def refresh_all(self) -> None:
|
|
189
|
+
"""Refresh all module states.
|
|
190
|
+
|
|
191
|
+
Queries module_output_state datapoint for eligible modules (XP24, XP33LR, XP33LED).
|
|
192
|
+
Updates outputs column and last_update timestamp for each queried module.
|
|
193
|
+
"""
|
|
194
|
+
self.on_status_message.emit("Refreshing module states...")
|
|
195
|
+
|
|
196
|
+
# Eligible module types that support output state queries
|
|
197
|
+
eligible_types = {"XP24", "XP33LR", "XP33LED"}
|
|
198
|
+
|
|
199
|
+
# Filter and query eligible modules
|
|
200
|
+
for module_state in self._module_states.values():
|
|
201
|
+
if module_state.module_type in eligible_types:
|
|
202
|
+
self._query_module_output_state(module_state.serial_number)
|
|
203
|
+
self.logger.debug(
|
|
204
|
+
f"Querying output state for {module_state.name} ({module_state.module_type})"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _query_module_output_state(self, serial_number: str) -> None:
|
|
208
|
+
"""Query module output state datapoint.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
serial_number: Module serial number to query.
|
|
212
|
+
"""
|
|
213
|
+
self._conbus_protocol.send_telegram(
|
|
214
|
+
telegram_type=TelegramType.SYSTEM,
|
|
215
|
+
serial_number=serial_number,
|
|
216
|
+
system_function=SystemFunction.READ_DATAPOINT,
|
|
217
|
+
data_value=str(DataPointType.MODULE_OUTPUT_STATE.value),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _on_connection_made(self) -> None:
|
|
221
|
+
"""Handle connection made event."""
|
|
222
|
+
if self._state_machine.transition("connected", ConnectionState.CONNECTED):
|
|
223
|
+
self._connection_state = ConnectionState.CONNECTED
|
|
224
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
225
|
+
self.on_status_message.emit(f"Connected to {self.server_info}")
|
|
226
|
+
|
|
227
|
+
# Emit initial module list
|
|
228
|
+
self.on_module_list_updated.emit(self.module_states)
|
|
229
|
+
|
|
230
|
+
def _on_connection_failed(self, failure: Exception) -> None:
|
|
231
|
+
"""Handle connection failed event.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
failure: Exception that caused the failure.
|
|
235
|
+
"""
|
|
236
|
+
if self._state_machine.transition("failed", ConnectionState.FAILED):
|
|
237
|
+
self._connection_state = ConnectionState.FAILED
|
|
238
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
239
|
+
self.on_status_message.emit(f"Connection failed: {failure}")
|
|
240
|
+
|
|
241
|
+
def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
|
|
242
|
+
"""Handle telegram received event.
|
|
243
|
+
|
|
244
|
+
Routes telegrams to appropriate handlers based on type.
|
|
245
|
+
Processes reply telegrams for datapoint queries and event telegrams for state changes.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
event: Telegram received event.
|
|
249
|
+
"""
|
|
250
|
+
# Route based on telegram type
|
|
251
|
+
if event.telegram_type == TelegramType.REPLY:
|
|
252
|
+
self._handle_reply_telegram(event)
|
|
253
|
+
elif event.telegram_type == TelegramType.EVENT:
|
|
254
|
+
self._handle_event_telegram(event)
|
|
255
|
+
|
|
256
|
+
def _handle_reply_telegram(self, event: TelegramReceivedEvent) -> None:
|
|
257
|
+
"""Handle reply telegram for datapoint queries.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
event: Telegram received event.
|
|
261
|
+
"""
|
|
262
|
+
serial_number = event.serial_number
|
|
263
|
+
if not serial_number or serial_number not in self._module_states:
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# Parse the reply telegram
|
|
267
|
+
reply_telegram = self._telegram_service.parse_reply_telegram(event.frame)
|
|
268
|
+
if not reply_telegram:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
# Check if this is a module output state response
|
|
272
|
+
if (
|
|
273
|
+
reply_telegram.system_function == SystemFunction.READ_DATAPOINT
|
|
274
|
+
and reply_telegram.datapoint_type == DataPointType.MODULE_OUTPUT_STATE
|
|
275
|
+
):
|
|
276
|
+
module_state = self._module_states[serial_number]
|
|
277
|
+
|
|
278
|
+
# Parse output state from data_value using TelegramOutputService
|
|
279
|
+
outputs = TelegramOutputService.format_output_state(
|
|
280
|
+
reply_telegram.data_value
|
|
281
|
+
)
|
|
282
|
+
module_state.outputs = outputs
|
|
283
|
+
module_state.last_update = datetime.now()
|
|
284
|
+
|
|
285
|
+
self.on_module_state_changed.emit(module_state)
|
|
286
|
+
self.logger.debug(f"Updated outputs for {module_state.name}: {outputs}")
|
|
287
|
+
|
|
288
|
+
def _on_timeout(self) -> None:
|
|
289
|
+
"""Handle timeout event."""
|
|
290
|
+
self.on_status_message.emit("Connection timeout")
|
|
291
|
+
|
|
292
|
+
def _on_failed(self, failure: Exception) -> None:
|
|
293
|
+
"""Handle protocol failure event.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
failure: Exception that caused the failure.
|
|
297
|
+
"""
|
|
298
|
+
if self._state_machine.transition("failed", ConnectionState.FAILED):
|
|
299
|
+
self._connection_state = ConnectionState.FAILED
|
|
300
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
301
|
+
self.on_status_message.emit(f"Protocol error: {failure}")
|
|
302
|
+
|
|
303
|
+
def _find_module_by_link(self, link_number: int) -> Optional[ModuleState]:
|
|
304
|
+
"""Find module state by link number.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
link_number: Link number to search for.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
ModuleState if found, None otherwise.
|
|
311
|
+
"""
|
|
312
|
+
for module_state in self._module_states.values():
|
|
313
|
+
if module_state.link_number == link_number:
|
|
314
|
+
return module_state
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
def _update_output_bit(
|
|
318
|
+
self, module_state: ModuleState, output_number: int, output_state: bool
|
|
319
|
+
) -> None:
|
|
320
|
+
"""Update a single output bit in module state.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
module_state: Module state to update.
|
|
324
|
+
output_number: Output number (0-3 for XP24).
|
|
325
|
+
output_state: True for ON, False for OFF.
|
|
326
|
+
"""
|
|
327
|
+
# Parse existing outputs string "0 1 0 0" → [0, 1, 0, 0]
|
|
328
|
+
outputs = module_state.outputs.split() if module_state.outputs else []
|
|
329
|
+
|
|
330
|
+
# Ensure we have enough outputs
|
|
331
|
+
while len(outputs) <= output_number:
|
|
332
|
+
outputs.append("0")
|
|
333
|
+
|
|
334
|
+
# Update the specific output
|
|
335
|
+
outputs[output_number] = "1" if output_state else "0"
|
|
336
|
+
|
|
337
|
+
# Convert back to string format
|
|
338
|
+
module_state.outputs = " ".join(outputs)
|
|
339
|
+
|
|
340
|
+
def _handle_event_telegram(self, event: TelegramReceivedEvent) -> None:
|
|
341
|
+
"""Handle event telegram for output state changes.
|
|
342
|
+
|
|
343
|
+
Processes XP24 output event telegrams to update module state in real-time.
|
|
344
|
+
Output events use input_number field with values 80-83 to represent outputs 0-3.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
event: Telegram received event containing event telegram.
|
|
348
|
+
"""
|
|
349
|
+
# Parse the event telegram
|
|
350
|
+
event_telegram = self._telegram_service.parse_event_telegram(event.frame)
|
|
351
|
+
if not event_telegram:
|
|
352
|
+
self.logger.debug("Failed to parse event telegram")
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
# Only process XP24 output events
|
|
356
|
+
if event_telegram.module_type != ModuleTypeCode.XP24.value:
|
|
357
|
+
self.logger.debug(
|
|
358
|
+
f"Ignoring event from module type {event_telegram.module_type}"
|
|
359
|
+
)
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
# Validate output number range (80-83 for XP24 outputs 0-3)
|
|
363
|
+
if not (80 <= event_telegram.input_number <= 83):
|
|
364
|
+
self.logger.debug(
|
|
365
|
+
f"Ignoring input event I{event_telegram.input_number:02d}"
|
|
366
|
+
)
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
# Find module by link number
|
|
370
|
+
module_state = self._find_module_by_link(event_telegram.link_number)
|
|
371
|
+
if not module_state:
|
|
372
|
+
self.logger.debug(
|
|
373
|
+
f"Module not found for link number {event_telegram.link_number}"
|
|
374
|
+
)
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
# Convert input_number to output_number (80→0, 81→1, 82→2, 83→3)
|
|
378
|
+
output_number = event_telegram.input_number - 80
|
|
379
|
+
output_state = event_telegram.is_button_press # M=True, B=False
|
|
380
|
+
|
|
381
|
+
# Update output state
|
|
382
|
+
self._update_output_bit(module_state, output_number, output_state)
|
|
383
|
+
module_state.last_update = datetime.now()
|
|
384
|
+
|
|
385
|
+
# Emit signal for UI update
|
|
386
|
+
self.on_module_state_changed.emit(module_state)
|
|
387
|
+
self.logger.debug(
|
|
388
|
+
f"Updated {module_state.name} output {output_number} to "
|
|
389
|
+
f"{'ON' if output_state else 'OFF'}"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def cleanup(self) -> None:
|
|
393
|
+
"""Clean up service resources."""
|
|
394
|
+
self._disconnect_signals()
|
|
395
|
+
self.logger.debug("StateMonitorService cleaned up")
|
|
396
|
+
|
|
397
|
+
def __enter__(self) -> "StateMonitorService":
|
|
398
|
+
"""Context manager entry.
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
Self for context manager.
|
|
402
|
+
"""
|
|
403
|
+
return self
|
|
404
|
+
|
|
405
|
+
def __exit__(self, _exc_type: object, _exc_val: object, _exc_tb: object) -> None:
|
|
406
|
+
"""Context manager exit.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
_exc_type: Exception type.
|
|
410
|
+
_exc_val: Exception value.
|
|
411
|
+
_exc_tb: Exception traceback.
|
|
412
|
+
"""
|
|
413
|
+
self.cleanup()
|
xp/term/state.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""State Monitor TUI Application."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
|
|
8
|
+
from xp.services.term.state_monitor_service import StateMonitorService
|
|
9
|
+
from xp.term.widgets.modules_list import ModulesListWidget
|
|
10
|
+
from xp.term.widgets.status_footer import StatusFooterWidget
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StateMonitorApp(App[None]):
|
|
14
|
+
"""Textual app for module state monitoring.
|
|
15
|
+
|
|
16
|
+
Displays module states from Conson configuration in an interactive
|
|
17
|
+
terminal interface with real-time updates.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
state_service: StateMonitorService for module state operations.
|
|
21
|
+
CSS_PATH: Path to CSS stylesheet file.
|
|
22
|
+
BINDINGS: Keyboard bindings for app actions.
|
|
23
|
+
TITLE: Application title displayed in header.
|
|
24
|
+
ENABLE_COMMAND_PALETTE: Disable Textual's command palette feature.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
CSS_PATH = Path(__file__).parent / "state.tcss"
|
|
28
|
+
TITLE = "Modules"
|
|
29
|
+
ENABLE_COMMAND_PALETTE = False
|
|
30
|
+
|
|
31
|
+
BINDINGS = [
|
|
32
|
+
("Q", "quit", "Quit"),
|
|
33
|
+
("C", "toggle_connection", "Connect"),
|
|
34
|
+
("r", "refresh_all", "Refresh"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
def __init__(self, state_service: StateMonitorService) -> None:
|
|
38
|
+
"""Initialize the State Monitor app.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
state_service: StateMonitorService for module state operations.
|
|
42
|
+
"""
|
|
43
|
+
super().__init__()
|
|
44
|
+
self.state_service: StateMonitorService = state_service
|
|
45
|
+
self.modules_widget: Optional[ModulesListWidget] = None
|
|
46
|
+
self.footer_widget: Optional[StatusFooterWidget] = None
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
"""Compose the app layout with widgets.
|
|
50
|
+
|
|
51
|
+
Yields:
|
|
52
|
+
ModulesListWidget and StatusFooterWidget.
|
|
53
|
+
"""
|
|
54
|
+
self.modules_widget = ModulesListWidget(
|
|
55
|
+
service=self.state_service, id="modules-list"
|
|
56
|
+
)
|
|
57
|
+
yield self.modules_widget
|
|
58
|
+
|
|
59
|
+
self.footer_widget = StatusFooterWidget(
|
|
60
|
+
service=self.state_service, id="footer-container"
|
|
61
|
+
)
|
|
62
|
+
yield self.footer_widget
|
|
63
|
+
|
|
64
|
+
async def on_mount(self) -> None:
|
|
65
|
+
"""Initialize app after UI is mounted.
|
|
66
|
+
|
|
67
|
+
Delays connection by 0.5s to let UI render first.
|
|
68
|
+
Sets up automatic screen refresh every second to update elapsed times.
|
|
69
|
+
"""
|
|
70
|
+
import asyncio
|
|
71
|
+
|
|
72
|
+
# Delay connection to let UI render
|
|
73
|
+
await asyncio.sleep(0.5)
|
|
74
|
+
self.state_service.connect()
|
|
75
|
+
|
|
76
|
+
# Set up periodic refresh to update elapsed times
|
|
77
|
+
self.set_interval(1.0, self._refresh_last_update_column)
|
|
78
|
+
|
|
79
|
+
def _refresh_last_update_column(self) -> None:
|
|
80
|
+
"""Refresh only the last_update column to show elapsed time."""
|
|
81
|
+
if self.modules_widget:
|
|
82
|
+
self.modules_widget.refresh_last_update_times()
|
|
83
|
+
|
|
84
|
+
def action_toggle_connection(self) -> None:
|
|
85
|
+
"""Toggle connection on 'c' key press.
|
|
86
|
+
|
|
87
|
+
Connects if disconnected/failed, disconnects if connected/connecting.
|
|
88
|
+
"""
|
|
89
|
+
self.state_service.toggle_connection()
|
|
90
|
+
|
|
91
|
+
def action_refresh_all(self) -> None:
|
|
92
|
+
"""Refresh all module data on 'r' key press."""
|
|
93
|
+
self.state_service.refresh_all()
|
|
94
|
+
|
|
95
|
+
def on_unmount(self) -> None:
|
|
96
|
+
"""Clean up service when app unmounts."""
|
|
97
|
+
self.state_service.cleanup()
|
xp/term/state.tcss
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* State Monitor TUI Styling */
|
|
2
|
+
|
|
3
|
+
/* Color overrides */
|
|
4
|
+
$success: #00ff00;
|
|
5
|
+
|
|
6
|
+
/* App-level styling */
|
|
7
|
+
Screen {
|
|
8
|
+
background: $background;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* Modules List Widget */
|
|
12
|
+
ModulesListWidget {
|
|
13
|
+
border: solid $success;
|
|
14
|
+
border-title-align: left;
|
|
15
|
+
width: 1fr;
|
|
16
|
+
height: 1fr;
|
|
17
|
+
background: $background;
|
|
18
|
+
padding: 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
ModulesListWidget:focus {
|
|
22
|
+
background: $background;
|
|
23
|
+
background-tint: transparent;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#modules-table {
|
|
27
|
+
background: $background !important;
|
|
28
|
+
width: 100%;
|
|
29
|
+
height: 1fr;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#modules-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
|
+
}
|
xp/term/widgets/help_menu.py
CHANGED
|
@@ -36,10 +36,10 @@ class HelpMenuWidget(Vertical):
|
|
|
36
36
|
"""
|
|
37
37
|
super().__init__(*args, **kwargs)
|
|
38
38
|
self.service: ProtocolMonitorService = service
|
|
39
|
-
self.help_table: DataTable = DataTable(
|
|
40
|
-
|
|
39
|
+
self.help_table: DataTable = DataTable(
|
|
40
|
+
id="help-table", show_header=False, cursor_type="row"
|
|
41
|
+
)
|
|
41
42
|
self.border_title = "Help menu"
|
|
42
|
-
self.can_focus = False
|
|
43
43
|
|
|
44
44
|
def compose(self) -> ComposeResult:
|
|
45
45
|
"""Compose the help menu layout.
|