conson-xp 1.24.0__py3-none-any.whl → 1.26.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.24.0.dist-info → conson_xp-1.26.0.dist-info}/METADATA +1 -1
- {conson_xp-1.24.0.dist-info → conson_xp-1.26.0.dist-info}/RECORD +16 -14
- xp/__init__.py +1 -1
- xp/cli/commands/term/term_commands.py +2 -6
- xp/models/term/telegram_display.py +19 -0
- xp/services/protocol/conbus_event_protocol.py +14 -1
- xp/services/term/__init__.py +5 -0
- xp/services/term/protocol_monitor_service.py +265 -0
- xp/term/protocol.py +22 -58
- xp/term/widgets/help_menu.py +8 -7
- xp/term/widgets/protocol_log.py +25 -253
- xp/term/widgets/status_footer.py +28 -4
- xp/utils/dependencies.py +37 -0
- xp/term/protocol.yml +0 -139
- {conson_xp-1.24.0.dist-info → conson_xp-1.26.0.dist-info}/WHEEL +0 -0
- {conson_xp-1.24.0.dist-info → conson_xp-1.26.0.dist-info}/entry_points.txt +0 -0
- {conson_xp-1.24.0.dist-info → conson_xp-1.26.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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.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
|
|
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=kElFFpbdUthk23lf6bfGIpzTbBEXE1Y08pU_yAyzmOg,676
|
|
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
|
|
@@ -108,6 +108,7 @@ xp/models/term/__init__.py,sha256=c1AMtVitYk80o9K_zWjYNzZYpFDASqM8S1Djm1PD4Qo,19
|
|
|
108
108
|
xp/models/term/connection_state.py,sha256=floDRMeMcfgMrYIVsyoVHBXHtxd3hqm-xOdr3oXtaHY,1793
|
|
109
109
|
xp/models/term/protocol_keys_config.py,sha256=CTujcfI2_NOeltjvHy_cnsHzxLSVsGFXieMZlD-zj0Q,1204
|
|
110
110
|
xp/models/term/status_message.py,sha256=DOmzL0dbig5mP1UEoXdgzGT4UG2RyAXa_yRVo5c4x8w,394
|
|
111
|
+
xp/models/term/telegram_display.py,sha256=RJDrJh4tqRmT0i1-tfYy17paEmVb3HY3DMuFPsEhZyc,533
|
|
111
112
|
xp/models/write_config_type.py,sha256=T2RaO52RpzoJ4782uMHE-fX7Ymx3CaIQAEwByydXq1M,881
|
|
112
113
|
xp/services/__init__.py,sha256=W9YZyrkh7vm--ZHhAXNQiOYQs5yhhmUHXP5I0Lf1XBg,782
|
|
113
114
|
xp/services/actiontable/__init__.py,sha256=z6js4EuJ6xKHaseTEhuEvKo1tr9K1XyQiruReJtBiPY,26
|
|
@@ -153,7 +154,7 @@ xp/services/homekit/homekit_service.py,sha256=0lW-hg40ETco3gDBEYkR_sX-UIYsLSKCD4
|
|
|
153
154
|
xp/services/log_file_service.py,sha256=fvPcZQj8XOKUA-4ep5R8n0PelvwvRlTLlVxvIWM5KR4,10506
|
|
154
155
|
xp/services/module_type_service.py,sha256=xWhr1EAZMykL5uNWHWdpa5T8yNruGKH43XRTOS8GwZg,7477
|
|
155
156
|
xp/services/protocol/__init__.py,sha256=qRufBmqRKGzpuzZ5bxBbmwf510TT00Ke8s5HcWGnqRY,818
|
|
156
|
-
xp/services/protocol/conbus_event_protocol.py,sha256=
|
|
157
|
+
xp/services/protocol/conbus_event_protocol.py,sha256=7u_Gv7vM53Ikzo56D5Vua4y8_0vsl6nUjfRJmrzgUhk,14936
|
|
157
158
|
xp/services/protocol/conbus_protocol.py,sha256=JO7yLkD_ohPT0ETjnAIx4CGpZyobf4LdbuozM_43btE,10276
|
|
158
159
|
xp/services/protocol/protocol_factory.py,sha256=PmjN9AtW9sxNo3voqUiNgQA-pTvX1RW4XXFlHKfFr5Q,2470
|
|
159
160
|
xp/services/protocol/telegram_protocol.py,sha256=Ki5DrXsKxiaqLcdP9WWUuhUI7cPu2DfwyZkh-Gv9Lb8,9496
|
|
@@ -177,20 +178,21 @@ xp/services/telegram/telegram_link_number_service.py,sha256=1_c-_QCRPTHYn3BmMElr
|
|
|
177
178
|
xp/services/telegram/telegram_output_service.py,sha256=UaUv_14fR8o5K2PxQBXrCzx-Hohnk-gzbev_oLw_Clc,10799
|
|
178
179
|
xp/services/telegram/telegram_service.py,sha256=XrP1CPi0ckxoKBaNwLA6lo-TogWxXgmXDOsU4Xl8BlY,13237
|
|
179
180
|
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
|
|
180
183
|
xp/term/__init__.py,sha256=Xg2DhBeI3xQJLfc7_BPWI1por-rUXemyer5OtOt9Cus,51
|
|
181
|
-
xp/term/protocol.py,sha256=
|
|
184
|
+
xp/term/protocol.py,sha256=rJmhvXaLBC9tVg7oYew6yAuCO0I9n-N7uIyOLm25Wnc,3330
|
|
182
185
|
xp/term/protocol.tcss,sha256=r_KfxrbpycGHLVXqZc6INBBcUJME0hLrAZkF1oqnab4,2126
|
|
183
|
-
xp/term/protocol.yml,sha256=BI1dyWfYsINsJnbSR-z4fzFOsYcY27dS6it8eo7AVnU,2124
|
|
184
186
|
xp/term/widgets/__init__.py,sha256=ftWmN_fmjxy2E8Qfm-YSRmzKfgL0KTBCTpgvYWCPbUY,274
|
|
185
|
-
xp/term/widgets/help_menu.py,sha256=
|
|
186
|
-
xp/term/widgets/protocol_log.py,sha256=
|
|
187
|
-
xp/term/widgets/status_footer.py,sha256=
|
|
187
|
+
xp/term/widgets/help_menu.py,sha256=7viKIfyPJr-uz55Y1kgo6h4iHhntxwKs_qmC5siRYNM,1821
|
|
188
|
+
xp/term/widgets/protocol_log.py,sha256=CJUpckWj7GC1kVqixDadteyGnI4aHyzd4kkH-pSbzno,2600
|
|
189
|
+
xp/term/widgets/status_footer.py,sha256=8O3W8clgSbkX21_b9iJ_3XKgDjYTG1Bi-L_PaiEPI7U,3104
|
|
188
190
|
xp/utils/__init__.py,sha256=_avMF_UOkfR3tNeDIPqQ5odmbq5raKkaq1rZ9Cn1CJs,332
|
|
189
191
|
xp/utils/checksum.py,sha256=HDpiQxmdIedbCbZ4o_Box0i_Zig417BtCV_46ZyhiTk,1711
|
|
190
|
-
xp/utils/dependencies.py,sha256=
|
|
192
|
+
xp/utils/dependencies.py,sha256=Rw3NsvPr7P7xtm2LzLLBP7Q8W07A2fmp4HOKXDH9wS4,23457
|
|
191
193
|
xp/utils/event_helper.py,sha256=W-A_xmoXlpWZBbJH6qdaN50o3-XrwFsDgvAGMJDiAgo,1001
|
|
192
194
|
xp/utils/logging.py,sha256=rZDXwlBrYK8A6MPq5StsMNpgsRowzJXM6fvROPwJdGM,3750
|
|
193
195
|
xp/utils/serialization.py,sha256=RWHHk86feaB4ZP7rjE4qOWK0900yg2joUBDkP76gfOY,4618
|
|
194
196
|
xp/utils/state_machine.py,sha256=Oe2sLwCh9z_vr1tF6X0ZRGTeuckRQAGzmef7xc9CNdc,2413
|
|
195
197
|
xp/utils/time_utils.py,sha256=dEyViDlAG9GWU-J3D_YVa-sGma6yiyyMTgN4h2x3PY4,3781
|
|
196
|
-
conson_xp-1.
|
|
198
|
+
conson_xp-1.26.0.dist-info/RECORD,,
|
xp/__init__.py
CHANGED
|
@@ -23,9 +23,5 @@ def protocol_monitor(ctx: Context) -> None:
|
|
|
23
23
|
"""
|
|
24
24
|
from xp.term.protocol import ProtocolMonitorApp
|
|
25
25
|
|
|
26
|
-
# Resolve
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
# Initialize and run Textual app
|
|
30
|
-
app = ProtocolMonitorApp(container=container)
|
|
31
|
-
app.run()
|
|
26
|
+
# Resolve ProtocolMonitorApp from container and run
|
|
27
|
+
ctx.obj.get("container").get_container().resolve(ProtocolMonitorApp).run()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Domain models for telegram display in terminal interface."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class TelegramDisplayEvent:
|
|
9
|
+
"""Event containing telegram data for display in TUI.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
direction: Direction of telegram ("RX" for received, "TX" for transmitted).
|
|
13
|
+
telegram: Formatted telegram string.
|
|
14
|
+
timestamp: Optional timestamp of the event.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
direction: Literal["RX", "TX"]
|
|
18
|
+
telegram: str
|
|
19
|
+
timestamp: Optional[float] = None
|
|
@@ -325,10 +325,23 @@ class ConbusEventProtocol(protocol.Protocol, protocol.ClientFactory):
|
|
|
325
325
|
self._reactor.stop()
|
|
326
326
|
|
|
327
327
|
def connect(self) -> None:
|
|
328
|
-
"""Connect to TCP server.
|
|
328
|
+
"""Connect to TCP server.
|
|
329
|
+
|
|
330
|
+
Automatically detects and integrates with running asyncio event loop if present.
|
|
331
|
+
"""
|
|
329
332
|
self.logger.info(
|
|
330
333
|
f"Connecting to TCP server {self.cli_config.ip}:{self.cli_config.port}"
|
|
331
334
|
)
|
|
335
|
+
|
|
336
|
+
# Auto-detect and integrate with asyncio event loop if available
|
|
337
|
+
try:
|
|
338
|
+
event_loop = asyncio.get_running_loop()
|
|
339
|
+
self.logger.debug(f"Detected running event loop: {event_loop}")
|
|
340
|
+
self.set_event_loop(event_loop)
|
|
341
|
+
except RuntimeError:
|
|
342
|
+
# No running event loop - that's fine for non-async contexts
|
|
343
|
+
self.logger.debug("No running event loop detected - using reactor only")
|
|
344
|
+
|
|
332
345
|
self._reactor.connectTCP(self.cli_config.ip, self.cli_config.port, self)
|
|
333
346
|
|
|
334
347
|
def disconnect(self) -> None:
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Protocol Monitor Service for terminal interface."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, ItemsView, Optional
|
|
5
|
+
|
|
6
|
+
from psygnal import Signal
|
|
7
|
+
from twisted.python.failure import Failure
|
|
8
|
+
|
|
9
|
+
from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
|
|
10
|
+
from xp.models.term.connection_state import ConnectionState
|
|
11
|
+
from xp.models.term.protocol_keys_config import ProtocolKeyConfig, ProtocolKeysConfig
|
|
12
|
+
from xp.models.term.telegram_display import TelegramDisplayEvent
|
|
13
|
+
from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProtocolMonitorService:
|
|
17
|
+
"""Service for protocol monitoring in terminal interface.
|
|
18
|
+
|
|
19
|
+
Wraps ConbusEventProtocol and provides high-level operations
|
|
20
|
+
for the TUI without exposing protocol implementation details.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
_conbus_protocol: Protocol instance for Conbus communication.
|
|
24
|
+
_protocol_keys: Configuration for protocol keyboard shortcuts.
|
|
25
|
+
connection_state: Current connection state (read-only property).
|
|
26
|
+
server_info: Server connection info as "IP:port" (read-only property).
|
|
27
|
+
on_connection_state_changed: Signal emitted when connection state changes.
|
|
28
|
+
on_telegram_display: Signal emitted when telegram should be displayed.
|
|
29
|
+
on_status_message: Signal emitted for status updates.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
on_connection_state_changed: Signal = Signal(ConnectionState)
|
|
33
|
+
on_telegram_display: Signal = Signal(TelegramDisplayEvent)
|
|
34
|
+
on_status_message: Signal = Signal(str)
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
conbus_protocol: ConbusEventProtocol,
|
|
39
|
+
protocol_keys: ProtocolKeysConfig,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Initialize the Protocol Monitor service.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
conbus_protocol: ConbusEventProtocol instance.
|
|
45
|
+
protocol_keys: Protocol keys configuration.
|
|
46
|
+
"""
|
|
47
|
+
self.logger = logging.getLogger(__name__)
|
|
48
|
+
self._conbus_protocol = conbus_protocol
|
|
49
|
+
self._connection_state = ConnectionState.DISCONNECTED
|
|
50
|
+
self._state_machine = ConnectionState.create_state_machine()
|
|
51
|
+
self._protocol_keys = protocol_keys
|
|
52
|
+
|
|
53
|
+
# Connect to protocol signals
|
|
54
|
+
self._connect_signals()
|
|
55
|
+
|
|
56
|
+
def _connect_signals(self) -> None:
|
|
57
|
+
"""Connect to protocol signals."""
|
|
58
|
+
self._conbus_protocol.on_connection_made.connect(self._on_connection_made)
|
|
59
|
+
self._conbus_protocol.on_connection_failed.connect(self._on_connection_failed)
|
|
60
|
+
self._conbus_protocol.on_telegram_received.connect(self._on_telegram_received)
|
|
61
|
+
self._conbus_protocol.on_telegram_sent.connect(self._on_telegram_sent)
|
|
62
|
+
self._conbus_protocol.on_timeout.connect(self._on_timeout)
|
|
63
|
+
self._conbus_protocol.on_failed.connect(self._on_failed)
|
|
64
|
+
|
|
65
|
+
def _disconnect_signals(self) -> None:
|
|
66
|
+
"""Disconnect from protocol signals."""
|
|
67
|
+
self._conbus_protocol.on_connection_made.disconnect(self._on_connection_made)
|
|
68
|
+
self._conbus_protocol.on_connection_failed.disconnect(
|
|
69
|
+
self._on_connection_failed
|
|
70
|
+
)
|
|
71
|
+
self._conbus_protocol.on_telegram_received.disconnect(
|
|
72
|
+
self._on_telegram_received
|
|
73
|
+
)
|
|
74
|
+
self._conbus_protocol.on_telegram_sent.disconnect(self._on_telegram_sent)
|
|
75
|
+
self._conbus_protocol.on_timeout.disconnect(self._on_timeout)
|
|
76
|
+
self._conbus_protocol.on_failed.disconnect(self._on_failed)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def connection_state(self) -> ConnectionState:
|
|
80
|
+
"""Get current connection state.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Current connection state.
|
|
84
|
+
"""
|
|
85
|
+
return self._connection_state
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def server_info(self) -> str:
|
|
89
|
+
"""Get server connection info (IP:port).
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Server address in format "IP:port".
|
|
93
|
+
"""
|
|
94
|
+
return f"{self._conbus_protocol.cli_config.ip}:{self._conbus_protocol.cli_config.port}"
|
|
95
|
+
|
|
96
|
+
def _connect(self) -> None:
|
|
97
|
+
"""Initiate connection to server."""
|
|
98
|
+
if not self._state_machine.can_transition("connect"):
|
|
99
|
+
self.logger.warning(
|
|
100
|
+
f"Cannot connect: current state is {self._connection_state.value}"
|
|
101
|
+
)
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
if self._state_machine.transition("connecting", ConnectionState.CONNECTING):
|
|
105
|
+
self._connection_state = ConnectionState.CONNECTING
|
|
106
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
107
|
+
self.on_status_message.emit(f"Connecting to {self.server_info}...")
|
|
108
|
+
|
|
109
|
+
self._conbus_protocol.connect()
|
|
110
|
+
|
|
111
|
+
def _disconnect(self) -> None:
|
|
112
|
+
"""Disconnect from server."""
|
|
113
|
+
if not self._state_machine.can_transition("disconnect"):
|
|
114
|
+
self.logger.warning(
|
|
115
|
+
f"Cannot disconnect: current state is {self._connection_state.value}"
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
if self._state_machine.transition(
|
|
120
|
+
"disconnecting", ConnectionState.DISCONNECTING
|
|
121
|
+
):
|
|
122
|
+
self._connection_state = ConnectionState.DISCONNECTING
|
|
123
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
124
|
+
self.on_status_message.emit("Disconnecting...")
|
|
125
|
+
|
|
126
|
+
self._conbus_protocol.disconnect()
|
|
127
|
+
|
|
128
|
+
if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
|
|
129
|
+
self._connection_state = ConnectionState.DISCONNECTED
|
|
130
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
131
|
+
self.on_status_message.emit("Disconnected")
|
|
132
|
+
|
|
133
|
+
def toggle_connection(self) -> None:
|
|
134
|
+
"""Toggle connection state between connected and disconnected.
|
|
135
|
+
|
|
136
|
+
Disconnects if currently connected or connecting.
|
|
137
|
+
Connects if currently disconnected or failed.
|
|
138
|
+
"""
|
|
139
|
+
if self._connection_state in (
|
|
140
|
+
ConnectionState.CONNECTED,
|
|
141
|
+
ConnectionState.CONNECTING,
|
|
142
|
+
):
|
|
143
|
+
self._disconnect()
|
|
144
|
+
else:
|
|
145
|
+
self._connect()
|
|
146
|
+
|
|
147
|
+
def _send_telegram(self, name: str, telegram: str) -> None:
|
|
148
|
+
"""Send a raw telegram.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
name: Display name for the telegram.
|
|
152
|
+
telegram: Raw telegram string.
|
|
153
|
+
"""
|
|
154
|
+
try:
|
|
155
|
+
self._conbus_protocol.send_raw_telegram(telegram)
|
|
156
|
+
self.on_status_message.emit(f"{name} sent.")
|
|
157
|
+
except Exception as e:
|
|
158
|
+
self.logger.error(f"Failed to send telegram: {e}")
|
|
159
|
+
self.on_status_message.emit(f"Failed: {e}")
|
|
160
|
+
|
|
161
|
+
def handle_key_press(self, key: str) -> bool:
|
|
162
|
+
"""Handle protocol key press.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
key: Key that was pressed.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
True if key was handled, False otherwise.
|
|
169
|
+
"""
|
|
170
|
+
if key in self._protocol_keys.protocol:
|
|
171
|
+
key_config = self._protocol_keys.protocol[key]
|
|
172
|
+
for telegram in key_config.telegrams:
|
|
173
|
+
self._send_telegram(key_config.name, telegram)
|
|
174
|
+
return True
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
# Protocol signal handlers
|
|
178
|
+
|
|
179
|
+
def _on_connection_made(self) -> None:
|
|
180
|
+
"""Handle connection established."""
|
|
181
|
+
if self._state_machine.transition("connected", ConnectionState.CONNECTED):
|
|
182
|
+
self._connection_state = ConnectionState.CONNECTED
|
|
183
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
184
|
+
self.on_status_message.emit(f"Connected to {self.server_info}")
|
|
185
|
+
|
|
186
|
+
def _on_connection_failed(self, failure: Failure) -> None:
|
|
187
|
+
"""Handle connection failed.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
failure: Twisted failure object with error details.
|
|
191
|
+
"""
|
|
192
|
+
if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
|
|
193
|
+
self._connection_state = ConnectionState.DISCONNECTED
|
|
194
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
195
|
+
self.on_status_message.emit(failure.getErrorMessage())
|
|
196
|
+
|
|
197
|
+
def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
|
|
198
|
+
"""Handle telegram received.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
event: Telegram received event with frame data.
|
|
202
|
+
"""
|
|
203
|
+
display_event = TelegramDisplayEvent(direction="RX", telegram=event.frame)
|
|
204
|
+
self.on_telegram_display.emit(display_event)
|
|
205
|
+
|
|
206
|
+
def _on_telegram_sent(self, telegram: str) -> None:
|
|
207
|
+
"""Handle telegram sent.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
telegram: Sent telegram string.
|
|
211
|
+
"""
|
|
212
|
+
display_event = TelegramDisplayEvent(direction="TX", telegram=telegram)
|
|
213
|
+
self.on_telegram_display.emit(display_event)
|
|
214
|
+
|
|
215
|
+
def _on_timeout(self) -> None:
|
|
216
|
+
"""Handle timeout."""
|
|
217
|
+
self.logger.debug("Timeout occurred (continuous monitoring)")
|
|
218
|
+
|
|
219
|
+
def _on_failed(self, error: str) -> None:
|
|
220
|
+
"""Handle connection failed.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
error: Error message describing the failure.
|
|
224
|
+
"""
|
|
225
|
+
if self._state_machine.transition("failed", ConnectionState.FAILED):
|
|
226
|
+
self._connection_state = ConnectionState.FAILED
|
|
227
|
+
self.on_connection_state_changed.emit(self._connection_state)
|
|
228
|
+
self.on_status_message.emit(f"Failed: {error}")
|
|
229
|
+
|
|
230
|
+
def cleanup(self) -> None:
|
|
231
|
+
"""Clean up service resources."""
|
|
232
|
+
self._disconnect_signals()
|
|
233
|
+
if self._conbus_protocol.transport:
|
|
234
|
+
self._disconnect()
|
|
235
|
+
|
|
236
|
+
def get_keys(self) -> ItemsView[str, ProtocolKeyConfig]:
|
|
237
|
+
"""Get protocol key mappings.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Dictionary items view of key to ProtocolKeyConfig mappings.
|
|
241
|
+
"""
|
|
242
|
+
return self._protocol_keys.protocol.items()
|
|
243
|
+
|
|
244
|
+
def __enter__(self) -> "ProtocolMonitorService":
|
|
245
|
+
"""Enter context manager.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Self for context management.
|
|
249
|
+
"""
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
def __exit__(
|
|
253
|
+
self,
|
|
254
|
+
_exc_type: Optional[type],
|
|
255
|
+
_exc_val: Optional[BaseException],
|
|
256
|
+
_exc_tb: Optional[Any],
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Exit context manager and clean up resources.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
_exc_type: Exception type if any.
|
|
262
|
+
_exc_val: Exception value if any.
|
|
263
|
+
_exc_tb: Exception traceback if any.
|
|
264
|
+
"""
|
|
265
|
+
self.cleanup()
|
xp/term/protocol.py
CHANGED
|
@@ -6,8 +6,6 @@ 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.models.term import ProtocolKeysConfig
|
|
10
|
-
from xp.models.term.status_message import StatusMessageChanged
|
|
11
9
|
from xp.term.widgets.help_menu import HelpMenuWidget
|
|
12
10
|
from xp.term.widgets.protocol_log import ProtocolLogWidget
|
|
13
11
|
from xp.term.widgets.status_footer import StatusFooterWidget
|
|
@@ -20,7 +18,7 @@ class ProtocolMonitorApp(App[None]):
|
|
|
20
18
|
terminal interface with keyboard shortcuts for control.
|
|
21
19
|
|
|
22
20
|
Attributes:
|
|
23
|
-
|
|
21
|
+
protocol_service: ProtocolMonitorService for protocol operations.
|
|
24
22
|
CSS_PATH: Path to CSS stylesheet file.
|
|
25
23
|
BINDINGS: Keyboard bindings for app actions.
|
|
26
24
|
TITLE: Application title displayed in header.
|
|
@@ -38,27 +36,17 @@ class ProtocolMonitorApp(App[None]):
|
|
|
38
36
|
("0-9,a-q", "protocol_keys", "Keys"),
|
|
39
37
|
]
|
|
40
38
|
|
|
41
|
-
def __init__(self,
|
|
39
|
+
def __init__(self, protocol_service: Any) -> None:
|
|
42
40
|
"""Initialize the Protocol Monitor app.
|
|
43
41
|
|
|
44
42
|
Args:
|
|
45
|
-
|
|
43
|
+
protocol_service: ProtocolMonitorService for protocol operations.
|
|
46
44
|
"""
|
|
47
45
|
super().__init__()
|
|
48
|
-
self.
|
|
46
|
+
self.protocol_service = protocol_service
|
|
49
47
|
self.protocol_widget: Optional[ProtocolLogWidget] = None
|
|
50
48
|
self.help_menu: Optional[HelpMenuWidget] = None
|
|
51
49
|
self.footer_widget: Optional[StatusFooterWidget] = None
|
|
52
|
-
self.protocol_keys = self._load_protocol_keys()
|
|
53
|
-
|
|
54
|
-
def _load_protocol_keys(self) -> ProtocolKeysConfig:
|
|
55
|
-
"""Load protocol keys from YAML config file.
|
|
56
|
-
|
|
57
|
-
Returns:
|
|
58
|
-
ProtocolKeysConfig instance.
|
|
59
|
-
"""
|
|
60
|
-
config_path = Path(__file__).parent / "protocol.yml"
|
|
61
|
-
return ProtocolKeysConfig.from_yaml(config_path)
|
|
62
50
|
|
|
63
51
|
def compose(self) -> ComposeResult:
|
|
64
52
|
"""Compose the app layout with widgets.
|
|
@@ -67,31 +55,37 @@ class ProtocolMonitorApp(App[None]):
|
|
|
67
55
|
ProtocolLogWidget and Footer widgets.
|
|
68
56
|
"""
|
|
69
57
|
with Horizontal(id="main-container"):
|
|
70
|
-
self.protocol_widget = ProtocolLogWidget(
|
|
58
|
+
self.protocol_widget = ProtocolLogWidget(service=self.protocol_service)
|
|
71
59
|
yield self.protocol_widget
|
|
72
60
|
|
|
73
61
|
# Help menu (hidden by default)
|
|
74
62
|
self.help_menu = HelpMenuWidget(
|
|
75
|
-
|
|
63
|
+
service=self.protocol_service, id="help-menu"
|
|
76
64
|
)
|
|
77
65
|
yield self.help_menu
|
|
78
66
|
|
|
79
|
-
self.footer_widget = StatusFooterWidget(
|
|
67
|
+
self.footer_widget = StatusFooterWidget(
|
|
68
|
+
service=self.protocol_service, id="footer-container"
|
|
69
|
+
)
|
|
80
70
|
yield self.footer_widget
|
|
81
71
|
|
|
72
|
+
async def on_mount(self) -> None:
|
|
73
|
+
"""Initialize app after UI is mounted.
|
|
74
|
+
|
|
75
|
+
Delays connection by 0.5s to let UI render first.
|
|
76
|
+
"""
|
|
77
|
+
import asyncio
|
|
78
|
+
|
|
79
|
+
# Delay connection to let UI render
|
|
80
|
+
await asyncio.sleep(0.5)
|
|
81
|
+
self.protocol_service.connect()
|
|
82
|
+
|
|
82
83
|
def action_toggle_connection(self) -> None:
|
|
83
84
|
"""Toggle connection on 'c' key press.
|
|
84
85
|
|
|
85
86
|
Connects if disconnected/failed, disconnects if connected/connecting.
|
|
86
87
|
"""
|
|
87
|
-
|
|
88
|
-
from xp.term.widgets.protocol_log import ConnectionState
|
|
89
|
-
|
|
90
|
-
state = self.protocol_widget.connection_state
|
|
91
|
-
if state in (ConnectionState.CONNECTED, ConnectionState.CONNECTING):
|
|
92
|
-
self.protocol_widget.disconnect()
|
|
93
|
-
else:
|
|
94
|
-
self.protocol_widget.connect()
|
|
88
|
+
self.protocol_service.toggle_connection()
|
|
95
89
|
|
|
96
90
|
def action_reset(self) -> None:
|
|
97
91
|
"""Reset and clear protocol widget on 'r' key press."""
|
|
@@ -104,34 +98,4 @@ class ProtocolMonitorApp(App[None]):
|
|
|
104
98
|
Args:
|
|
105
99
|
event: Key press event from Textual.
|
|
106
100
|
"""
|
|
107
|
-
|
|
108
|
-
key_config = self.protocol_keys.protocol[event.key]
|
|
109
|
-
for telegram in key_config.telegrams:
|
|
110
|
-
self.protocol_widget.send_telegram(key_config.name, telegram)
|
|
111
|
-
|
|
112
|
-
def on_mount(self) -> None:
|
|
113
|
-
"""Set up status line updates when app mounts."""
|
|
114
|
-
if self.protocol_widget:
|
|
115
|
-
self.protocol_widget.watch(
|
|
116
|
-
self.protocol_widget,
|
|
117
|
-
"connection_state",
|
|
118
|
-
self._update_status,
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
def _update_status(self, state: Any) -> None:
|
|
122
|
-
"""Update status line with connection state.
|
|
123
|
-
|
|
124
|
-
Args:
|
|
125
|
-
state: Current connection state.
|
|
126
|
-
"""
|
|
127
|
-
if self.footer_widget:
|
|
128
|
-
self.footer_widget.update_status(state)
|
|
129
|
-
|
|
130
|
-
def on_status_message_changed(self, message: StatusMessageChanged) -> None:
|
|
131
|
-
"""Handle status message changes from protocol widget.
|
|
132
|
-
|
|
133
|
-
Args:
|
|
134
|
-
message: Message containing the status text.
|
|
135
|
-
"""
|
|
136
|
-
if self.footer_widget:
|
|
137
|
-
self.footer_widget.update_message(message.message)
|
|
101
|
+
self.protocol_service.handle_key_press(event.key)
|
xp/term/widgets/help_menu.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Help Menu Widget for displaying keyboard shortcuts and protocol keys."""
|
|
2
2
|
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
4
|
|
|
5
5
|
from textual.app import ComposeResult
|
|
6
6
|
from textual.containers import Vertical
|
|
7
7
|
from textual.widgets import DataTable
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from xp.services.term.protocol_monitor_service import ProtocolMonitorService
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class HelpMenuWidget(Vertical):
|
|
@@ -16,25 +17,25 @@ class HelpMenuWidget(Vertical):
|
|
|
16
17
|
corresponding protocol commands.
|
|
17
18
|
|
|
18
19
|
Attributes:
|
|
19
|
-
|
|
20
|
+
service: ProtocolMonitorService for accessing protocol keys.
|
|
20
21
|
help_table: DataTable widget for displaying key mappings.
|
|
21
22
|
"""
|
|
22
23
|
|
|
23
24
|
def __init__(
|
|
24
25
|
self,
|
|
25
|
-
|
|
26
|
+
service: "ProtocolMonitorService",
|
|
26
27
|
*args: Any,
|
|
27
28
|
**kwargs: Any,
|
|
28
29
|
) -> None:
|
|
29
30
|
"""Initialize the Help Menu widget.
|
|
30
31
|
|
|
31
32
|
Args:
|
|
32
|
-
|
|
33
|
+
service: ProtocolMonitorService instance.
|
|
33
34
|
args: Additional positional arguments for Vertical.
|
|
34
35
|
kwargs: Additional keyword arguments for Vertical.
|
|
35
36
|
"""
|
|
36
37
|
super().__init__(*args, **kwargs)
|
|
37
|
-
self.
|
|
38
|
+
self.service: ProtocolMonitorService = service
|
|
38
39
|
self.help_table: DataTable = DataTable(id="help-table", show_header=False)
|
|
39
40
|
self.help_table.can_focus = False
|
|
40
41
|
self.border_title = "Help menu"
|
|
@@ -51,5 +52,5 @@ class HelpMenuWidget(Vertical):
|
|
|
51
52
|
def on_mount(self) -> None:
|
|
52
53
|
"""Populate help table when widget mounts."""
|
|
53
54
|
self.help_table.add_columns("Key", "Command")
|
|
54
|
-
for key, config in self.
|
|
55
|
+
for key, config in self.service.get_keys():
|
|
55
56
|
self.help_table.add_row(key, config.name)
|
xp/term/widgets/protocol_log.py
CHANGED
|
@@ -1,49 +1,38 @@
|
|
|
1
1
|
"""Protocol Log Widget for displaying telegram stream."""
|
|
2
2
|
|
|
3
|
-
import asyncio
|
|
4
3
|
import logging
|
|
5
4
|
from typing import Any, Optional
|
|
6
5
|
|
|
7
|
-
from textual.reactive import reactive
|
|
8
6
|
from textual.widget import Widget
|
|
9
7
|
from textual.widgets import RichLog
|
|
10
|
-
from twisted.python.failure import Failure
|
|
11
8
|
|
|
12
|
-
from xp.models.
|
|
13
|
-
from xp.
|
|
14
|
-
from xp.models.term.status_message import StatusMessageChanged
|
|
15
|
-
from xp.services.protocol import ConbusEventProtocol
|
|
9
|
+
from xp.models.term.telegram_display import TelegramDisplayEvent
|
|
10
|
+
from xp.services.term.protocol_monitor_service import ProtocolMonitorService
|
|
16
11
|
|
|
17
12
|
|
|
18
13
|
class ProtocolLogWidget(Widget):
|
|
19
14
|
"""Widget for displaying protocol telegram stream.
|
|
20
15
|
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
Displays live RX/TX telegram stream with color-coded direction markers
|
|
17
|
+
via ProtocolMonitorService.
|
|
23
18
|
|
|
24
19
|
Attributes:
|
|
25
|
-
|
|
26
|
-
connection_state: Current connection state (reactive).
|
|
27
|
-
protocol: Reference to ConbusEventProtocol (prevents duplicate connections).
|
|
20
|
+
service: ProtocolMonitorService for protocol operations.
|
|
28
21
|
logger: Logger instance for this widget.
|
|
29
22
|
log_widget: RichLog widget for displaying messages.
|
|
30
23
|
"""
|
|
31
24
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def __init__(self, container: Any) -> None:
|
|
25
|
+
def __init__(self, service: ProtocolMonitorService) -> None:
|
|
35
26
|
"""Initialize the Protocol Log widget.
|
|
36
27
|
|
|
37
28
|
Args:
|
|
38
|
-
|
|
29
|
+
service: ProtocolMonitorService instance for protocol operations.
|
|
39
30
|
"""
|
|
40
31
|
super().__init__()
|
|
41
32
|
self.border_title = "Protocol"
|
|
42
|
-
self.
|
|
43
|
-
self.protocol: Optional[ConbusEventProtocol] = None
|
|
33
|
+
self.service = service
|
|
44
34
|
self.logger = logging.getLogger(__name__)
|
|
45
35
|
self.log_widget: Optional[RichLog] = None
|
|
46
|
-
self._state_machine = ConnectionState.create_state_machine()
|
|
47
36
|
|
|
48
37
|
def compose(self) -> Any:
|
|
49
38
|
"""Compose the widget layout.
|
|
@@ -54,256 +43,39 @@ class ProtocolLogWidget(Widget):
|
|
|
54
43
|
self.log_widget = RichLog(highlight=False, markup=True)
|
|
55
44
|
yield self.log_widget
|
|
56
45
|
|
|
57
|
-
|
|
58
|
-
"""Initialize
|
|
46
|
+
def on_mount(self) -> None:
|
|
47
|
+
"""Initialize widget when mounted.
|
|
59
48
|
|
|
60
|
-
|
|
61
|
-
Resolves ConbusReceiveService and connects signals.
|
|
49
|
+
Connects to service signals for telegram display.
|
|
62
50
|
"""
|
|
63
|
-
#
|
|
64
|
-
self.
|
|
65
|
-
|
|
66
|
-
# Connect psygnal signals
|
|
67
|
-
self.protocol.on_connection_made.connect(self._on_connection_made)
|
|
68
|
-
self.protocol.on_connection_failed.connect(self._on_connection_failed)
|
|
69
|
-
self.protocol.on_telegram_received.connect(self._on_telegram_received)
|
|
70
|
-
self.protocol.on_telegram_sent.connect(self._on_telegram_sent)
|
|
71
|
-
self.protocol.on_timeout.connect(self._on_timeout)
|
|
72
|
-
self.protocol.on_failed.connect(self._on_failed)
|
|
73
|
-
|
|
74
|
-
# Delay connection to let UI render
|
|
75
|
-
await asyncio.sleep(0.5)
|
|
76
|
-
self._start_connection()
|
|
77
|
-
|
|
78
|
-
async def _start_connection_async(self) -> None:
|
|
79
|
-
"""Start TCP connection to Conbus server (async).
|
|
80
|
-
|
|
81
|
-
Guards against duplicate connections and sets up protocol signals.
|
|
82
|
-
Integrates Twisted reactor with Textual's asyncio loop cleanly.
|
|
83
|
-
"""
|
|
84
|
-
# Guard against duplicate connections (race condition)
|
|
85
|
-
if self.protocol is None:
|
|
86
|
-
self.logger.error("Protocol not initialized")
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
# Guard: Don't connect if already connected or connecting
|
|
90
|
-
if not self._state_machine.can_transition("connecting"):
|
|
91
|
-
self.logger.warning(
|
|
92
|
-
f"Already {self._state_machine.get_state().value}, ignoring connect request"
|
|
93
|
-
)
|
|
94
|
-
return
|
|
95
|
-
|
|
96
|
-
try:
|
|
97
|
-
# Transition to CONNECTING
|
|
98
|
-
if self._state_machine.transition("connecting", ConnectionState.CONNECTING):
|
|
99
|
-
self.connection_state = ConnectionState.CONNECTING
|
|
100
|
-
self.post_status(
|
|
101
|
-
f"Connecting to {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}..."
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
# Store protocol reference
|
|
105
|
-
self.logger.info(f"Protocol object: {self.protocol}")
|
|
106
|
-
self.logger.info(f"Reactor object: {self.protocol._reactor}")
|
|
107
|
-
self.logger.info(f"Reactor running: {self.protocol._reactor.running}")
|
|
108
|
-
|
|
109
|
-
# Get the currently running asyncio event loop (Textual's loop)
|
|
110
|
-
event_loop = asyncio.get_running_loop()
|
|
111
|
-
self.logger.info(f"Current running loop: {event_loop}")
|
|
112
|
-
self.logger.info(f"Loop is running: {event_loop.is_running()}")
|
|
113
|
-
|
|
114
|
-
self.protocol.connect()
|
|
115
|
-
|
|
116
|
-
# Wait for connection to establish
|
|
117
|
-
await asyncio.sleep(1.0)
|
|
118
|
-
self.logger.info(f"After 1s - transport: {self.protocol.transport}")
|
|
119
|
-
|
|
120
|
-
except Exception as e:
|
|
121
|
-
self.logger.error(f"Connection failed: {e}")
|
|
122
|
-
self.post_status(f"Connection failed: {e}")
|
|
123
|
-
# Transition to FAILED
|
|
124
|
-
if self._state_machine.transition("failed", ConnectionState.FAILED):
|
|
125
|
-
self.connection_state = ConnectionState.FAILED
|
|
126
|
-
|
|
127
|
-
def _start_connection(self) -> None:
|
|
128
|
-
"""Start connection (sync wrapper for async method)."""
|
|
129
|
-
# Use run_worker to run async method from sync context
|
|
130
|
-
self.logger.debug("Start connection")
|
|
131
|
-
self.post_status("Start connection")
|
|
132
|
-
self.run_worker(self._start_connection_async(), exclusive=True)
|
|
51
|
+
# Connect to service signals
|
|
52
|
+
self.service.on_telegram_display.connect(self._on_telegram_display)
|
|
133
53
|
|
|
134
|
-
def
|
|
135
|
-
"""Handle
|
|
136
|
-
|
|
137
|
-
Sets state to CONNECTED and displays success message.
|
|
138
|
-
"""
|
|
139
|
-
self.logger.debug("Connection made")
|
|
140
|
-
self.post_status("Connection made")
|
|
141
|
-
# Transition to CONNECTED
|
|
142
|
-
if self._state_machine.transition("connected", ConnectionState.CONNECTED):
|
|
143
|
-
self.connection_state = ConnectionState.CONNECTED
|
|
144
|
-
if self.protocol:
|
|
145
|
-
self.post_status(
|
|
146
|
-
f"Connected to {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}"
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
def _on_connection_failed(self, failure: Failure) -> None:
|
|
150
|
-
"""Handle connection failed signal.
|
|
151
|
-
|
|
152
|
-
Sets state to DISCONNECTED and displays success message.
|
|
153
|
-
"""
|
|
154
|
-
self.logger.debug("Connection failed")
|
|
155
|
-
self.post_status(failure.getErrorMessage())
|
|
156
|
-
# Transition to CONNECTED
|
|
157
|
-
if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
|
|
158
|
-
self.connection_state = ConnectionState.DISCONNECTED
|
|
159
|
-
|
|
160
|
-
def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
|
|
161
|
-
"""Handle telegram received signal.
|
|
162
|
-
|
|
163
|
-
Args:
|
|
164
|
-
event: Telegram received event with frame data.
|
|
165
|
-
"""
|
|
166
|
-
self.logger.debug("Telegram received")
|
|
167
|
-
if self.log_widget:
|
|
168
|
-
# Display [RX] and frame in bright green
|
|
169
|
-
self.log_widget.write(f"[#00ff00]\\[RX] {event.frame}[/#00ff00]")
|
|
170
|
-
|
|
171
|
-
def _on_telegram_sent(self, telegram: str) -> None:
|
|
172
|
-
"""Handle telegram sent signal.
|
|
54
|
+
def _on_telegram_display(self, event: TelegramDisplayEvent) -> None:
|
|
55
|
+
"""Handle telegram display event from service.
|
|
173
56
|
|
|
174
57
|
Args:
|
|
175
|
-
|
|
58
|
+
event: Telegram display event with direction and telegram data.
|
|
176
59
|
"""
|
|
177
|
-
self.logger.debug("Telegram sent")
|
|
178
60
|
if self.log_widget:
|
|
179
|
-
|
|
180
|
-
self.log_widget.write(
|
|
181
|
-
|
|
182
|
-
def _on_timeout(self) -> None:
|
|
183
|
-
"""Handle timeout signal.
|
|
184
|
-
|
|
185
|
-
Logs timeout but continues monitoring (no action needed).
|
|
186
|
-
"""
|
|
187
|
-
self.logger.debug("Timeout")
|
|
188
|
-
self.logger.debug("Timeout occurred (continuous monitoring)")
|
|
189
|
-
|
|
190
|
-
def _on_failed(self, error: str) -> None:
|
|
191
|
-
"""Handle connection failed signal.
|
|
192
|
-
|
|
193
|
-
Args:
|
|
194
|
-
error: Error message describing the failure.
|
|
195
|
-
"""
|
|
196
|
-
# Transition to FAILED
|
|
197
|
-
if self._state_machine.transition("failed", ConnectionState.FAILED):
|
|
198
|
-
self.connection_state = ConnectionState.FAILED
|
|
199
|
-
self.logger.error(f"Connection failed: {error}")
|
|
200
|
-
self.post_status(f"Failed: {error}")
|
|
201
|
-
|
|
202
|
-
def post_status(self, message: str) -> None:
|
|
203
|
-
"""Post status message.
|
|
204
|
-
|
|
205
|
-
Args:
|
|
206
|
-
message: message to be sent to status bar.
|
|
207
|
-
"""
|
|
208
|
-
self.post_message(StatusMessageChanged(message))
|
|
209
|
-
|
|
210
|
-
def connect(self) -> None:
|
|
211
|
-
"""Connect to Conbus server.
|
|
212
|
-
|
|
213
|
-
Only initiates connection if currently DISCONNECTED or FAILED.
|
|
214
|
-
"""
|
|
215
|
-
self.logger.debug("Connect")
|
|
216
|
-
|
|
217
|
-
# Guard: Check if connection is allowed
|
|
218
|
-
if not self._state_machine.can_transition("connect"):
|
|
219
|
-
self.logger.warning(
|
|
220
|
-
f"Cannot connect: current state is {self._state_machine.get_state().value}"
|
|
221
|
-
)
|
|
222
|
-
return
|
|
223
|
-
|
|
224
|
-
self._start_connection()
|
|
225
|
-
|
|
226
|
-
def disconnect(self) -> None:
|
|
227
|
-
"""Disconnect from Conbus server.
|
|
228
|
-
|
|
229
|
-
Only disconnects if currently CONNECTED or CONNECTING.
|
|
230
|
-
"""
|
|
231
|
-
self.logger.debug("Disconnect")
|
|
232
|
-
|
|
233
|
-
# Guard: Check if disconnection is allowed
|
|
234
|
-
if not self._state_machine.can_transition("disconnect"):
|
|
235
|
-
self.logger.warning(
|
|
236
|
-
f"Cannot disconnect: current state is {self._state_machine.get_state().value}"
|
|
61
|
+
color = "bold #00ff00" if event.direction == "TX" else "#00ff00"
|
|
62
|
+
self.log_widget.write(
|
|
63
|
+
f"[{color}]\\[{event.direction}] {event.telegram}[/{color}]"
|
|
237
64
|
)
|
|
238
|
-
return
|
|
239
|
-
|
|
240
|
-
# Transition to DISCONNECTING
|
|
241
|
-
if self._state_machine.transition(
|
|
242
|
-
"disconnecting", ConnectionState.DISCONNECTING
|
|
243
|
-
):
|
|
244
|
-
self.connection_state = ConnectionState.DISCONNECTING
|
|
245
|
-
self.post_status("Disconnecting...")
|
|
246
|
-
|
|
247
|
-
if self.protocol:
|
|
248
|
-
self.protocol.disconnect()
|
|
249
|
-
|
|
250
|
-
# Transition to DISCONNECTED
|
|
251
|
-
if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
|
|
252
|
-
self.connection_state = ConnectionState.DISCONNECTED
|
|
253
|
-
self.post_status("Disconnected")
|
|
254
|
-
|
|
255
|
-
def send_telegram(self, name: str, telegram: str) -> None:
|
|
256
|
-
"""Send a raw telegram string.
|
|
257
|
-
|
|
258
|
-
Args:
|
|
259
|
-
name: Telegram name (e.g., "Discover")
|
|
260
|
-
telegram: Telegram string including angle brackets (e.g., "S0000000000F01D00")
|
|
261
|
-
"""
|
|
262
|
-
if self.protocol is None:
|
|
263
|
-
self.logger.warning("Cannot send telegram: not connected")
|
|
264
|
-
return
|
|
265
|
-
|
|
266
|
-
try:
|
|
267
|
-
# Remove angle brackets if present
|
|
268
|
-
self.post_status(f"{name} sent.")
|
|
269
|
-
# Send raw telegram
|
|
270
|
-
self.protocol.send_raw_telegram(telegram)
|
|
271
|
-
|
|
272
|
-
except Exception as e:
|
|
273
|
-
self.logger.error(f"Failed to send telegram: {e}")
|
|
274
|
-
self.post_status(f"Failed: {e}")
|
|
275
65
|
|
|
276
66
|
def clear_log(self) -> None:
|
|
277
67
|
"""Clear the protocol log widget."""
|
|
278
68
|
if self.log_widget:
|
|
279
69
|
self.log_widget.clear()
|
|
280
|
-
self.post_status("Log cleared")
|
|
281
70
|
|
|
282
71
|
def on_unmount(self) -> None:
|
|
283
72
|
"""Clean up when widget unmounts.
|
|
284
73
|
|
|
285
|
-
Disconnects signals
|
|
74
|
+
Disconnects signals from service.
|
|
286
75
|
"""
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
self.protocol.on_connection_made.disconnect(self._on_connection_made)
|
|
291
|
-
self.protocol.on_telegram_received.disconnect(
|
|
292
|
-
self._on_telegram_received
|
|
293
|
-
)
|
|
294
|
-
self.protocol.on_telegram_sent.disconnect(self._on_telegram_sent)
|
|
295
|
-
self.protocol.on_timeout.disconnect(self._on_timeout)
|
|
296
|
-
self.protocol.on_failed.disconnect(self._on_failed)
|
|
297
|
-
|
|
298
|
-
# Close transport if connected
|
|
299
|
-
if self.protocol.transport:
|
|
300
|
-
self.protocol.disconnect()
|
|
301
|
-
|
|
302
|
-
# Reset protocol reference
|
|
303
|
-
self.protocol = None
|
|
304
|
-
|
|
305
|
-
# Set state to disconnected
|
|
306
|
-
self.connection_state = ConnectionState.DISCONNECTED
|
|
76
|
+
try:
|
|
77
|
+
# Disconnect service signals
|
|
78
|
+
self.service.on_telegram_display.disconnect(self._on_telegram_display)
|
|
307
79
|
|
|
308
|
-
|
|
309
|
-
|
|
80
|
+
except Exception as e:
|
|
81
|
+
self.logger.error(f"Error during cleanup: {e}")
|
xp/term/widgets/status_footer.py
CHANGED
|
@@ -1,30 +1,42 @@
|
|
|
1
1
|
"""Status Footer Widget for displaying app footer with connection status."""
|
|
2
2
|
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any, Optional
|
|
4
4
|
|
|
5
5
|
from textual.app import ComposeResult
|
|
6
6
|
from textual.containers import Horizontal
|
|
7
7
|
from textual.widgets import Footer, Static
|
|
8
8
|
|
|
9
|
+
from xp.models.term.connection_state import ConnectionState
|
|
10
|
+
from xp.services.term.protocol_monitor_service import ProtocolMonitorService
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
class StatusFooterWidget(Horizontal):
|
|
11
14
|
"""Footer widget with connection status indicator.
|
|
12
15
|
|
|
13
16
|
Combines the Textual Footer with a status indicator dot that shows
|
|
14
|
-
the current connection state.
|
|
17
|
+
the current connection state. Subscribes directly to service signals.
|
|
15
18
|
|
|
16
19
|
Attributes:
|
|
20
|
+
service: ProtocolMonitorService for connection state and status updates.
|
|
17
21
|
status_widget: Static widget displaying colored status dot.
|
|
22
|
+
status_text_widget: Static widget displaying status messages.
|
|
18
23
|
"""
|
|
19
24
|
|
|
20
|
-
def __init__(
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
service: Optional[ProtocolMonitorService] = None,
|
|
28
|
+
*args: Any,
|
|
29
|
+
**kwargs: Any,
|
|
30
|
+
) -> None:
|
|
21
31
|
"""Initialize the Status Footer widget.
|
|
22
32
|
|
|
23
33
|
Args:
|
|
34
|
+
service: Optional ProtocolMonitorService for signal subscriptions.
|
|
24
35
|
args: Additional positional arguments for Horizontal.
|
|
25
36
|
kwargs: Additional keyword arguments for Horizontal.
|
|
26
37
|
"""
|
|
27
38
|
super().__init__(*args, **kwargs)
|
|
39
|
+
self.service = service
|
|
28
40
|
self.status_text_widget: Static = Static("", id="status-text")
|
|
29
41
|
self.status_widget: Static = Static("○", id="status-line")
|
|
30
42
|
|
|
@@ -38,7 +50,19 @@ class StatusFooterWidget(Horizontal):
|
|
|
38
50
|
yield self.status_text_widget
|
|
39
51
|
yield self.status_widget
|
|
40
52
|
|
|
41
|
-
def
|
|
53
|
+
def on_mount(self) -> None:
|
|
54
|
+
"""Subscribe to service signals when widget mounts."""
|
|
55
|
+
if self.service:
|
|
56
|
+
self.service.on_connection_state_changed.connect(self.update_status)
|
|
57
|
+
self.service.on_status_message.connect(self.update_message)
|
|
58
|
+
|
|
59
|
+
def on_unmount(self) -> None:
|
|
60
|
+
"""Unsubscribe from service signals when widget unmounts."""
|
|
61
|
+
if self.service:
|
|
62
|
+
self.service.on_connection_state_changed.disconnect(self.update_status)
|
|
63
|
+
self.service.on_status_message.disconnect(self.update_message)
|
|
64
|
+
|
|
65
|
+
def update_status(self, state: ConnectionState) -> None:
|
|
42
66
|
"""Update status indicator with connection state.
|
|
43
67
|
|
|
44
68
|
Args:
|
xp/utils/dependencies.py
CHANGED
|
@@ -10,6 +10,7 @@ from xp.models import ConbusClientConfig
|
|
|
10
10
|
from xp.models.conbus.conbus_logger_config import ConbusLoggerConfig
|
|
11
11
|
from xp.models.homekit.homekit_config import HomekitConfig
|
|
12
12
|
from xp.models.homekit.homekit_conson_config import ConsonModuleListConfig
|
|
13
|
+
from xp.models.term.protocol_keys_config import ProtocolKeysConfig
|
|
13
14
|
from xp.services.actiontable.actiontable_serializer import ActionTableSerializer
|
|
14
15
|
from xp.services.actiontable.msactiontable_serializer import MsActionTableSerializer
|
|
15
16
|
from xp.services.actiontable.msactiontable_xp20_serializer import (
|
|
@@ -73,6 +74,8 @@ from xp.services.telegram.telegram_discover_service import TelegramDiscoverServi
|
|
|
73
74
|
from xp.services.telegram.telegram_link_number_service import LinkNumberService
|
|
74
75
|
from xp.services.telegram.telegram_output_service import TelegramOutputService
|
|
75
76
|
from xp.services.telegram.telegram_service import TelegramService
|
|
77
|
+
from xp.services.term.protocol_monitor_service import ProtocolMonitorService
|
|
78
|
+
from xp.term.protocol import ProtocolMonitorApp
|
|
76
79
|
from xp.utils.logging import LoggerService
|
|
77
80
|
|
|
78
81
|
asyncioreactor.install()
|
|
@@ -94,6 +97,7 @@ class ServiceContainer:
|
|
|
94
97
|
homekit_config_path: str = "homekit.yml",
|
|
95
98
|
conson_config_path: str = "conson.yml",
|
|
96
99
|
server_port: int = 10001,
|
|
100
|
+
protocol_keys_config_path: str = "protocol.yml",
|
|
97
101
|
reverse_proxy_port: int = 10001,
|
|
98
102
|
):
|
|
99
103
|
"""
|
|
@@ -104,6 +108,7 @@ class ServiceContainer:
|
|
|
104
108
|
logger_config_path: Path to the Conbus Loggerr configuration file
|
|
105
109
|
homekit_config_path: Path to the HomeKit configuration file
|
|
106
110
|
conson_config_path: Path to the Conson configuration file
|
|
111
|
+
protocol_keys_config_path: Path to the protocol keys configuration file
|
|
107
112
|
server_port: Port for the server service
|
|
108
113
|
reverse_proxy_port: Port for the reverse proxy service
|
|
109
114
|
"""
|
|
@@ -112,6 +117,7 @@ class ServiceContainer:
|
|
|
112
117
|
self._logger_config_path = logger_config_path
|
|
113
118
|
self._homekit_config_path = homekit_config_path
|
|
114
119
|
self._conson_config_path = conson_config_path
|
|
120
|
+
self._protocol_keys_config_path = protocol_keys_config_path
|
|
115
121
|
self._server_port = server_port
|
|
116
122
|
self._reverse_proxy_port = reverse_proxy_port
|
|
117
123
|
|
|
@@ -193,6 +199,24 @@ class ServiceContainer:
|
|
|
193
199
|
scope=punq.Scope.singleton,
|
|
194
200
|
)
|
|
195
201
|
|
|
202
|
+
# Terminal UI
|
|
203
|
+
self.container.register(
|
|
204
|
+
ProtocolMonitorService,
|
|
205
|
+
factory=lambda: ProtocolMonitorService(
|
|
206
|
+
conbus_protocol=self.container.resolve(ConbusEventProtocol),
|
|
207
|
+
protocol_keys=self._load_protocol_keys(),
|
|
208
|
+
),
|
|
209
|
+
scope=punq.Scope.singleton,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
self.container.register(
|
|
213
|
+
ProtocolMonitorApp,
|
|
214
|
+
factory=lambda: ProtocolMonitorApp(
|
|
215
|
+
protocol_service=self.container.resolve(ProtocolMonitorService)
|
|
216
|
+
),
|
|
217
|
+
scope=punq.Scope.singleton,
|
|
218
|
+
)
|
|
219
|
+
|
|
196
220
|
self.container.register(
|
|
197
221
|
ConbusEventRawService,
|
|
198
222
|
factory=lambda: ConbusEventRawService(
|
|
@@ -549,6 +573,19 @@ class ServiceContainer:
|
|
|
549
573
|
scope=punq.Scope.singleton,
|
|
550
574
|
)
|
|
551
575
|
|
|
576
|
+
def _load_protocol_keys(self) -> "ProtocolKeysConfig":
|
|
577
|
+
"""Load protocol keys from YAML config file.
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
ProtocolKeysConfig instance loaded from configuration path.
|
|
581
|
+
"""
|
|
582
|
+
from pathlib import Path
|
|
583
|
+
|
|
584
|
+
from xp.models.term.protocol_keys_config import ProtocolKeysConfig
|
|
585
|
+
|
|
586
|
+
config_path = Path(self._protocol_keys_config_path).resolve()
|
|
587
|
+
return ProtocolKeysConfig.from_yaml(config_path)
|
|
588
|
+
|
|
552
589
|
def get_container(self) -> punq.Container:
|
|
553
590
|
"""
|
|
554
591
|
Get the configured container with all services registered.
|
xp/term/protocol.yml
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
protocol:
|
|
2
|
-
"1":
|
|
3
|
-
name: "Discover"
|
|
4
|
-
telegrams:
|
|
5
|
-
- S0000000000F01D00
|
|
6
|
-
"2":
|
|
7
|
-
name: "Error Code"
|
|
8
|
-
telegrams:
|
|
9
|
-
- S0020044966F02D10
|
|
10
|
-
"3":
|
|
11
|
-
name: "Module Type"
|
|
12
|
-
telegrams:
|
|
13
|
-
- S0020044966F02D00
|
|
14
|
-
"4":
|
|
15
|
-
name: "Auto Report"
|
|
16
|
-
telegrams:
|
|
17
|
-
- S0020044966F02D21
|
|
18
|
-
"5":
|
|
19
|
-
name: "Link Number"
|
|
20
|
-
telegrams:
|
|
21
|
-
- S0020044966F02D04
|
|
22
|
-
"6":
|
|
23
|
-
name: "Blink On"
|
|
24
|
-
telegrams:
|
|
25
|
-
- S0020044966F01D01
|
|
26
|
-
"7":
|
|
27
|
-
name: "Blink Off"
|
|
28
|
-
telegrams:
|
|
29
|
-
- S0020044966F01D00
|
|
30
|
-
"8":
|
|
31
|
-
name: "Output 1 On"
|
|
32
|
-
telegrams:
|
|
33
|
-
- S0020044966F02101
|
|
34
|
-
"9":
|
|
35
|
-
name: "Output 1 Off"
|
|
36
|
-
telegrams:
|
|
37
|
-
- S0020044966F02100
|
|
38
|
-
"0":
|
|
39
|
-
name: "Output State"
|
|
40
|
-
telegrams:
|
|
41
|
-
- S0020044966F02D09
|
|
42
|
-
"a":
|
|
43
|
-
name: "Module State"
|
|
44
|
-
telegrams:
|
|
45
|
-
- S0020044966F02D09
|
|
46
|
-
"b":
|
|
47
|
-
name: "All Off"
|
|
48
|
-
telegrams:
|
|
49
|
-
- E02L00I00M
|
|
50
|
-
- E02L00I00B
|
|
51
|
-
"c":
|
|
52
|
-
name: "All On"
|
|
53
|
-
telegrams:
|
|
54
|
-
- E02L00I08M
|
|
55
|
-
- E02L00I08B
|
|
56
|
-
|
|
57
|
-
"d":
|
|
58
|
-
name: "Link 1 On"
|
|
59
|
-
telegrams:
|
|
60
|
-
- E02L01I08M
|
|
61
|
-
- E02L01I08B
|
|
62
|
-
|
|
63
|
-
"e":
|
|
64
|
-
name: "Link 1 Off"
|
|
65
|
-
telegrams:
|
|
66
|
-
- E02L01I09M
|
|
67
|
-
- E02L01I09B
|
|
68
|
-
|
|
69
|
-
"f":
|
|
70
|
-
name: "Link 2 On"
|
|
71
|
-
telegrams:
|
|
72
|
-
- E02L02I08M
|
|
73
|
-
- E02L02I08B
|
|
74
|
-
|
|
75
|
-
"g":
|
|
76
|
-
name: "Link 2 Off"
|
|
77
|
-
telegrams:
|
|
78
|
-
- E02L02I09M
|
|
79
|
-
- E02L02I09B
|
|
80
|
-
|
|
81
|
-
"h":
|
|
82
|
-
name: "Link 3 On"
|
|
83
|
-
telegrams:
|
|
84
|
-
- E02L03I08M
|
|
85
|
-
- E02L03I08B
|
|
86
|
-
|
|
87
|
-
"i":
|
|
88
|
-
name: "Link 3 Off"
|
|
89
|
-
telegrams:
|
|
90
|
-
- E02L03I09M
|
|
91
|
-
- E02L03I09B
|
|
92
|
-
|
|
93
|
-
"j":
|
|
94
|
-
name: "Link 4 On"
|
|
95
|
-
telegrams:
|
|
96
|
-
- E02L04I08M
|
|
97
|
-
- E02L04I08B
|
|
98
|
-
|
|
99
|
-
"k":
|
|
100
|
-
name: "Link 4 Off"
|
|
101
|
-
telegrams:
|
|
102
|
-
- E02L04I09M
|
|
103
|
-
- E02L04I09B
|
|
104
|
-
|
|
105
|
-
"l":
|
|
106
|
-
name: "Link 5 On"
|
|
107
|
-
telegrams:
|
|
108
|
-
- E02L05I08M
|
|
109
|
-
- E02L05I08B
|
|
110
|
-
|
|
111
|
-
"m":
|
|
112
|
-
name: "Link 5 Off"
|
|
113
|
-
telegrams:
|
|
114
|
-
- E02L05I09M
|
|
115
|
-
- E02L05I09B
|
|
116
|
-
|
|
117
|
-
"n":
|
|
118
|
-
name: "Link 6 On"
|
|
119
|
-
telegrams:
|
|
120
|
-
- E02L06I08M
|
|
121
|
-
- E02L06I08B
|
|
122
|
-
|
|
123
|
-
"o":
|
|
124
|
-
name: "Link 6 Off"
|
|
125
|
-
telegrams:
|
|
126
|
-
- E02L06I09M
|
|
127
|
-
- E02L06I09B
|
|
128
|
-
|
|
129
|
-
"p":
|
|
130
|
-
name: "Link 7 On"
|
|
131
|
-
telegrams:
|
|
132
|
-
- E02L07I08M
|
|
133
|
-
- E02L07I08B
|
|
134
|
-
|
|
135
|
-
"q":
|
|
136
|
-
name: "Link 7 Off"
|
|
137
|
-
telegrams:
|
|
138
|
-
- E02L07I09M
|
|
139
|
-
- E02L07I09B
|
|
File without changes
|
|
File without changes
|
|
File without changes
|