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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: conson-xp
3
- Version: 1.24.0
3
+ Version: 1.26.0
4
4
  Summary: XP Protocol Communication Tools
5
5
  Author-Email: ldvchosal <ldvchosal@github.com>
6
6
  License: MIT License
@@ -1,8 +1,8 @@
1
- conson_xp-1.24.0.dist-info/METADATA,sha256=OIqtn9P69KMHIOn2EV8vgrHgUSQRQnFd1VGMFOU5sdE,10298
2
- conson_xp-1.24.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
- conson_xp-1.24.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
- conson_xp-1.24.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
- xp/__init__.py,sha256=WnwD09XucegEUWyg8Au3w-_Cz33wTXvYu4cW_mzxpmE,181
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=ccBdvvyxjh2-cptmI9ohVIa02OOfG0dzO1JFb4KTowQ,744
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=h9ZdnN9CWSBXQqE-M9qmPPbMT3r25cxzXuJsvURp1WQ,14390
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=acYYsv7_4z5ePrnslSC1exKzqbKOE5ZGds4J33Q2XNs,4784
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=bdT5AYRdtKt_tvZTVbG7-DPMb1mj78kggtjjsa-95BA,1780
186
- xp/term/widgets/protocol_log.py,sha256=4wh6tpaaKiEUOQTqqpUml6b5WpV3YVmXlG-W8_UYuLA,11693
187
- xp/term/widgets/status_footer.py,sha256=eRZHkrG5aZCMulibX56KfFyGHS8IgL-7psvr9f9S6FI,1992
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=ECS6p0eXzocM5INLwJeckHXn_Dim18uOjXTJ29qQvkQ,22001
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.24.0.dist-info/RECORD,,
198
+ conson_xp-1.26.0.dist-info/RECORD,,
xp/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  conson-xp package.
4
4
  """
5
5
 
6
- __version__ = "1.24.0"
6
+ __version__ = "1.26.0"
7
7
  __manufacturer__ = "salchichon"
8
8
  __model__ = "xp.cli"
9
9
  __serial__ = "2025.09.23.000"
@@ -23,9 +23,5 @@ def protocol_monitor(ctx: Context) -> None:
23
23
  """
24
24
  from xp.term.protocol import ProtocolMonitorApp
25
25
 
26
- # Resolve ServiceContainer from context
27
- container = ctx.obj.get("container").get_container()
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,5 @@
1
+ """Terminal interface services."""
2
+
3
+ from xp.services.term.protocol_monitor_service import ProtocolMonitorService
4
+
5
+ __all__ = ["ProtocolMonitorService"]
@@ -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
- container: ServiceContainer for dependency injection.
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, container: Any) -> None:
39
+ def __init__(self, protocol_service: Any) -> None:
42
40
  """Initialize the Protocol Monitor app.
43
41
 
44
42
  Args:
45
- container: ServiceContainer for resolving services.
43
+ protocol_service: ProtocolMonitorService for protocol operations.
46
44
  """
47
45
  super().__init__()
48
- self.container = container
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(container=self.container)
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
- protocol_keys=self.protocol_keys, id="help-menu"
63
+ service=self.protocol_service, id="help-menu"
76
64
  )
77
65
  yield self.help_menu
78
66
 
79
- self.footer_widget = StatusFooterWidget(id="footer-container")
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
- if self.protocol_widget:
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
- if event.key in self.protocol_keys.protocol and self.protocol_widget:
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)
@@ -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
- from xp.models.term import ProtocolKeysConfig
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
- protocol_keys: Configuration of protocol keys and their telegrams.
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
- protocol_keys: ProtocolKeysConfig,
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
- protocol_keys: Configuration containing protocol key mappings.
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.protocol_keys = protocol_keys
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.protocol_keys.protocol.items():
55
+ for key, config in self.service.get_keys():
55
56
  self.help_table.add_row(key, config.name)
@@ -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.protocol.conbus_protocol import TelegramReceivedEvent
13
- from xp.models.term.connection_state import ConnectionState
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
- Connects to Conbus server via ConbusReceiveService and displays
22
- live RX/TX telegram stream with color-coded direction markers.
16
+ Displays live RX/TX telegram stream with color-coded direction markers
17
+ via ProtocolMonitorService.
23
18
 
24
19
  Attributes:
25
- container: ServiceContainer for dependency injection.
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
- connection_state = reactive(ConnectionState.DISCONNECTED)
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
- container: ServiceContainer for resolving services.
29
+ service: ProtocolMonitorService instance for protocol operations.
39
30
  """
40
31
  super().__init__()
41
32
  self.border_title = "Protocol"
42
- self.container = container
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
- async def on_mount(self) -> None:
58
- """Initialize connection when widget mounts.
46
+ def on_mount(self) -> None:
47
+ """Initialize widget when mounted.
59
48
 
60
- Delays connection by 0.5s to let UI render first.
61
- Resolves ConbusReceiveService and connects signals.
49
+ Connects to service signals for telegram display.
62
50
  """
63
- # Resolve service from container (singleton)
64
- self.protocol = self.container.resolve(ConbusEventProtocol)
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 _on_connection_made(self) -> None:
135
- """Handle connection established signal.
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
- telegram: Sent telegram string.
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
- # Display [TX] and frame in bold bright green
180
- self.log_widget.write(f"[bold #00ff00]\\[TX] {telegram}[/bold #00ff00]")
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 and closes transport connection.
74
+ Disconnects signals from service.
286
75
  """
287
- if self.protocol is not None:
288
- try:
289
- # Disconnect all signals
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
- except Exception as e:
309
- self.logger.error(f"Error during cleanup: {e}")
80
+ except Exception as e:
81
+ self.logger.error(f"Error during cleanup: {e}")
@@ -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__(self, *args: Any, **kwargs: Any) -> None:
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 update_status(self, state: Any) -> None:
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