conson-xp 1.26.0__py3-none-any.whl → 1.28.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: conson-xp
3
- Version: 1.26.0
3
+ Version: 1.28.0
4
4
  Summary: XP Protocol Communication Tools
5
5
  Author-Email: ldvchosal <ldvchosal@github.com>
6
6
  License: MIT License
@@ -417,6 +417,7 @@ xp telegram version
417
417
 
418
418
  xp term
419
419
  xp term protocol
420
+ xp term state
420
421
 
421
422
  <!-- END CLI HELP -->
422
423
  ```
@@ -1,8 +1,8 @@
1
- conson_xp-1.26.0.dist-info/METADATA,sha256=x2lguLJmE7yZdTNZLrogbVdb93RrTgFOv-Izz_7-eII,10298
2
- conson_xp-1.26.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
- conson_xp-1.26.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
- conson_xp-1.26.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
- xp/__init__.py,sha256=w0zdd6Iu_R5Xugn6Z7IEbzdaTA5LPt1ALETFqFfqQtc,181
1
+ conson_xp-1.28.0.dist-info/METADATA,sha256=vm3hTbH67pUIRDsWHMEsHM4UGNUw3ZNL6V5Xh3KZxPE,10312
2
+ conson_xp-1.28.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
+ conson_xp-1.28.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
+ conson_xp-1.28.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
+ xp/__init__.py,sha256=Ww_plPH2wL8n-IB9Qn1--AEu9xjpxIPxX53TpCchpnM,181
6
6
  xp/cli/__init__.py,sha256=QjnKB1KaI2aIyKlzrnvCwfbBuUj8HNgwNMvNJVQofbI,81
7
7
  xp/cli/__main__.py,sha256=l2iKwMdat5rTGd3JWs-uGksnYYDDffp_Npz05QdKEeU,117
8
8
  xp/cli/commands/__init__.py,sha256=noh8fdZAWq-ihJEboP8WugbIgq4LJ3jUWMRA7720xWE,4909
@@ -42,7 +42,7 @@ xp/cli/commands/telegram/telegram_parse_commands.py,sha256=_OYOso1hS4f_ox96qlkYL
42
42
  xp/cli/commands/telegram/telegram_version_commands.py,sha256=WQyx1-B9yJ8V9WrFyBpOvULJ-jq12GoZZDDoRbM7eyw,1553
43
43
  xp/cli/commands/term/__init__.py,sha256=1NNST_8YJfj5LCujQISwQflK6LyEn7mDmZpMpvI9d-o,116
44
44
  xp/cli/commands/term/term.py,sha256=gjvsv2OE-F_KNWQrWi04fXQ5cGo0l8P-Ortbb5KTA-A,309
45
- xp/cli/commands/term/term_commands.py,sha256=kElFFpbdUthk23lf6bfGIpzTbBEXE1Y08pU_yAyzmOg,676
45
+ xp/cli/commands/term/term_commands.py,sha256=CwqnLPEi7LuC7bCo7kIGKMZoVICY0nu42k8C554A1TA,1206
46
46
  xp/cli/main.py,sha256=ap5jU0DrSnrCKDKqGXcz9N-sngZodyyN-5ReWE8Fh1s,1817
47
47
  xp/cli/utils/__init__.py,sha256=gTGIj60Uai0iE7sr9_TtEpl04fD7krtTzbbigXUsUVU,46
48
48
  xp/cli/utils/click_tree.py,sha256=ilmM2IMa_c-TqUMsv2alrZXuS0BNhvVlrBlSfyN8lzM,1670
@@ -104,8 +104,9 @@ xp/models/telegram/system_telegram.py,sha256=9FNQ4Mf47mRK7wGrTg2GzziVsrEWCE5ZkZp
104
104
  xp/models/telegram/telegram.py,sha256=IJUxHX6ftLcET9C1pjvLhUO5Db5JO6W7rUItzdEW30I,842
105
105
  xp/models/telegram/telegram_type.py,sha256=GhqKP63oNMyh2tIvCPcsC5RFp4s4JjhmEqCLCC-8XMk,423
106
106
  xp/models/telegram/timeparam_type.py,sha256=Ar8xvSfPmOAgR2g2Je0FgvP01SL7bPvZn5_HrVDpmJM,1137
107
- xp/models/term/__init__.py,sha256=c1AMtVitYk80o9K_zWjYNzZYpFDASqM8S1Djm1PD4Qo,192
107
+ xp/models/term/__init__.py,sha256=aFvzGZHr_dI6USb8MJuYLSLMvxi_ZWMVtokHDt8428s,263
108
108
  xp/models/term/connection_state.py,sha256=floDRMeMcfgMrYIVsyoVHBXHtxd3hqm-xOdr3oXtaHY,1793
109
+ xp/models/term/module_state.py,sha256=p9pXCVRDIdMEbAf5jhAHoZ2dnqhpu2rli_ThA1WUCj0,864
109
110
  xp/models/term/protocol_keys_config.py,sha256=CTujcfI2_NOeltjvHy_cnsHzxLSVsGFXieMZlD-zj0Q,1204
110
111
  xp/models/term/status_message.py,sha256=DOmzL0dbig5mP1UEoXdgzGT4UG2RyAXa_yRVo5c4x8w,394
111
112
  xp/models/term/telegram_display.py,sha256=RJDrJh4tqRmT0i1-tfYy17paEmVb3HY3DMuFPsEhZyc,533
@@ -167,32 +168,36 @@ xp/services/server/server_service.py,sha256=JPRFMto2l956dW7vfSclQugu2vdF0fssxxUO
167
168
  xp/services/server/xp130_server_service.py,sha256=YnvetDp72-QzkyDGB4qfZZIwFs03HuibUOz2zb9XR0c,2191
168
169
  xp/services/server/xp20_server_service.py,sha256=1wJ7A-bRkN9O5Spu3q3LNDW31mNtNF2eNMQ5E6O2ltA,2928
169
170
  xp/services/server/xp230_server_service.py,sha256=k9ftCY5tjLFP31mKVCspq283RVaPkGx-Yq61Urk8JLs,1815
170
- xp/services/server/xp24_server_service.py,sha256=S4kDZHf6SsFTwIzk1PwkWntFHtmOuVcz6UclkRdTGsc,8670
171
- xp/services/server/xp33_server_service.py,sha256=X5BJr7RYueHAPNrfW-HnqV7ZN-OAouKxH1qMdDADqhk,19745
171
+ xp/services/server/xp24_server_service.py,sha256=a-RZzmieoPd8-SrNX1qwdqzsizjjWwz6TAb0f4Ehz2k,8598
172
+ xp/services/server/xp33_server_service.py,sha256=vvgQxnYXfHlzh3uFYxexHrrOr4l1qPI85n6ig17iWA0,19673
172
173
  xp/services/telegram/__init__.py,sha256=kv0JgMg13Fp18WgGQpalNRAWwiWbrz18X4kZAP9xpSQ,48
173
174
  xp/services/telegram/telegram_blink_service.py,sha256=Xctc9mCSZiiW1YTh8cA-4jlc8fTioS5OxT6ymhSqiYI,4487
174
175
  xp/services/telegram/telegram_checksum_service.py,sha256=rp_C5PlraOOIyqZDp9XjBBNZLUeBLdQNNHVpN6D-1v8,4729
175
176
  xp/services/telegram/telegram_datapoint_service.py,sha256=iZ-zp_EM_1ouyeTbd2erhIY2x-98nEHveWWN_a9NfFU,2750
176
177
  xp/services/telegram/telegram_discover_service.py,sha256=oTpiq-yzP_UmC0xVOMMFeHO-rIlK1pF3aG-Kq4SeiBI,9025
177
178
  xp/services/telegram/telegram_link_number_service.py,sha256=1_c-_QCRPTHYn3BmMElrBJqGG6vnoIst8CB-N42hazk,6862
178
- xp/services/telegram/telegram_output_service.py,sha256=UaUv_14fR8o5K2PxQBXrCzx-Hohnk-gzbev_oLw_Clc,10799
179
+ xp/services/telegram/telegram_output_service.py,sha256=LK9xHAc3eNeXz82Xs9Nm8WfrHNr7-u2vboDiB7mIFPQ,11950
179
180
  xp/services/telegram/telegram_service.py,sha256=XrP1CPi0ckxoKBaNwLA6lo-TogWxXgmXDOsU4Xl8BlY,13237
180
181
  xp/services/telegram/telegram_version_service.py,sha256=M5HdOTsLdcwo122FP-jW6R740ktLrtKf2TiMDVz23h8,10528
181
- xp/services/term/__init__.py,sha256=rWZ9hypFYDwrUCW_36cRZ4RalaPByyHQCEnOxgHrbuk,151
182
- xp/services/term/protocol_monitor_service.py,sha256=q24gnRB4SQ1BA9ZeSU7ylYQ6yQ4j12HlSPgQowsAOv8,9950
182
+ xp/services/term/__init__.py,sha256=BIeOK042bMR-0l6MA80wdW5VuHlpWOXtRER9IG5ilQA,245
183
+ xp/services/term/protocol_monitor_service.py,sha256=PhEzLNzWf1XieQw94ua-hJu9ccwrAzhdxSZGe4kHghs,9945
184
+ xp/services/term/state_monitor_service.py,sha256=gLvNdFMQ8TKX_fAu27TaIyCiEmf-hepkt7zYgFVTvng,12337
183
185
  xp/term/__init__.py,sha256=Xg2DhBeI3xQJLfc7_BPWI1por-rUXemyer5OtOt9Cus,51
184
- xp/term/protocol.py,sha256=rJmhvXaLBC9tVg7oYew6yAuCO0I9n-N7uIyOLm25Wnc,3330
186
+ xp/term/protocol.py,sha256=oLJAExvIaOSpy75A5TaYB_7R9skTTtNtPx8hiJLdy_U,3425
185
187
  xp/term/protocol.tcss,sha256=r_KfxrbpycGHLVXqZc6INBBcUJME0hLrAZkF1oqnab4,2126
188
+ xp/term/state.py,sha256=sR7I6t4gJSkO2YS3TwonAnGPR_f43coCk4xKdWETus0,3233
189
+ xp/term/state.tcss,sha256=Njp7fc16cCunLq7hi5RvXjPi4jSCGi5aPDnusb9dq1Y,1401
186
190
  xp/term/widgets/__init__.py,sha256=ftWmN_fmjxy2E8Qfm-YSRmzKfgL0KTBCTpgvYWCPbUY,274
187
- xp/term/widgets/help_menu.py,sha256=7viKIfyPJr-uz55Y1kgo6h4iHhntxwKs_qmC5siRYNM,1821
191
+ xp/term/widgets/help_menu.py,sha256=w2NjwiC_s16St0rigZ9ef9S0V9Y4v0J5eCVCHAdRKF4,1789
192
+ xp/term/widgets/modules_list.py,sha256=_B46p8lCOH4jr1kVqphS7Rixr6bobg6c_pD4RCj_NRE,7321
188
193
  xp/term/widgets/protocol_log.py,sha256=CJUpckWj7GC1kVqixDadteyGnI4aHyzd4kkH-pSbzno,2600
189
- xp/term/widgets/status_footer.py,sha256=8O3W8clgSbkX21_b9iJ_3XKgDjYTG1Bi-L_PaiEPI7U,3104
194
+ xp/term/widgets/status_footer.py,sha256=bxrcqKzJ9V0aPSn_WwraVpJz7NxBUh3yIjA3fwv5nVA,3256
190
195
  xp/utils/__init__.py,sha256=_avMF_UOkfR3tNeDIPqQ5odmbq5raKkaq1rZ9Cn1CJs,332
191
196
  xp/utils/checksum.py,sha256=HDpiQxmdIedbCbZ4o_Box0i_Zig417BtCV_46ZyhiTk,1711
192
- xp/utils/dependencies.py,sha256=Rw3NsvPr7P7xtm2LzLLBP7Q8W07A2fmp4HOKXDH9wS4,23457
197
+ xp/utils/dependencies.py,sha256=UmVAEpGqEG6Li0h6u6I-mFgBTu6dsTeWjWUnfaGFofQ,24227
193
198
  xp/utils/event_helper.py,sha256=W-A_xmoXlpWZBbJH6qdaN50o3-XrwFsDgvAGMJDiAgo,1001
194
199
  xp/utils/logging.py,sha256=rZDXwlBrYK8A6MPq5StsMNpgsRowzJXM6fvROPwJdGM,3750
195
200
  xp/utils/serialization.py,sha256=RWHHk86feaB4ZP7rjE4qOWK0900yg2joUBDkP76gfOY,4618
196
201
  xp/utils/state_machine.py,sha256=Oe2sLwCh9z_vr1tF6X0ZRGTeuckRQAGzmef7xc9CNdc,2413
197
202
  xp/utils/time_utils.py,sha256=dEyViDlAG9GWU-J3D_YVa-sGma6yiyyMTgN4h2x3PY4,3781
198
- conson_xp-1.26.0.dist-info/RECORD,,
203
+ conson_xp-1.28.0.dist-info/RECORD,,
xp/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  conson-xp package.
4
4
  """
5
5
 
6
- __version__ = "1.26.0"
6
+ __version__ = "1.28.0"
7
7
  __manufacturer__ = "salchichon"
8
8
  __model__ = "xp.cli"
9
9
  __serial__ = "2025.09.23.000"
@@ -25,3 +25,24 @@ def protocol_monitor(ctx: Context) -> None:
25
25
 
26
26
  # Resolve ProtocolMonitorApp from container and run
27
27
  ctx.obj.get("container").get_container().resolve(ProtocolMonitorApp).run()
28
+
29
+
30
+ @term.command("state")
31
+ @click.pass_context
32
+ def state_monitor(ctx: Context) -> None:
33
+ r"""Start TUI for module state monitoring.
34
+
35
+ Displays module states from Conson configuration with real-time
36
+ updates in an interactive terminal interface.
37
+
38
+ Args:
39
+ ctx: Click context object.
40
+
41
+ Examples:
42
+ \b
43
+ xp term state
44
+ """
45
+ from xp.term.state import StateMonitorApp
46
+
47
+ # Resolve StateMonitorApp from container and run
48
+ ctx.obj.get("container").get_container().resolve(StateMonitorApp).run()
@@ -1,11 +1,13 @@
1
1
  """Terminal UI models."""
2
2
 
3
+ from xp.models.term.module_state import ModuleState
3
4
  from xp.models.term.protocol_keys_config import (
4
5
  ProtocolKeyConfig,
5
6
  ProtocolKeysConfig,
6
7
  )
7
8
 
8
9
  __all__ = [
10
+ "ModuleState",
9
11
  "ProtocolKeyConfig",
10
12
  "ProtocolKeysConfig",
11
13
  ]
@@ -0,0 +1,28 @@
1
+ """Module state data model."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class ModuleState:
10
+ """State of a Conson module for TUI display.
11
+
12
+ Attributes:
13
+ name: Module name/identifier (e.g., A01, A02).
14
+ serial_number: Module serial number.
15
+ module_type: Module type designation (e.g., XP130, XP230, XP24).
16
+ outputs: Output states as space-separated binary values. Empty string for modules without outputs.
17
+ auto_report: Auto-report enabled status (Y/N).
18
+ error_status: Module status ("OK" or error code like "E10").
19
+ last_update: Last communication timestamp. None if never updated.
20
+ """
21
+
22
+ name: str
23
+ serial_number: str
24
+ module_type: str
25
+ outputs: str
26
+ auto_report: bool
27
+ error_status: str
28
+ last_update: Optional[datetime]
@@ -161,10 +161,7 @@ class XP24ServerService(BaseServerService):
161
161
 
162
162
  data_value = handler()
163
163
  data_part = (
164
- f"R{self.serial_number}"
165
- f"F02D{datapoint_type.value}"
166
- f"{self.module_type_code.value:02}"
167
- f"{data_value}"
164
+ f"R{self.serial_number}" f"F02D{datapoint_type.value}" f"{data_value}"
168
165
  )
169
166
  telegram = self._build_response_telegram(data_part)
170
167
 
@@ -226,10 +226,7 @@ class XP33ServerService(BaseServerService):
226
226
 
227
227
  data_value = handler()
228
228
  data_part = (
229
- f"R{self.serial_number}"
230
- f"F02D{datapoint_type.value}"
231
- f"{self.module_type_code.value:02}"
232
- f"{data_value}"
229
+ f"R{self.serial_number}" f"F02D{datapoint_type.value}" f"{data_value}"
233
230
  )
234
231
  telegram = self._build_response_telegram(data_part)
235
232
 
@@ -320,3 +320,36 @@ class TelegramOutputService:
320
320
  f"Timestamp: {telegram.timestamp}\n"
321
321
  f"Checksum: {telegram.checksum}{checksum_status}"
322
322
  )
323
+
324
+ @staticmethod
325
+ def format_output_state(data_value: str) -> str:
326
+ """Format module output state data value for display.
327
+
328
+ Algorithm:
329
+ 1. Remove 'x' characters
330
+ 2. Format to 4 chars with space padding on the right
331
+ 3. Invert order
332
+ 4. Add spaces between characters
333
+
334
+ Args:
335
+ data_value: Raw data value from module output state datapoint (e.g., "xxxx0101", "xx1110").
336
+
337
+ Returns:
338
+ Formatted output string with spaces (e.g., "1 0 1 0", "0 1 1 1").
339
+
340
+ Examples:
341
+ >>> TelegramOutputService.format_output_state("xxxx0101")
342
+ "1 0 1 0"
343
+ >>> TelegramOutputService.format_output_state("xx1110")
344
+ "0 1 1 1"
345
+ >>> TelegramOutputService.format_output_state("xxxx01")
346
+ " 1 0"
347
+ """
348
+ # Remove 'x' characters
349
+ cleaned = data_value.replace("x", "").replace("X", "")
350
+ # Format to 4 chars with space padding on the right
351
+ padded = cleaned.ljust(4)[:4]
352
+ # Invert order
353
+ inverted = padded[::-1]
354
+ # Add spaces between characters
355
+ return " ".join(inverted)
@@ -1,5 +1,6 @@
1
1
  """Terminal interface services."""
2
2
 
3
3
  from xp.services.term.protocol_monitor_service import ProtocolMonitorService
4
+ from xp.services.term.state_monitor_service import StateMonitorService
4
5
 
5
- __all__ = ["ProtocolMonitorService"]
6
+ __all__ = ["ProtocolMonitorService", "StateMonitorService"]
@@ -93,7 +93,7 @@ class ProtocolMonitorService:
93
93
  """
94
94
  return f"{self._conbus_protocol.cli_config.ip}:{self._conbus_protocol.cli_config.port}"
95
95
 
96
- def _connect(self) -> None:
96
+ def connect(self) -> None:
97
97
  """Initiate connection to server."""
98
98
  if not self._state_machine.can_transition("connect"):
99
99
  self.logger.warning(
@@ -108,7 +108,7 @@ class ProtocolMonitorService:
108
108
 
109
109
  self._conbus_protocol.connect()
110
110
 
111
- def _disconnect(self) -> None:
111
+ def disconnect(self) -> None:
112
112
  """Disconnect from server."""
113
113
  if not self._state_machine.can_transition("disconnect"):
114
114
  self.logger.warning(
@@ -140,9 +140,9 @@ class ProtocolMonitorService:
140
140
  ConnectionState.CONNECTED,
141
141
  ConnectionState.CONNECTING,
142
142
  ):
143
- self._disconnect()
143
+ self.disconnect()
144
144
  else:
145
- self._connect()
145
+ self.connect()
146
146
 
147
147
  def _send_telegram(self, name: str, telegram: str) -> None:
148
148
  """Send a raw telegram.
@@ -231,7 +231,7 @@ class ProtocolMonitorService:
231
231
  """Clean up service resources."""
232
232
  self._disconnect_signals()
233
233
  if self._conbus_protocol.transport:
234
- self._disconnect()
234
+ self.disconnect()
235
235
 
236
236
  def get_keys(self) -> ItemsView[str, ProtocolKeyConfig]:
237
237
  """Get protocol key mappings.
@@ -0,0 +1,313 @@
1
+ """State Monitor Service for terminal interface."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import Dict, List
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.system_function import SystemFunction
13
+ from xp.models.telegram.telegram_type import TelegramType
14
+ from xp.models.term.connection_state import ConnectionState
15
+ from xp.models.term.module_state import ModuleState
16
+ from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
17
+ from xp.services.telegram.telegram_output_service import TelegramOutputService
18
+ from xp.services.telegram.telegram_service import TelegramService
19
+
20
+
21
+ class StateMonitorService:
22
+ """Service for module state monitoring in terminal interface.
23
+
24
+ Wraps ConbusEventProtocol and ConsonModuleListConfig to provide
25
+ high-level module state tracking for the TUI.
26
+
27
+ Attributes:
28
+ on_connection_state_changed: Signal emitted when connection state changes.
29
+ on_module_list_updated: Signal emitted when module list refreshed from config.
30
+ on_module_state_changed: Signal emitted when individual module state updates.
31
+ on_module_error: Signal emitted when module error occurs.
32
+ on_status_message: Signal emitted for status messages.
33
+ connection_state: Property returning current connection state.
34
+ server_info: Property returning server connection info (IP:port).
35
+ module_states: Property returning list of all module states.
36
+ """
37
+
38
+ on_connection_state_changed: Signal = Signal(ConnectionState)
39
+ on_module_list_updated: Signal = Signal(list)
40
+ on_module_state_changed: Signal = Signal(ModuleState)
41
+ on_module_error: Signal = Signal(str, str)
42
+ on_status_message: Signal = Signal(str)
43
+
44
+ def __init__(
45
+ self,
46
+ conbus_protocol: ConbusEventProtocol,
47
+ conson_config: ConsonModuleListConfig,
48
+ telegram_service: TelegramService,
49
+ ) -> None:
50
+ """Initialize the State Monitor service.
51
+
52
+ Args:
53
+ conbus_protocol: ConbusEventProtocol instance.
54
+ conson_config: ConsonModuleListConfig for module configuration.
55
+ telegram_service: TelegramService for parsing telegrams.
56
+ """
57
+ self.logger = logging.getLogger(__name__)
58
+ self._conbus_protocol = conbus_protocol
59
+ self._conson_config = conson_config
60
+ self._telegram_service = telegram_service
61
+ self._connection_state = ConnectionState.DISCONNECTED
62
+ self._state_machine = ConnectionState.create_state_machine()
63
+ self._module_states: Dict[str, ModuleState] = {}
64
+
65
+ # Connect to protocol signals
66
+ self._connect_signals()
67
+
68
+ # Initialize module states from config
69
+ self._initialize_module_states()
70
+
71
+ def _initialize_module_states(self) -> None:
72
+ """Initialize module states from ConsonModuleListConfig."""
73
+ for module_config in self._conson_config.root:
74
+ # Map auto_report_status: PP → True, others → False
75
+ auto_report = module_config.auto_report_status == "PP"
76
+
77
+ module_state = ModuleState(
78
+ name=module_config.name,
79
+ serial_number=module_config.serial_number,
80
+ module_type=module_config.module_type,
81
+ outputs="", # Empty initially
82
+ auto_report=auto_report,
83
+ error_status="OK",
84
+ last_update=None, # Not updated yet
85
+ )
86
+ self._module_states[module_config.serial_number] = module_state
87
+
88
+ def _connect_signals(self) -> None:
89
+ """Connect to protocol signals."""
90
+ self._conbus_protocol.on_connection_made.connect(self._on_connection_made)
91
+ self._conbus_protocol.on_connection_failed.connect(self._on_connection_failed)
92
+ self._conbus_protocol.on_telegram_received.connect(self._on_telegram_received)
93
+ self._conbus_protocol.on_timeout.connect(self._on_timeout)
94
+ self._conbus_protocol.on_failed.connect(self._on_failed)
95
+
96
+ def _disconnect_signals(self) -> None:
97
+ """Disconnect from protocol signals."""
98
+ self._conbus_protocol.on_connection_made.disconnect(self._on_connection_made)
99
+ self._conbus_protocol.on_connection_failed.disconnect(
100
+ self._on_connection_failed
101
+ )
102
+ self._conbus_protocol.on_telegram_received.disconnect(
103
+ self._on_telegram_received
104
+ )
105
+ self._conbus_protocol.on_timeout.disconnect(self._on_timeout)
106
+ self._conbus_protocol.on_failed.disconnect(self._on_failed)
107
+
108
+ @property
109
+ def connection_state(self) -> ConnectionState:
110
+ """Get current connection state.
111
+
112
+ Returns:
113
+ Current connection state.
114
+ """
115
+ return self._connection_state
116
+
117
+ @property
118
+ def server_info(self) -> str:
119
+ """Get server connection info (IP:port).
120
+
121
+ Returns:
122
+ Server address in format "IP:port".
123
+ """
124
+ return f"{self._conbus_protocol.cli_config.ip}:{self._conbus_protocol.cli_config.port}"
125
+
126
+ @property
127
+ def module_states(self) -> List[ModuleState]:
128
+ """Get all module states.
129
+
130
+ Returns:
131
+ List of all module states.
132
+ """
133
+ return list(self._module_states.values())
134
+
135
+ def connect(self) -> None:
136
+ """Initiate connection to server."""
137
+ if not self._state_machine.can_transition("connect"):
138
+ self.logger.warning(
139
+ f"Cannot connect: current state is {self._connection_state.value}"
140
+ )
141
+ return
142
+
143
+ if self._state_machine.transition("connecting", ConnectionState.CONNECTING):
144
+ self._connection_state = ConnectionState.CONNECTING
145
+ self.on_connection_state_changed.emit(self._connection_state)
146
+ self.on_status_message.emit(f"Connecting to {self.server_info}...")
147
+
148
+ self._conbus_protocol.connect()
149
+
150
+ def disconnect(self) -> None:
151
+ """Disconnect from server."""
152
+ if not self._state_machine.can_transition("disconnect"):
153
+ self.logger.warning(
154
+ f"Cannot disconnect: current state is {self._connection_state.value}"
155
+ )
156
+ return
157
+
158
+ if self._state_machine.transition(
159
+ "disconnecting", ConnectionState.DISCONNECTING
160
+ ):
161
+ self._connection_state = ConnectionState.DISCONNECTING
162
+ self.on_connection_state_changed.emit(self._connection_state)
163
+ self.on_status_message.emit("Disconnecting...")
164
+
165
+ self._conbus_protocol.disconnect()
166
+
167
+ if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
168
+ self._connection_state = ConnectionState.DISCONNECTED
169
+ self.on_connection_state_changed.emit(self._connection_state)
170
+ self.on_status_message.emit("Disconnected")
171
+
172
+ def toggle_connection(self) -> None:
173
+ """Toggle connection state between connected and disconnected.
174
+
175
+ Disconnects if currently connected or connecting.
176
+ Connects if currently disconnected or failed.
177
+ """
178
+ if self._connection_state in (
179
+ ConnectionState.CONNECTED,
180
+ ConnectionState.CONNECTING,
181
+ ):
182
+ self.disconnect()
183
+ else:
184
+ self.connect()
185
+
186
+ def refresh_all(self) -> None:
187
+ """Refresh all module states.
188
+
189
+ Queries module_output_state datapoint for eligible modules (XP24, XP33LR, XP33LED).
190
+ Updates outputs column and last_update timestamp for each queried module.
191
+ """
192
+ self.on_status_message.emit("Refreshing module states...")
193
+
194
+ # Eligible module types that support output state queries
195
+ eligible_types = {"XP24", "XP33LR", "XP33LED"}
196
+
197
+ # Filter and query eligible modules
198
+ for module_state in self._module_states.values():
199
+ if module_state.module_type in eligible_types:
200
+ self._query_module_output_state(module_state.serial_number)
201
+ self.logger.debug(
202
+ f"Querying output state for {module_state.name} ({module_state.module_type})"
203
+ )
204
+
205
+ def _query_module_output_state(self, serial_number: str) -> None:
206
+ """Query module output state datapoint.
207
+
208
+ Args:
209
+ serial_number: Module serial number to query.
210
+ """
211
+ self._conbus_protocol.send_telegram(
212
+ telegram_type=TelegramType.SYSTEM,
213
+ serial_number=serial_number,
214
+ system_function=SystemFunction.READ_DATAPOINT,
215
+ data_value=str(DataPointType.MODULE_OUTPUT_STATE.value),
216
+ )
217
+
218
+ def _on_connection_made(self) -> None:
219
+ """Handle connection made event."""
220
+ if self._state_machine.transition("connected", ConnectionState.CONNECTED):
221
+ self._connection_state = ConnectionState.CONNECTED
222
+ self.on_connection_state_changed.emit(self._connection_state)
223
+ self.on_status_message.emit(f"Connected to {self.server_info}")
224
+
225
+ # Emit initial module list
226
+ self.on_module_list_updated.emit(self.module_states)
227
+
228
+ def _on_connection_failed(self, failure: Exception) -> None:
229
+ """Handle connection failed event.
230
+
231
+ Args:
232
+ failure: Exception that caused the failure.
233
+ """
234
+ if self._state_machine.transition("failed", ConnectionState.FAILED):
235
+ self._connection_state = ConnectionState.FAILED
236
+ self.on_connection_state_changed.emit(self._connection_state)
237
+ self.on_status_message.emit(f"Connection failed: {failure}")
238
+
239
+ def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
240
+ """Handle telegram received event.
241
+
242
+ Parse output states from telegram and update module state.
243
+
244
+ Args:
245
+ event: Telegram received event.
246
+ """
247
+ # Only process reply telegrams
248
+ if event.telegram_type != TelegramType.REPLY:
249
+ return
250
+
251
+ serial_number = event.serial_number
252
+ if not serial_number or serial_number not in self._module_states:
253
+ return
254
+
255
+ # Parse the reply telegram
256
+ reply_telegram = self._telegram_service.parse_reply_telegram(event.frame)
257
+ if not reply_telegram:
258
+ return
259
+
260
+ # Check if this is a module output state response
261
+ if (
262
+ reply_telegram.system_function == SystemFunction.READ_DATAPOINT
263
+ and reply_telegram.datapoint_type == DataPointType.MODULE_OUTPUT_STATE
264
+ ):
265
+ module_state = self._module_states[serial_number]
266
+
267
+ # Parse output state from data_value using TelegramOutputService
268
+ outputs = TelegramOutputService.format_output_state(
269
+ reply_telegram.data_value
270
+ )
271
+ module_state.outputs = outputs
272
+ module_state.last_update = datetime.now()
273
+
274
+ self.on_module_state_changed.emit(module_state)
275
+ self.logger.debug(f"Updated outputs for {module_state.name}: {outputs}")
276
+
277
+ def _on_timeout(self) -> None:
278
+ """Handle timeout event."""
279
+ self.on_status_message.emit("Connection timeout")
280
+
281
+ def _on_failed(self, failure: Exception) -> None:
282
+ """Handle protocol failure event.
283
+
284
+ Args:
285
+ failure: Exception that caused the failure.
286
+ """
287
+ if self._state_machine.transition("failed", ConnectionState.FAILED):
288
+ self._connection_state = ConnectionState.FAILED
289
+ self.on_connection_state_changed.emit(self._connection_state)
290
+ self.on_status_message.emit(f"Protocol error: {failure}")
291
+
292
+ def cleanup(self) -> None:
293
+ """Clean up service resources."""
294
+ self._disconnect_signals()
295
+ self.logger.debug("StateMonitorService cleaned up")
296
+
297
+ def __enter__(self) -> "StateMonitorService":
298
+ """Context manager entry.
299
+
300
+ Returns:
301
+ Self for context manager.
302
+ """
303
+ return self
304
+
305
+ def __exit__(self, _exc_type: object, _exc_val: object, _exc_tb: object) -> None:
306
+ """Context manager exit.
307
+
308
+ Args:
309
+ _exc_type: Exception type.
310
+ _exc_val: Exception value.
311
+ _exc_tb: Exception traceback.
312
+ """
313
+ self.cleanup()
xp/term/protocol.py CHANGED
@@ -6,6 +6,7 @@ from typing import Any, Optional
6
6
  from textual.app import App, ComposeResult
7
7
  from textual.containers import Horizontal
8
8
 
9
+ from xp.services.term import ProtocolMonitorService
9
10
  from xp.term.widgets.help_menu import HelpMenuWidget
10
11
  from xp.term.widgets.protocol_log import ProtocolLogWidget
11
12
  from xp.term.widgets.status_footer import StatusFooterWidget
@@ -36,14 +37,14 @@ class ProtocolMonitorApp(App[None]):
36
37
  ("0-9,a-q", "protocol_keys", "Keys"),
37
38
  ]
38
39
 
39
- def __init__(self, protocol_service: Any) -> None:
40
+ def __init__(self, protocol_service: ProtocolMonitorService) -> None:
40
41
  """Initialize the Protocol Monitor app.
41
42
 
42
43
  Args:
43
44
  protocol_service: ProtocolMonitorService for protocol operations.
44
45
  """
45
46
  super().__init__()
46
- self.protocol_service = protocol_service
47
+ self.protocol_service: ProtocolMonitorService = protocol_service
47
48
  self.protocol_widget: Optional[ProtocolLogWidget] = None
48
49
  self.help_menu: Optional[HelpMenuWidget] = None
49
50
  self.footer_widget: Optional[StatusFooterWidget] = None
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.
@@ -0,0 +1,217 @@
1
+ """Modules List Widget for displaying module state table."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any, List, Optional
5
+
6
+ from textual.app import ComposeResult
7
+ from textual.widgets import DataTable, Static
8
+
9
+ from xp.models.term.module_state import ModuleState
10
+ from xp.services.term.state_monitor_service import StateMonitorService
11
+
12
+
13
+ class ModulesListWidget(Static):
14
+ """Widget displaying module states in a data table.
15
+
16
+ Shows module information with real-time updates from StateMonitorService.
17
+ Table displays: name, serial_number, module_type, outputs, report, status, last_update.
18
+
19
+ Attributes:
20
+ service: StateMonitorService for module state updates.
21
+ table: DataTable widget displaying module information.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ service: Optional[StateMonitorService] = None,
27
+ *args: Any,
28
+ **kwargs: Any,
29
+ ) -> None:
30
+ """Initialize the Modules List widget.
31
+
32
+ Args:
33
+ service: Optional StateMonitorService for signal subscriptions.
34
+ args: Additional positional arguments for Static.
35
+ kwargs: Additional keyword arguments for Static.
36
+ """
37
+ super().__init__(*args, **kwargs)
38
+ self.service = service
39
+ self.table: Optional[DataTable] = None
40
+ self._row_keys: dict[str, Any] = {} # Map serial_number to row key
41
+
42
+ def compose(self) -> ComposeResult:
43
+ """Compose the widget layout.
44
+
45
+ Yields:
46
+ DataTable widget.
47
+ """
48
+ self.table = DataTable(id="modules-table", cursor_type="row")
49
+ yield self.table
50
+
51
+ def on_mount(self) -> None:
52
+ """Initialize table and subscribe to service signals when widget mounts."""
53
+ # Set border title
54
+ self.border_title = "Modules"
55
+
56
+ if self.table:
57
+ # Set table to full width
58
+ self.table.styles.width = "100%"
59
+
60
+ # Setup table columns
61
+ self.table.add_column("name", key="name")
62
+ self.table.add_column("serial number", key="serial_number")
63
+ self.table.add_column("module type", key="module_type")
64
+ self.table.add_column("outputs", key="outputs")
65
+ self.table.add_column("report", key="report")
66
+ self.table.add_column("status", key="status")
67
+ self.table.add_column("last update", key="last_update")
68
+
69
+ if self.service:
70
+ self.service.on_module_list_updated.connect(self.update_module_list)
71
+ self.service.on_module_state_changed.connect(self.update_module_state)
72
+
73
+ def on_unmount(self) -> None:
74
+ """Unsubscribe from service signals when widget unmounts."""
75
+ if self.service:
76
+ self.service.on_module_list_updated.disconnect(self.update_module_list)
77
+ self.service.on_module_state_changed.disconnect(self.update_module_state)
78
+
79
+ def update_module_list(self, module_states: List[ModuleState]) -> None:
80
+ """Update entire module list from service.
81
+
82
+ Clears existing table and repopulates with all modules.
83
+
84
+ Args:
85
+ module_states: List of all module states.
86
+ """
87
+ if not self.table:
88
+ return
89
+
90
+ # Clear existing rows
91
+ self.table.clear()
92
+ self._row_keys.clear()
93
+
94
+ # Add all modules
95
+ for module_state in module_states:
96
+ self._add_module_row(module_state)
97
+
98
+ def update_module_state(self, module_state: ModuleState) -> None:
99
+ """Update individual module state in table.
100
+
101
+ Updates existing row if module exists, otherwise adds new row.
102
+
103
+ Args:
104
+ module_state: Updated module state.
105
+ """
106
+ if not self.table:
107
+ return
108
+
109
+ serial_number = module_state.serial_number
110
+
111
+ if serial_number in self._row_keys:
112
+ # Update existing row
113
+ row_key = self._row_keys[serial_number]
114
+ self.table.update_cell(
115
+ row_key, "outputs", self._format_outputs(module_state.outputs)
116
+ )
117
+ self.table.update_cell(
118
+ row_key, "report", self._format_report(module_state.auto_report)
119
+ )
120
+ self.table.update_cell(row_key, "status", module_state.error_status)
121
+ self.table.update_cell(
122
+ row_key,
123
+ "last_update",
124
+ self._format_last_update(module_state.last_update),
125
+ )
126
+ else:
127
+ # Add new row
128
+ self._add_module_row(module_state)
129
+
130
+ def _add_module_row(self, module_state: ModuleState) -> None:
131
+ """Add a module row to the table.
132
+
133
+ Args:
134
+ module_state: Module state to add.
135
+ """
136
+ if not self.table:
137
+ return
138
+
139
+ row_key = self.table.add_row(
140
+ module_state.name,
141
+ module_state.serial_number,
142
+ module_state.module_type,
143
+ self._format_outputs(module_state.outputs),
144
+ self._format_report(module_state.auto_report),
145
+ module_state.error_status,
146
+ self._format_last_update(module_state.last_update),
147
+ )
148
+ self._row_keys[module_state.serial_number] = row_key
149
+
150
+ def _format_outputs(self, outputs: str) -> str:
151
+ """Format outputs for display.
152
+
153
+ Args:
154
+ outputs: Raw output string.
155
+
156
+ Returns:
157
+ Formatted output string (empty string for modules without outputs).
158
+ """
159
+ return outputs
160
+
161
+ def _format_report(self, auto_report: bool) -> str:
162
+ """Format auto-report status for display.
163
+
164
+ Args:
165
+ auto_report: Auto-report boolean value.
166
+
167
+ Returns:
168
+ "Y" if True, "N" if False.
169
+ """
170
+ return "Y" if auto_report else "N"
171
+
172
+ def _format_last_update(self, last_update: Optional[datetime]) -> str:
173
+ """Format last update timestamp for display.
174
+
175
+ Shows elapsed time in HH:MM:SS format or "--:--:--" if never updated.
176
+
177
+ Args:
178
+ last_update: Last update timestamp or None.
179
+
180
+ Returns:
181
+ Formatted time string.
182
+ """
183
+ if last_update is None:
184
+ return "--:--:--"
185
+
186
+ # Calculate elapsed time
187
+ elapsed = datetime.now() - last_update
188
+ total_seconds = int(elapsed.total_seconds())
189
+
190
+ hours = total_seconds // 3600
191
+ minutes = (total_seconds % 3600) // 60
192
+ seconds = total_seconds % 60
193
+
194
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
195
+
196
+ def refresh_last_update_times(self) -> None:
197
+ """Refresh only the last_update column for all modules.
198
+
199
+ Updates the elapsed time display without querying the service.
200
+ """
201
+ if not self.table or not self.service:
202
+ return
203
+
204
+ # Update last_update column for each module
205
+ for serial_number, row_key in self._row_keys.items():
206
+ # Get the module state from service
207
+ module_states = self.service.module_states
208
+ module_state = next(
209
+ (m for m in module_states if m.serial_number == serial_number), None
210
+ )
211
+ if module_state:
212
+ # Update only the last_update cell
213
+ self.table.update_cell(
214
+ row_key,
215
+ "last_update",
216
+ self._format_last_update(module_state.last_update),
217
+ )
@@ -1,6 +1,6 @@
1
1
  """Status Footer Widget for displaying app footer with connection status."""
2
2
 
3
- from typing import Any, Optional
3
+ from typing import Any, Optional, Union
4
4
 
5
5
  from textual.app import ComposeResult
6
6
  from textual.containers import Horizontal
@@ -8,6 +8,7 @@ from textual.widgets import Footer, Static
8
8
 
9
9
  from xp.models.term.connection_state import ConnectionState
10
10
  from xp.services.term.protocol_monitor_service import ProtocolMonitorService
11
+ from xp.services.term.state_monitor_service import StateMonitorService
11
12
 
12
13
 
13
14
  class StatusFooterWidget(Horizontal):
@@ -17,21 +18,21 @@ class StatusFooterWidget(Horizontal):
17
18
  the current connection state. Subscribes directly to service signals.
18
19
 
19
20
  Attributes:
20
- service: ProtocolMonitorService for connection state and status updates.
21
+ service: ProtocolMonitorService or StateMonitorService for connection state and status updates.
21
22
  status_widget: Static widget displaying colored status dot.
22
23
  status_text_widget: Static widget displaying status messages.
23
24
  """
24
25
 
25
26
  def __init__(
26
27
  self,
27
- service: Optional[ProtocolMonitorService] = None,
28
+ service: Optional[Union[ProtocolMonitorService, StateMonitorService]] = None,
28
29
  *args: Any,
29
30
  **kwargs: Any,
30
31
  ) -> None:
31
32
  """Initialize the Status Footer widget.
32
33
 
33
34
  Args:
34
- service: Optional ProtocolMonitorService for signal subscriptions.
35
+ service: Optional ProtocolMonitorService or StateMonitorService for signal subscriptions.
35
36
  args: Additional positional arguments for Horizontal.
36
37
  kwargs: Additional keyword arguments for Horizontal.
37
38
  """
xp/utils/dependencies.py CHANGED
@@ -75,7 +75,9 @@ from xp.services.telegram.telegram_link_number_service import LinkNumberService
75
75
  from xp.services.telegram.telegram_output_service import TelegramOutputService
76
76
  from xp.services.telegram.telegram_service import TelegramService
77
77
  from xp.services.term.protocol_monitor_service import ProtocolMonitorService
78
+ from xp.services.term.state_monitor_service import StateMonitorService
78
79
  from xp.term.protocol import ProtocolMonitorApp
80
+ from xp.term.state import StateMonitorApp
79
81
  from xp.utils.logging import LoggerService
80
82
 
81
83
  asyncioreactor.install()
@@ -217,6 +219,24 @@ class ServiceContainer:
217
219
  scope=punq.Scope.singleton,
218
220
  )
219
221
 
222
+ self.container.register(
223
+ StateMonitorService,
224
+ factory=lambda: StateMonitorService(
225
+ conbus_protocol=self.container.resolve(ConbusEventProtocol),
226
+ conson_config=self.container.resolve(ConsonModuleListConfig),
227
+ telegram_service=self.container.resolve(TelegramService),
228
+ ),
229
+ scope=punq.Scope.singleton,
230
+ )
231
+
232
+ self.container.register(
233
+ StateMonitorApp,
234
+ factory=lambda: StateMonitorApp(
235
+ state_service=self.container.resolve(StateMonitorService)
236
+ ),
237
+ scope=punq.Scope.singleton,
238
+ )
239
+
220
240
  self.container.register(
221
241
  ConbusEventRawService,
222
242
  factory=lambda: ConbusEventRawService(