conson-xp 1.20.0__py3-none-any.whl → 1.22.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.20.0.dist-info → conson_xp-1.22.0.dist-info}/METADATA +32 -17
- {conson_xp-1.20.0.dist-info → conson_xp-1.22.0.dist-info}/RECORD +20 -14
- xp/__init__.py +1 -1
- xp/cli/commands/term/term_commands.py +1 -1
- xp/models/term/__init__.py +11 -0
- xp/models/term/protocol_keys_config.py +45 -0
- xp/services/protocol/conbus_event_protocol.py +8 -0
- xp/term/protocol.py +127 -0
- xp/term/protocol.tcss +135 -0
- xp/term/protocol.yml +139 -0
- xp/term/widgets/__init__.py +7 -0
- xp/term/widgets/help_menu.py +55 -0
- xp/{tui → term}/widgets/protocol_log.py +157 -76
- xp/term/widgets/status_footer.py +53 -0
- xp/utils/logging.py +16 -5
- xp/utils/state_machine.py +81 -0
- xp/tui/app.py +0 -72
- xp/tui/protocol.tcss +0 -50
- xp/tui/widgets/__init__.py +0 -1
- {conson_xp-1.20.0.dist-info → conson_xp-1.22.0.dist-info}/WHEEL +0 -0
- {conson_xp-1.20.0.dist-info → conson_xp-1.22.0.dist-info}/entry_points.txt +0 -0
- {conson_xp-1.20.0.dist-info → conson_xp-1.22.0.dist-info}/licenses/LICENSE +0 -0
- /xp/{tui → term}/__init__.py +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Help Menu Widget for displaying keyboard shortcuts and protocol keys."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.widgets import DataTable
|
|
8
|
+
|
|
9
|
+
from xp.models.term import ProtocolKeysConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HelpMenuWidget(Vertical):
|
|
13
|
+
"""Help menu widget displaying keyboard shortcuts and protocol keys.
|
|
14
|
+
|
|
15
|
+
Displays a table of available keyboard shortcuts mapped to their
|
|
16
|
+
corresponding protocol commands.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
protocol_keys: Configuration of protocol keys and their telegrams.
|
|
20
|
+
help_table: DataTable widget for displaying key mappings.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
protocol_keys: ProtocolKeysConfig,
|
|
26
|
+
*args: Any,
|
|
27
|
+
**kwargs: Any,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Initialize the Help Menu widget.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
protocol_keys: Configuration containing protocol key mappings.
|
|
33
|
+
args: Additional positional arguments for Vertical.
|
|
34
|
+
kwargs: Additional keyword arguments for Vertical.
|
|
35
|
+
"""
|
|
36
|
+
super().__init__(*args, **kwargs)
|
|
37
|
+
self.protocol_keys = protocol_keys
|
|
38
|
+
self.help_table: DataTable = DataTable(id="help-table", show_header=False)
|
|
39
|
+
self.help_table.can_focus = False
|
|
40
|
+
self.border_title = "Help menu"
|
|
41
|
+
self.can_focus = False
|
|
42
|
+
|
|
43
|
+
def compose(self) -> ComposeResult:
|
|
44
|
+
"""Compose the help menu layout.
|
|
45
|
+
|
|
46
|
+
Yields:
|
|
47
|
+
DataTable widget with key mappings.
|
|
48
|
+
"""
|
|
49
|
+
yield self.help_table
|
|
50
|
+
|
|
51
|
+
def on_mount(self) -> None:
|
|
52
|
+
"""Populate help table when widget mounts."""
|
|
53
|
+
self.help_table.add_columns("Key", "Command")
|
|
54
|
+
for key, config in self.protocol_keys.protocol.items():
|
|
55
|
+
self.help_table.add_row(key, config.name)
|
|
@@ -5,6 +5,7 @@ import logging
|
|
|
5
5
|
from enum import Enum
|
|
6
6
|
from typing import Any, Optional
|
|
7
7
|
|
|
8
|
+
from textual.message import Message
|
|
8
9
|
from textual.reactive import reactive
|
|
9
10
|
from textual.widget import Widget
|
|
10
11
|
from textual.widgets import RichLog
|
|
@@ -12,24 +13,62 @@ from textual.widgets import RichLog
|
|
|
12
13
|
from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
|
|
13
14
|
from xp.services.conbus.conbus_receive_service import ConbusReceiveService
|
|
14
15
|
from xp.services.protocol import ConbusEventProtocol
|
|
16
|
+
from xp.utils.state_machine import StateMachine
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
class ConnectionState(str, Enum):
|
|
18
20
|
"""Connection state enumeration.
|
|
19
21
|
|
|
20
22
|
Attributes:
|
|
23
|
+
DISCONNECTING: Disconnecting to server.
|
|
21
24
|
DISCONNECTED: Not connected to server.
|
|
22
25
|
CONNECTING: Connection in progress.
|
|
23
26
|
CONNECTED: Successfully connected.
|
|
24
27
|
FAILED: Connection failed.
|
|
25
28
|
"""
|
|
26
29
|
|
|
30
|
+
DISCONNECTING = "DISCONNECTING"
|
|
27
31
|
DISCONNECTED = "DISCONNECTED"
|
|
28
32
|
CONNECTING = "CONNECTING"
|
|
29
33
|
CONNECTED = "CONNECTED"
|
|
30
34
|
FAILED = "FAILED"
|
|
31
35
|
|
|
32
36
|
|
|
37
|
+
def create_connection_state_machine() -> StateMachine:
|
|
38
|
+
"""Create and configure state machine for connection management.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Configured StateMachine with connection state transitions.
|
|
42
|
+
"""
|
|
43
|
+
sm = StateMachine(ConnectionState.DISCONNECTED)
|
|
44
|
+
|
|
45
|
+
# Define valid transitions
|
|
46
|
+
sm.define_transition(
|
|
47
|
+
"connect", {ConnectionState.DISCONNECTED, ConnectionState.FAILED}
|
|
48
|
+
)
|
|
49
|
+
sm.define_transition(
|
|
50
|
+
"disconnect", {ConnectionState.CONNECTED, ConnectionState.CONNECTING}
|
|
51
|
+
)
|
|
52
|
+
sm.define_transition(
|
|
53
|
+
"connecting", {ConnectionState.DISCONNECTED, ConnectionState.FAILED}
|
|
54
|
+
)
|
|
55
|
+
sm.define_transition("connected", {ConnectionState.CONNECTING})
|
|
56
|
+
sm.define_transition(
|
|
57
|
+
"disconnecting", {ConnectionState.CONNECTED, ConnectionState.CONNECTING}
|
|
58
|
+
)
|
|
59
|
+
sm.define_transition("disconnected", {ConnectionState.DISCONNECTING})
|
|
60
|
+
sm.define_transition(
|
|
61
|
+
"failed",
|
|
62
|
+
{
|
|
63
|
+
ConnectionState.CONNECTING,
|
|
64
|
+
ConnectionState.CONNECTED,
|
|
65
|
+
ConnectionState.DISCONNECTING,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return sm
|
|
70
|
+
|
|
71
|
+
|
|
33
72
|
class ProtocolLogWidget(Widget):
|
|
34
73
|
"""Widget for displaying protocol telegram stream.
|
|
35
74
|
|
|
@@ -45,6 +84,18 @@ class ProtocolLogWidget(Widget):
|
|
|
45
84
|
log_widget: RichLog widget for displaying messages.
|
|
46
85
|
"""
|
|
47
86
|
|
|
87
|
+
class StatusMessageChanged(Message):
|
|
88
|
+
"""Message posted when status message changes."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, message: str) -> None:
|
|
91
|
+
"""Initialize the message.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
message: The status message to display.
|
|
95
|
+
"""
|
|
96
|
+
super().__init__()
|
|
97
|
+
self.message = message
|
|
98
|
+
|
|
48
99
|
connection_state = reactive(ConnectionState.DISCONNECTED)
|
|
49
100
|
|
|
50
101
|
def __init__(self, container: Any) -> None:
|
|
@@ -54,11 +105,13 @@ class ProtocolLogWidget(Widget):
|
|
|
54
105
|
container: ServiceContainer for resolving services.
|
|
55
106
|
"""
|
|
56
107
|
super().__init__()
|
|
108
|
+
self.border_title = "Protocol"
|
|
57
109
|
self.container = container
|
|
58
110
|
self.protocol: Optional[ConbusEventProtocol] = None
|
|
59
111
|
self.service: Optional[ConbusReceiveService] = None
|
|
60
112
|
self.logger = logging.getLogger(__name__)
|
|
61
113
|
self.log_widget: Optional[RichLog] = None
|
|
114
|
+
self._state_machine = create_connection_state_machine()
|
|
62
115
|
|
|
63
116
|
def compose(self) -> Any:
|
|
64
117
|
"""Compose the widget layout.
|
|
@@ -66,7 +119,7 @@ class ProtocolLogWidget(Widget):
|
|
|
66
119
|
Yields:
|
|
67
120
|
RichLog widget for message display.
|
|
68
121
|
"""
|
|
69
|
-
self.log_widget = RichLog(highlight=
|
|
122
|
+
self.log_widget = RichLog(highlight=False, markup=True)
|
|
70
123
|
yield self.log_widget
|
|
71
124
|
|
|
72
125
|
async def on_mount(self) -> None:
|
|
@@ -88,7 +141,7 @@ class ProtocolLogWidget(Widget):
|
|
|
88
141
|
|
|
89
142
|
# Delay connection to let UI render
|
|
90
143
|
await asyncio.sleep(0.5)
|
|
91
|
-
|
|
144
|
+
self._start_connection()
|
|
92
145
|
|
|
93
146
|
async def _start_connection_async(self) -> None:
|
|
94
147
|
"""Start TCP connection to Conbus server (async).
|
|
@@ -105,11 +158,22 @@ class ProtocolLogWidget(Widget):
|
|
|
105
158
|
self.logger.error("Protocol not initialized")
|
|
106
159
|
return
|
|
107
160
|
|
|
161
|
+
# Guard: Don't connect if already connected or connecting
|
|
162
|
+
if not self._state_machine.can_transition("connecting"):
|
|
163
|
+
self.logger.warning(
|
|
164
|
+
f"Already {self._state_machine.get_state().value}, ignoring connect request"
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
|
|
108
168
|
try:
|
|
109
|
-
#
|
|
110
|
-
self.
|
|
111
|
-
|
|
112
|
-
self.
|
|
169
|
+
# Transition to CONNECTING
|
|
170
|
+
if self._state_machine.transition("connecting", ConnectionState.CONNECTING):
|
|
171
|
+
self.connection_state = ConnectionState.CONNECTING
|
|
172
|
+
self.post_message(
|
|
173
|
+
self.StatusMessageChanged(
|
|
174
|
+
f"Connecting to {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}..."
|
|
175
|
+
)
|
|
176
|
+
)
|
|
113
177
|
|
|
114
178
|
# Store protocol reference
|
|
115
179
|
self.logger.info(f"Protocol object: {self.protocol}")
|
|
@@ -146,26 +210,11 @@ class ProtocolLogWidget(Widget):
|
|
|
146
210
|
)
|
|
147
211
|
|
|
148
212
|
reactor = self.service.conbus_protocol._reactor
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
self.logger.info("Executing connectTCP in event loop callback")
|
|
155
|
-
if self.protocol is not None:
|
|
156
|
-
reactor.connectTCP(
|
|
157
|
-
self.protocol.cli_config.ip,
|
|
158
|
-
self.protocol.cli_config.port,
|
|
159
|
-
self.protocol,
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
event_loop.call_soon(do_connect)
|
|
163
|
-
self.logger.info("Scheduled connectTCP on running loop")
|
|
164
|
-
|
|
165
|
-
if self.log_widget:
|
|
166
|
-
self.log_widget.write(
|
|
167
|
-
f"[dim]→ {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}[/dim]"
|
|
168
|
-
)
|
|
213
|
+
reactor.connectTCP(
|
|
214
|
+
self.protocol.cli_config.ip,
|
|
215
|
+
self.protocol.cli_config.port,
|
|
216
|
+
self.protocol,
|
|
217
|
+
)
|
|
169
218
|
|
|
170
219
|
# Wait for connection to establish
|
|
171
220
|
await asyncio.sleep(1.0)
|
|
@@ -173,15 +222,15 @@ class ProtocolLogWidget(Widget):
|
|
|
173
222
|
|
|
174
223
|
except Exception as e:
|
|
175
224
|
self.logger.error(f"Connection failed: {e}")
|
|
176
|
-
|
|
177
|
-
if self.
|
|
178
|
-
self.
|
|
179
|
-
|
|
180
|
-
self.set_timer(2.0, self.app.exit)
|
|
225
|
+
# Transition to FAILED
|
|
226
|
+
if self._state_machine.transition("failed", ConnectionState.FAILED):
|
|
227
|
+
self.connection_state = ConnectionState.FAILED
|
|
228
|
+
self.post_message(self.StatusMessageChanged(f"Connection error: {e}"))
|
|
181
229
|
|
|
182
230
|
def _start_connection(self) -> None:
|
|
183
231
|
"""Start connection (sync wrapper for async method)."""
|
|
184
232
|
# Use run_worker to run async method from sync context
|
|
233
|
+
self.logger.debug("Start connection")
|
|
185
234
|
self.run_worker(self._start_connection_async(), exclusive=True)
|
|
186
235
|
|
|
187
236
|
def _on_connection_made(self) -> None:
|
|
@@ -189,10 +238,16 @@ class ProtocolLogWidget(Widget):
|
|
|
189
238
|
|
|
190
239
|
Sets state to CONNECTED and displays success message.
|
|
191
240
|
"""
|
|
192
|
-
self.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
self.
|
|
241
|
+
self.logger.debug("Connection made")
|
|
242
|
+
# Transition to CONNECTED
|
|
243
|
+
if self._state_machine.transition("connected", ConnectionState.CONNECTED):
|
|
244
|
+
self.connection_state = ConnectionState.CONNECTED
|
|
245
|
+
if self.protocol:
|
|
246
|
+
self.post_message(
|
|
247
|
+
self.StatusMessageChanged(
|
|
248
|
+
f"Connected to {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}"
|
|
249
|
+
)
|
|
250
|
+
)
|
|
196
251
|
|
|
197
252
|
def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
|
|
198
253
|
"""Handle telegram received signal.
|
|
@@ -200,9 +255,10 @@ class ProtocolLogWidget(Widget):
|
|
|
200
255
|
Args:
|
|
201
256
|
event: Telegram received event with frame data.
|
|
202
257
|
"""
|
|
258
|
+
self.logger.debug("Telegram received")
|
|
203
259
|
if self.log_widget:
|
|
204
|
-
# Display [RX]
|
|
205
|
-
self.log_widget.write(f"[
|
|
260
|
+
# Display [RX] and frame in bright green
|
|
261
|
+
self.log_widget.write(f"[#00ff00]\\[RX] {event.frame}[/#00ff00]")
|
|
206
262
|
|
|
207
263
|
def _on_telegram_sent(self, telegram: str) -> None:
|
|
208
264
|
"""Handle telegram sent signal.
|
|
@@ -210,15 +266,17 @@ class ProtocolLogWidget(Widget):
|
|
|
210
266
|
Args:
|
|
211
267
|
telegram: Sent telegram string.
|
|
212
268
|
"""
|
|
269
|
+
self.logger.debug("Telegram sent")
|
|
213
270
|
if self.log_widget:
|
|
214
|
-
# Display [TX]
|
|
215
|
-
self.log_widget.write(f"[
|
|
271
|
+
# Display [TX] and frame in bold bright green
|
|
272
|
+
self.log_widget.write(f"[bold #00ff00]\\[TX] {telegram}[/bold #00ff00]")
|
|
216
273
|
|
|
217
274
|
def _on_timeout(self) -> None:
|
|
218
275
|
"""Handle timeout signal.
|
|
219
276
|
|
|
220
277
|
Logs timeout but continues monitoring (no action needed).
|
|
221
278
|
"""
|
|
279
|
+
self.logger.debug("Timeout")
|
|
222
280
|
self.logger.debug("Timeout occurred (continuous monitoring)")
|
|
223
281
|
|
|
224
282
|
def _on_failed(self, error: str) -> None:
|
|
@@ -227,60 +285,83 @@ class ProtocolLogWidget(Widget):
|
|
|
227
285
|
Args:
|
|
228
286
|
error: Error message describing the failure.
|
|
229
287
|
"""
|
|
230
|
-
|
|
231
|
-
self.
|
|
288
|
+
# Transition to FAILED
|
|
289
|
+
if self._state_machine.transition("failed", ConnectionState.FAILED):
|
|
290
|
+
self.connection_state = ConnectionState.FAILED
|
|
291
|
+
self.logger.error(f"Connection failed: {error}")
|
|
292
|
+
self.post_message(self.StatusMessageChanged(f"Failed: {error}"))
|
|
232
293
|
|
|
233
|
-
|
|
234
|
-
|
|
294
|
+
def connect(self) -> None:
|
|
295
|
+
"""Connect to Conbus server.
|
|
235
296
|
|
|
236
|
-
|
|
237
|
-
|
|
297
|
+
Only initiates connection if currently DISCONNECTED or FAILED.
|
|
298
|
+
"""
|
|
299
|
+
self.logger.debug("Connect")
|
|
300
|
+
|
|
301
|
+
# Guard: Check if connection is allowed
|
|
302
|
+
if not self._state_machine.can_transition("connect"):
|
|
303
|
+
self.logger.warning(
|
|
304
|
+
f"Cannot connect: current state is {self._state_machine.get_state().value}"
|
|
305
|
+
)
|
|
306
|
+
return
|
|
238
307
|
|
|
239
|
-
def connect(self) -> None:
|
|
240
|
-
"""Connect to Conbus server."""
|
|
241
308
|
self._start_connection()
|
|
242
309
|
|
|
243
310
|
def disconnect(self) -> None:
|
|
244
|
-
"""Disconnect from Conbus server.
|
|
311
|
+
"""Disconnect from Conbus server.
|
|
312
|
+
|
|
313
|
+
Only disconnects if currently CONNECTED or CONNECTING.
|
|
314
|
+
"""
|
|
315
|
+
self.logger.debug("Disconnect")
|
|
316
|
+
|
|
317
|
+
# Guard: Check if disconnection is allowed
|
|
318
|
+
if not self._state_machine.can_transition("disconnect"):
|
|
319
|
+
self.logger.warning(
|
|
320
|
+
f"Cannot disconnect: current state is {self._state_machine.get_state().value}"
|
|
321
|
+
)
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
# Transition to DISCONNECTING
|
|
325
|
+
if self._state_machine.transition(
|
|
326
|
+
"disconnecting", ConnectionState.DISCONNECTING
|
|
327
|
+
):
|
|
328
|
+
self.connection_state = ConnectionState.DISCONNECTING
|
|
329
|
+
self.post_message(self.StatusMessageChanged("Disconnecting..."))
|
|
330
|
+
|
|
245
331
|
if self.protocol:
|
|
246
332
|
self.protocol.disconnect()
|
|
247
333
|
|
|
248
|
-
|
|
249
|
-
""
|
|
334
|
+
# Transition to DISCONNECTED
|
|
335
|
+
if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
|
|
336
|
+
self.connection_state = ConnectionState.DISCONNECTED
|
|
337
|
+
self.post_message(self.StatusMessageChanged("Disconnected"))
|
|
338
|
+
|
|
339
|
+
def send_telegram(self, name: str, telegram: str) -> None:
|
|
340
|
+
"""Send a raw telegram string.
|
|
250
341
|
|
|
251
|
-
|
|
252
|
-
|
|
342
|
+
Args:
|
|
343
|
+
name: Telegram name (e.g., "Discover")
|
|
344
|
+
telegram: Telegram string including angle brackets (e.g., "S0000000000F01D00")
|
|
253
345
|
"""
|
|
254
346
|
if self.protocol is None:
|
|
255
|
-
self.logger.warning("Cannot send
|
|
256
|
-
if self.log_widget:
|
|
257
|
-
self.log_widget.write(
|
|
258
|
-
"[yellow]Not connected, cannot send discover[/yellow]"
|
|
259
|
-
)
|
|
347
|
+
self.logger.warning("Cannot send telegram: not connected")
|
|
260
348
|
return
|
|
261
349
|
|
|
262
350
|
try:
|
|
263
|
-
#
|
|
264
|
-
|
|
265
|
-
#
|
|
266
|
-
|
|
267
|
-
from xp.models.telegram.telegram_type import TelegramType
|
|
268
|
-
|
|
269
|
-
# Send discover: S 0000000000 F01 D00
|
|
270
|
-
self.protocol.send_telegram(
|
|
271
|
-
telegram_type=TelegramType.SYSTEM,
|
|
272
|
-
serial_number="0000000000",
|
|
273
|
-
system_function=SystemFunction.DISCOVERY,
|
|
274
|
-
data_value="00",
|
|
275
|
-
)
|
|
276
|
-
|
|
277
|
-
if self.log_widget:
|
|
278
|
-
self.log_widget.write("[yellow]Discover telegram sent[/yellow]")
|
|
351
|
+
# Remove angle brackets if present
|
|
352
|
+
self.post_message(self.StatusMessageChanged(f"Sending {name}..."))
|
|
353
|
+
# Send raw telegram
|
|
354
|
+
self.protocol.send_raw_telegram(telegram)
|
|
279
355
|
|
|
280
356
|
except Exception as e:
|
|
281
|
-
self.logger.error(f"Failed to send
|
|
282
|
-
|
|
283
|
-
|
|
357
|
+
self.logger.error(f"Failed to send telegram: {e}")
|
|
358
|
+
self.post_message(self.StatusMessageChanged(f"Failed: {e}"))
|
|
359
|
+
|
|
360
|
+
def clear_log(self) -> None:
|
|
361
|
+
"""Clear the protocol log widget."""
|
|
362
|
+
if self.log_widget:
|
|
363
|
+
self.log_widget.clear()
|
|
364
|
+
self.post_message(self.StatusMessageChanged("Log cleared"))
|
|
284
365
|
|
|
285
366
|
def on_unmount(self) -> None:
|
|
286
367
|
"""Clean up when widget unmounts.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Status Footer Widget for displaying app footer with connection status."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Horizontal
|
|
7
|
+
from textual.widgets import Footer, Static
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StatusFooterWidget(Horizontal):
|
|
11
|
+
"""Footer widget with connection status indicator.
|
|
12
|
+
|
|
13
|
+
Combines the Textual Footer with a status indicator dot that shows
|
|
14
|
+
the current connection state.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
status_widget: Static widget displaying colored status dot.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
21
|
+
"""Initialize the Status Footer widget.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
args: Additional positional arguments for Horizontal.
|
|
25
|
+
kwargs: Additional keyword arguments for Horizontal.
|
|
26
|
+
"""
|
|
27
|
+
super().__init__(*args, **kwargs)
|
|
28
|
+
self.status_widget: Static = Static("○", id="status-line")
|
|
29
|
+
|
|
30
|
+
def compose(self) -> ComposeResult:
|
|
31
|
+
"""Compose the footer layout.
|
|
32
|
+
|
|
33
|
+
Yields:
|
|
34
|
+
Footer and status indicator widgets.
|
|
35
|
+
"""
|
|
36
|
+
yield Footer()
|
|
37
|
+
yield self.status_widget
|
|
38
|
+
|
|
39
|
+
def update_status(self, state: Any) -> None:
|
|
40
|
+
"""Update status indicator with connection state.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
state: Current connection state (ConnectionState enum).
|
|
44
|
+
"""
|
|
45
|
+
# Map states to colored dots
|
|
46
|
+
dot = {
|
|
47
|
+
"CONNECTED": "[green]●[/green]",
|
|
48
|
+
"CONNECTING": "[yellow]●[/yellow]",
|
|
49
|
+
"DISCONNECTING": "[yellow]●[/yellow]",
|
|
50
|
+
"FAILED": "[red]●[/red]",
|
|
51
|
+
"DISCONNECTED": "○",
|
|
52
|
+
}.get(state.value, "○")
|
|
53
|
+
self.status_widget.update(dot)
|
xp/utils/logging.py
CHANGED
|
@@ -20,11 +20,22 @@ class LoggerService:
|
|
|
20
20
|
self.logger = logging.getLogger(__name__)
|
|
21
21
|
|
|
22
22
|
def setup(self) -> None:
|
|
23
|
-
"""Setup
|
|
24
|
-
# Setup file logging for term app
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
"""Setup file logging only with configured levels."""
|
|
24
|
+
# Setup file logging for term app (console logging disabled)
|
|
25
|
+
root_logger = logging.getLogger()
|
|
26
|
+
|
|
27
|
+
# Remove any existing console handlers
|
|
28
|
+
root_logger.handlers = [
|
|
29
|
+
h
|
|
30
|
+
for h in root_logger.handlers
|
|
31
|
+
if not isinstance(h, logging.StreamHandler)
|
|
32
|
+
or isinstance(h, RotatingFileHandler)
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Set root logger level
|
|
36
|
+
numeric_level = getattr(logging, self.logging_config.default_level.upper())
|
|
37
|
+
root_logger.setLevel(numeric_level)
|
|
38
|
+
|
|
28
39
|
self.setup_file_logging(
|
|
29
40
|
self.logging_config.log_format, self.logging_config.date_format
|
|
30
41
|
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Lightweight state machine utilities.
|
|
2
|
+
|
|
3
|
+
Provides simple, zero-dependency state machine implementation for
|
|
4
|
+
managing state transitions with validation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Set
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StateMachine:
|
|
12
|
+
"""Lightweight state machine for managing state transitions.
|
|
13
|
+
|
|
14
|
+
Enforces valid state transitions and prevents invalid operations.
|
|
15
|
+
Zero dependencies, suitable for any state-based logic.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
>>> from enum import Enum
|
|
19
|
+
>>> class State(str, Enum):
|
|
20
|
+
... IDLE = "IDLE"
|
|
21
|
+
... RUNNING = "RUNNING"
|
|
22
|
+
...
|
|
23
|
+
>>> sm = StateMachine(State.IDLE)
|
|
24
|
+
>>> sm.define_transition("start", {State.IDLE}, State.RUNNING)
|
|
25
|
+
>>> sm.can_transition("start") # True
|
|
26
|
+
>>> sm.transition("start", State.RUNNING) # True
|
|
27
|
+
>>> sm.get_state() # State.RUNNING
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, initial: Enum):
|
|
31
|
+
"""Initialize state machine.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
initial: Initial state (any Enum value).
|
|
35
|
+
"""
|
|
36
|
+
self.state = initial
|
|
37
|
+
self._valid_transitions: dict[str, Set[Enum]] = {}
|
|
38
|
+
|
|
39
|
+
def define_transition(self, action: str, valid_sources: Set[Enum]) -> None:
|
|
40
|
+
"""Define valid source states for an action.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
action: Action name (e.g., "connect", "disconnect").
|
|
44
|
+
valid_sources: Set of states from which action is valid.
|
|
45
|
+
"""
|
|
46
|
+
self._valid_transitions[action] = valid_sources
|
|
47
|
+
|
|
48
|
+
def can_transition(self, action: str) -> bool:
|
|
49
|
+
"""Check if action is valid from current state.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
action: Action to check (e.g., "connect", "disconnect").
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
True if action is valid from current state.
|
|
56
|
+
"""
|
|
57
|
+
valid_sources = self._valid_transitions.get(action, set())
|
|
58
|
+
return self.state in valid_sources
|
|
59
|
+
|
|
60
|
+
def transition(self, action: str, new_state: Enum) -> bool:
|
|
61
|
+
"""Attempt state transition.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
action: Action triggering the transition.
|
|
65
|
+
new_state: Target state.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
True if transition succeeded, False if invalid.
|
|
69
|
+
"""
|
|
70
|
+
if self.can_transition(action):
|
|
71
|
+
self.state = new_state
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
def get_state(self) -> Enum:
|
|
76
|
+
"""Get current state.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Current state as Enum value.
|
|
80
|
+
"""
|
|
81
|
+
return self.state
|
xp/tui/app.py
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
"""Protocol Monitor TUI Application."""
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from typing import Any, Optional
|
|
5
|
-
|
|
6
|
-
from textual.app import App, ComposeResult
|
|
7
|
-
from textual.widgets import Footer, Header
|
|
8
|
-
|
|
9
|
-
from xp.tui.widgets.protocol_log import ProtocolLogWidget
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class ProtocolMonitorApp(App[None]):
|
|
13
|
-
"""Textual app for real-time protocol monitoring.
|
|
14
|
-
|
|
15
|
-
Displays live RX/TX telegram stream from Conbus server in an interactive
|
|
16
|
-
terminal interface with keyboard shortcuts for control.
|
|
17
|
-
|
|
18
|
-
Attributes:
|
|
19
|
-
container: ServiceContainer for dependency injection.
|
|
20
|
-
CSS_PATH: Path to CSS stylesheet file.
|
|
21
|
-
BINDINGS: Keyboard bindings for app actions.
|
|
22
|
-
TITLE: Application title displayed in header.
|
|
23
|
-
"""
|
|
24
|
-
|
|
25
|
-
CSS_PATH = Path(__file__).parent / "protocol.tcss"
|
|
26
|
-
TITLE = "Protocol Monitor"
|
|
27
|
-
|
|
28
|
-
BINDINGS = [
|
|
29
|
-
("q", "quit", "Quit"),
|
|
30
|
-
("c", "connect", "Connect"),
|
|
31
|
-
("d", "disconnect", "Disconnect"),
|
|
32
|
-
("1", "discover", "Discover"),
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
def __init__(self, container: Any) -> None:
|
|
36
|
-
"""Initialize the Protocol Monitor app.
|
|
37
|
-
|
|
38
|
-
Args:
|
|
39
|
-
container: ServiceContainer for resolving services.
|
|
40
|
-
"""
|
|
41
|
-
super().__init__()
|
|
42
|
-
self.container = container
|
|
43
|
-
self.protocol_widget: Optional[ProtocolLogWidget] = None
|
|
44
|
-
|
|
45
|
-
def compose(self) -> ComposeResult:
|
|
46
|
-
"""Compose the app layout with widgets.
|
|
47
|
-
|
|
48
|
-
Yields:
|
|
49
|
-
Header, ProtocolLogWidget, and Footer widgets.
|
|
50
|
-
"""
|
|
51
|
-
yield Header()
|
|
52
|
-
self.protocol_widget = ProtocolLogWidget(container=self.container)
|
|
53
|
-
yield self.protocol_widget
|
|
54
|
-
yield Footer()
|
|
55
|
-
|
|
56
|
-
def action_discover(self) -> None:
|
|
57
|
-
"""Send discover telegram on 'D' key press.
|
|
58
|
-
|
|
59
|
-
Sends predefined discover telegram <S0000000000F01D00FA> to the bus.
|
|
60
|
-
"""
|
|
61
|
-
if self.protocol_widget:
|
|
62
|
-
self.protocol_widget.send_discover()
|
|
63
|
-
|
|
64
|
-
def action_connect(self) -> None:
|
|
65
|
-
"""Connect protocol on 'c' key press."""
|
|
66
|
-
if self.protocol_widget:
|
|
67
|
-
self.protocol_widget.connect()
|
|
68
|
-
|
|
69
|
-
def action_disconnect(self) -> None:
|
|
70
|
-
"""Disconnect protocol on 'd' key press."""
|
|
71
|
-
if self.protocol_widget:
|
|
72
|
-
self.protocol_widget.disconnect()
|