conson-xp 1.27.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.
- {conson_xp-1.27.0.dist-info → conson_xp-1.28.0.dist-info}/METADATA +2 -1
- {conson_xp-1.27.0.dist-info → conson_xp-1.28.0.dist-info}/RECORD +20 -15
- 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 +28 -0
- 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 +313 -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 +217 -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.28.0.dist-info}/WHEEL +0 -0
- {conson_xp-1.27.0.dist-info → conson_xp-1.28.0.dist-info}/entry_points.txt +0 -0
- {conson_xp-1.27.0.dist-info → conson_xp-1.28.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: conson-xp
|
|
3
|
-
Version: 1.
|
|
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.
|
|
2
|
-
conson_xp-1.
|
|
3
|
-
conson_xp-1.
|
|
4
|
-
conson_xp-1.
|
|
5
|
-
xp/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
171
|
-
xp/services/server/xp33_server_service.py,sha256=
|
|
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=
|
|
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=
|
|
182
|
+
xp/services/term/__init__.py,sha256=BIeOK042bMR-0l6MA80wdW5VuHlpWOXtRER9IG5ilQA,245
|
|
182
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
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=
|
|
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=
|
|
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=
|
|
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.
|
|
203
|
+
conson_xp-1.28.0.dist-info/RECORD,,
|
xp/__init__.py
CHANGED
|
@@ -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()
|
xp/models/term/__init__.py
CHANGED
|
@@ -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)
|
xp/services/term/__init__.py
CHANGED
|
@@ -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"]
|
|
@@ -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/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.
|
|
@@ -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
|
+
)
|
xp/term/widgets/status_footer.py
CHANGED
|
@@ -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(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|