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.
@@ -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
+ }
@@ -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(id="help-table", show_header=False)
40
- self.help_table.can_focus = False
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.