conson-xp 1.20.0__py3-none-any.whl → 1.21.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.21.0.dist-info}/METADATA +1 -1
- {conson_xp-1.20.0.dist-info → conson_xp-1.21.0.dist-info}/RECORD +18 -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/app.py +158 -0
- xp/term/protocol.tcss +135 -0
- xp/term/protocol.yml +139 -0
- xp/{tui → term}/widgets/protocol_log.py +157 -76
- 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
- {conson_xp-1.20.0.dist-info → conson_xp-1.21.0.dist-info}/WHEEL +0 -0
- {conson_xp-1.20.0.dist-info → conson_xp-1.21.0.dist-info}/entry_points.txt +0 -0
- {conson_xp-1.20.0.dist-info → conson_xp-1.21.0.dist-info}/licenses/LICENSE +0 -0
- /xp/{tui → term}/__init__.py +0 -0
- /xp/{tui → term}/widgets/__init__.py +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.21.0.dist-info/METADATA,sha256=HK1EyLvZ5dYaq4J30bnPy8dCvOHt7Zg7eZEI_6yjAbk,9584
|
|
2
|
+
conson_xp-1.21.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
|
|
3
|
+
conson_xp-1.21.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
|
|
4
|
+
conson_xp-1.21.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
|
|
5
|
+
xp/__init__.py,sha256=XRUlxeaLq3T5GvQqiQm4B3VDGuqGInrFa511pFvemqI,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=5LwxOp1oHqki3iWPHXm6enPCF4De6uDg5N1QDVxjROs,739
|
|
46
46
|
xp/cli/main.py,sha256=ap5jU0DrSnrCKDKqGXcz9N-sngZodyyN-5ReWE8Fh1s,1817
|
|
47
47
|
xp/cli/utils/__init__.py,sha256=gTGIj60Uai0iE7sr9_TtEpl04fD7krtTzbbigXUsUVU,46
|
|
48
48
|
xp/cli/utils/click_tree.py,sha256=ilmM2IMa_c-TqUMsv2alrZXuS0BNhvVlrBlSfyN8lzM,1670
|
|
@@ -104,6 +104,8 @@ xp/models/telegram/system_telegram.py,sha256=9FNQ4Mf47mRK7wGrTg2GzziVsrEWCE5ZkZp
|
|
|
104
104
|
xp/models/telegram/telegram.py,sha256=IJUxHX6ftLcET9C1pjvLhUO5Db5JO6W7rUItzdEW30I,842
|
|
105
105
|
xp/models/telegram/telegram_type.py,sha256=GhqKP63oNMyh2tIvCPcsC5RFp4s4JjhmEqCLCC-8XMk,423
|
|
106
106
|
xp/models/telegram/timeparam_type.py,sha256=Ar8xvSfPmOAgR2g2Je0FgvP01SL7bPvZn5_HrVDpmJM,1137
|
|
107
|
+
xp/models/term/__init__.py,sha256=c1AMtVitYk80o9K_zWjYNzZYpFDASqM8S1Djm1PD4Qo,192
|
|
108
|
+
xp/models/term/protocol_keys_config.py,sha256=CTujcfI2_NOeltjvHy_cnsHzxLSVsGFXieMZlD-zj0Q,1204
|
|
107
109
|
xp/models/write_config_type.py,sha256=T2RaO52RpzoJ4782uMHE-fX7Ymx3CaIQAEwByydXq1M,881
|
|
108
110
|
xp/services/__init__.py,sha256=W9YZyrkh7vm--ZHhAXNQiOYQs5yhhmUHXP5I0Lf1XBg,782
|
|
109
111
|
xp/services/actiontable/__init__.py,sha256=z6js4EuJ6xKHaseTEhuEvKo1tr9K1XyQiruReJtBiPY,26
|
|
@@ -149,7 +151,7 @@ xp/services/homekit/homekit_service.py,sha256=0lW-hg40ETco3gDBEYkR_sX-UIYsLSKCD4
|
|
|
149
151
|
xp/services/log_file_service.py,sha256=fvPcZQj8XOKUA-4ep5R8n0PelvwvRlTLlVxvIWM5KR4,10506
|
|
150
152
|
xp/services/module_type_service.py,sha256=xWhr1EAZMykL5uNWHWdpa5T8yNruGKH43XRTOS8GwZg,7477
|
|
151
153
|
xp/services/protocol/__init__.py,sha256=qRufBmqRKGzpuzZ5bxBbmwf510TT00Ke8s5HcWGnqRY,818
|
|
152
|
-
xp/services/protocol/conbus_event_protocol.py,sha256=
|
|
154
|
+
xp/services/protocol/conbus_event_protocol.py,sha256=t_ovcLbwXays-y8u-EqFpDSfo2Xc_BNl3jAj9PqxRwg,13885
|
|
153
155
|
xp/services/protocol/conbus_protocol.py,sha256=JO7yLkD_ohPT0ETjnAIx4CGpZyobf4LdbuozM_43btE,10276
|
|
154
156
|
xp/services/protocol/protocol_factory.py,sha256=PmjN9AtW9sxNo3voqUiNgQA-pTvX1RW4XXFlHKfFr5Q,2470
|
|
155
157
|
xp/services/protocol/telegram_protocol.py,sha256=Ki5DrXsKxiaqLcdP9WWUuhUI7cPu2DfwyZkh-Gv9Lb8,9496
|
|
@@ -173,16 +175,18 @@ xp/services/telegram/telegram_link_number_service.py,sha256=1_c-_QCRPTHYn3BmMElr
|
|
|
173
175
|
xp/services/telegram/telegram_output_service.py,sha256=UaUv_14fR8o5K2PxQBXrCzx-Hohnk-gzbev_oLw_Clc,10799
|
|
174
176
|
xp/services/telegram/telegram_service.py,sha256=XrP1CPi0ckxoKBaNwLA6lo-TogWxXgmXDOsU4Xl8BlY,13237
|
|
175
177
|
xp/services/telegram/telegram_version_service.py,sha256=M5HdOTsLdcwo122FP-jW6R740ktLrtKf2TiMDVz23h8,10528
|
|
176
|
-
xp/
|
|
177
|
-
xp/
|
|
178
|
-
xp/
|
|
179
|
-
xp/
|
|
180
|
-
xp/
|
|
178
|
+
xp/term/__init__.py,sha256=Xg2DhBeI3xQJLfc7_BPWI1por-rUXemyer5OtOt9Cus,51
|
|
179
|
+
xp/term/app.py,sha256=vrC5VJbw1tlCvKnLR0n9NvGWaJx44yz0gG6k3--BaNk,5652
|
|
180
|
+
xp/term/protocol.tcss,sha256=biaIv6X-bmD1tbXENFUNsXOF8ABkuoRAUgeSDqwudtQ,2126
|
|
181
|
+
xp/term/protocol.yml,sha256=vPtTsMdasWiruj8iqqQX4f4ZtQdJ5wA39IJ-et3qiZc,2110
|
|
182
|
+
xp/term/widgets/__init__.py,sha256=Ewiza9u6k5K50zZRIMD7jjOHY1IvGhoX1ViwlqhdGms,27
|
|
183
|
+
xp/term/widgets/protocol_log.py,sha256=yuSfc61azgQmWQxLvBcmVik3cJAW1Hocj2Sk8qr3hSg,14343
|
|
181
184
|
xp/utils/__init__.py,sha256=_avMF_UOkfR3tNeDIPqQ5odmbq5raKkaq1rZ9Cn1CJs,332
|
|
182
185
|
xp/utils/checksum.py,sha256=HDpiQxmdIedbCbZ4o_Box0i_Zig417BtCV_46ZyhiTk,1711
|
|
183
186
|
xp/utils/dependencies.py,sha256=ECS6p0eXzocM5INLwJeckHXn_Dim18uOjXTJ29qQvkQ,22001
|
|
184
187
|
xp/utils/event_helper.py,sha256=W-A_xmoXlpWZBbJH6qdaN50o3-XrwFsDgvAGMJDiAgo,1001
|
|
185
|
-
xp/utils/logging.py,sha256=
|
|
188
|
+
xp/utils/logging.py,sha256=rZDXwlBrYK8A6MPq5StsMNpgsRowzJXM6fvROPwJdGM,3750
|
|
186
189
|
xp/utils/serialization.py,sha256=RWHHk86feaB4ZP7rjE4qOWK0900yg2joUBDkP76gfOY,4618
|
|
190
|
+
xp/utils/state_machine.py,sha256=Oe2sLwCh9z_vr1tF6X0ZRGTeuckRQAGzmef7xc9CNdc,2413
|
|
187
191
|
xp/utils/time_utils.py,sha256=dEyViDlAG9GWU-J3D_YVa-sGma6yiyyMTgN4h2x3PY4,3781
|
|
188
|
-
conson_xp-1.
|
|
192
|
+
conson_xp-1.21.0.dist-info/RECORD,,
|
xp/__init__.py
CHANGED
|
@@ -21,7 +21,7 @@ def protocol_monitor(ctx: Context) -> None:
|
|
|
21
21
|
\b
|
|
22
22
|
xp term protocol
|
|
23
23
|
"""
|
|
24
|
-
from xp.
|
|
24
|
+
from xp.term.app import ProtocolMonitorApp
|
|
25
25
|
|
|
26
26
|
# Resolve ServiceContainer from context
|
|
27
27
|
container = ctx.obj.get("container").get_container()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Protocol keys configuration model."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProtocolKeyConfig(BaseModel):
|
|
11
|
+
"""Configuration for a single protocol key.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
name: Human-readable command name.
|
|
15
|
+
telegrams: List of raw telegram strings to send (without angle brackets).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
name: str = Field(..., description="Human-readable command name")
|
|
19
|
+
telegrams: list[str] = Field(..., description="List of raw telegram strings")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProtocolKeysConfig(BaseModel):
|
|
23
|
+
"""Protocol keys configuration.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
protocol: Dictionary mapping key to protocol configuration.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
protocol: Dict[str, ProtocolKeyConfig] = Field(
|
|
30
|
+
default_factory=dict, description="Protocol key mappings"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_yaml(cls, config_path: Path) -> "ProtocolKeysConfig":
|
|
35
|
+
"""Load protocol keys from YAML file.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
config_path: Path to YAML configuration file.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
ProtocolKeysConfig instance.
|
|
42
|
+
"""
|
|
43
|
+
with config_path.open("r") as f:
|
|
44
|
+
data = yaml.safe_load(f)
|
|
45
|
+
return cls(**data)
|
|
@@ -209,6 +209,14 @@ class ConbusEventProtocol(protocol.Protocol, protocol.ClientFactory):
|
|
|
209
209
|
f"F{system_function.value}"
|
|
210
210
|
f"D{data_value}"
|
|
211
211
|
)
|
|
212
|
+
self.send_raw_telegram(payload)
|
|
213
|
+
|
|
214
|
+
def send_raw_telegram(self, payload: str) -> None:
|
|
215
|
+
"""Send telegram with specified parameters.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
payload: Telegram to send.
|
|
219
|
+
"""
|
|
212
220
|
self.telegram_queue.put_nowait(payload.encode())
|
|
213
221
|
self.call_later(0.0, self.start_queue_manager)
|
|
214
222
|
|
xp/term/app.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
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.containers import Horizontal, Vertical
|
|
8
|
+
from textual.widgets import DataTable, Footer, Static
|
|
9
|
+
|
|
10
|
+
from xp.models.term import ProtocolKeysConfig
|
|
11
|
+
from xp.term.widgets.protocol_log import ProtocolLogWidget
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProtocolMonitorApp(App[None]):
|
|
15
|
+
"""Textual app for real-time protocol monitoring.
|
|
16
|
+
|
|
17
|
+
Displays live RX/TX telegram stream from Conbus server in an interactive
|
|
18
|
+
terminal interface with keyboard shortcuts for control.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
container: ServiceContainer for dependency injection.
|
|
22
|
+
CSS_PATH: Path to CSS stylesheet file.
|
|
23
|
+
BINDINGS: Keyboard bindings for app actions.
|
|
24
|
+
TITLE: Application title displayed in header.
|
|
25
|
+
ENABLE_COMMAND_PALETTE: Disable Textual's command palette feature.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
CSS_PATH = Path(__file__).parent / "protocol.tcss"
|
|
29
|
+
TITLE = "Protocol Monitor"
|
|
30
|
+
ENABLE_COMMAND_PALETTE = False
|
|
31
|
+
|
|
32
|
+
BINDINGS = [
|
|
33
|
+
("Q", "quit", "Quit"),
|
|
34
|
+
("C", "toggle_connection", "Connect"),
|
|
35
|
+
("R", "reset", "Reset"),
|
|
36
|
+
("0-9,a-q", "protocol_keys", "Keys"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
def __init__(self, container: Any) -> None:
|
|
40
|
+
"""Initialize the Protocol Monitor app.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
container: ServiceContainer for resolving services.
|
|
44
|
+
"""
|
|
45
|
+
super().__init__()
|
|
46
|
+
self.container = container
|
|
47
|
+
self.protocol_widget: Optional[ProtocolLogWidget] = None
|
|
48
|
+
self.status_widget: Optional[Static] = None
|
|
49
|
+
self.status_text_widget: Optional[Static] = None
|
|
50
|
+
self.help_table: Optional[DataTable] = None
|
|
51
|
+
self.protocol_keys = self._load_protocol_keys()
|
|
52
|
+
|
|
53
|
+
def _load_protocol_keys(self) -> ProtocolKeysConfig:
|
|
54
|
+
"""Load protocol keys from YAML config file.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
ProtocolKeysConfig instance.
|
|
58
|
+
"""
|
|
59
|
+
config_path = Path(__file__).parent / "protocol.yml"
|
|
60
|
+
return ProtocolKeysConfig.from_yaml(config_path)
|
|
61
|
+
|
|
62
|
+
def compose(self) -> ComposeResult:
|
|
63
|
+
"""Compose the app layout with widgets.
|
|
64
|
+
|
|
65
|
+
Yields:
|
|
66
|
+
ProtocolLogWidget and Footer widgets.
|
|
67
|
+
"""
|
|
68
|
+
with Horizontal(id="main-container"):
|
|
69
|
+
self.protocol_widget = ProtocolLogWidget(container=self.container)
|
|
70
|
+
yield self.protocol_widget
|
|
71
|
+
|
|
72
|
+
# Help menu (hidden by default)
|
|
73
|
+
help_container = Vertical(id="help-menu")
|
|
74
|
+
help_container.border_title = "Help menu"
|
|
75
|
+
help_container.can_focus = False
|
|
76
|
+
with help_container:
|
|
77
|
+
self.help_table = DataTable(id="help-table", show_header=False)
|
|
78
|
+
self.help_table.can_focus = False
|
|
79
|
+
yield self.help_table
|
|
80
|
+
|
|
81
|
+
with Horizontal(id="footer-container"):
|
|
82
|
+
yield Footer()
|
|
83
|
+
self.status_widget = Static("○", id="status-line")
|
|
84
|
+
yield self.status_widget
|
|
85
|
+
|
|
86
|
+
def action_toggle_connection(self) -> None:
|
|
87
|
+
"""Toggle connection on 'c' key press.
|
|
88
|
+
|
|
89
|
+
Connects if disconnected/failed, disconnects if connected/connecting.
|
|
90
|
+
"""
|
|
91
|
+
if self.protocol_widget:
|
|
92
|
+
from xp.term.widgets.protocol_log import ConnectionState
|
|
93
|
+
|
|
94
|
+
state = self.protocol_widget.connection_state
|
|
95
|
+
if state in (ConnectionState.CONNECTED, ConnectionState.CONNECTING):
|
|
96
|
+
self.protocol_widget.disconnect()
|
|
97
|
+
else:
|
|
98
|
+
self.protocol_widget.connect()
|
|
99
|
+
|
|
100
|
+
def action_reset(self) -> None:
|
|
101
|
+
"""Reset and clear protocol widget on 'r' key press."""
|
|
102
|
+
if self.protocol_widget:
|
|
103
|
+
self.protocol_widget.clear_log()
|
|
104
|
+
|
|
105
|
+
def on_key(self, event: Any) -> None:
|
|
106
|
+
"""Handle key press events for protocol keys.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
event: Key press event from Textual.
|
|
110
|
+
"""
|
|
111
|
+
if event.key in self.protocol_keys.protocol and self.protocol_widget:
|
|
112
|
+
key_config = self.protocol_keys.protocol[event.key]
|
|
113
|
+
for telegram in key_config.telegrams:
|
|
114
|
+
self.protocol_widget.send_telegram(key_config.name, telegram)
|
|
115
|
+
|
|
116
|
+
def on_mount(self) -> None:
|
|
117
|
+
"""Set up status line updates when app mounts."""
|
|
118
|
+
if self.protocol_widget:
|
|
119
|
+
self.protocol_widget.watch(
|
|
120
|
+
self.protocol_widget,
|
|
121
|
+
"connection_state",
|
|
122
|
+
self._update_status,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Initialize help table
|
|
126
|
+
if self.help_table:
|
|
127
|
+
self.help_table.add_columns("Key", "Command")
|
|
128
|
+
for key, config in self.protocol_keys.protocol.items():
|
|
129
|
+
self.help_table.add_row(key, config.name)
|
|
130
|
+
|
|
131
|
+
def _update_status(self, state: Any) -> None:
|
|
132
|
+
"""Update status line with connection state.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
state: Current connection state.
|
|
136
|
+
"""
|
|
137
|
+
if self.status_widget:
|
|
138
|
+
# Map states to colored dots
|
|
139
|
+
status_map = {
|
|
140
|
+
"CONNECTED": "[green]●[/green]",
|
|
141
|
+
"CONNECTING": "[yellow]●[/yellow]",
|
|
142
|
+
"DISCONNECTING": "[yellow]●[/yellow]",
|
|
143
|
+
"FAILED": "[red]●[/red]",
|
|
144
|
+
"DISCONNECTED": "○",
|
|
145
|
+
}
|
|
146
|
+
dot = status_map.get(state.value, "○")
|
|
147
|
+
self.status_widget.update(dot)
|
|
148
|
+
|
|
149
|
+
def on_protocol_log_widget_status_message_changed(
|
|
150
|
+
self, message: ProtocolLogWidget.StatusMessageChanged
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Handle status message changes from protocol widget.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
message: Message containing the status text.
|
|
156
|
+
"""
|
|
157
|
+
if self.status_text_widget:
|
|
158
|
+
self.status_text_widget.update(message.message)
|
xp/term/protocol.tcss
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/* Protocol Monitor TUI Styling */
|
|
2
|
+
|
|
3
|
+
/* App-level styling */
|
|
4
|
+
Screen {
|
|
5
|
+
background: $background;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* Protocol Log Widget */
|
|
9
|
+
ProtocolLogWidget {
|
|
10
|
+
border: solid $success;
|
|
11
|
+
width: 1fr;
|
|
12
|
+
height: 1fr;
|
|
13
|
+
background: $background;
|
|
14
|
+
padding: 1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
ProtocolLogWidget > RichLog {
|
|
18
|
+
background: $background !important;
|
|
19
|
+
scrollbar-background: $background;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
ProtocolLogWidget > RichLog:focus {
|
|
23
|
+
background: $background !important;
|
|
24
|
+
background-tint: transparent;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
ProtocolLogWidget > .connection-status {
|
|
28
|
+
color: $text;
|
|
29
|
+
text-align: center;
|
|
30
|
+
padding: 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
ProtocolLogWidget > .connection-status.connecting {
|
|
34
|
+
color: $warning;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
ProtocolLogWidget > .connection-status.connected {
|
|
38
|
+
color: $success;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
ProtocolLogWidget > .connection-status.failed {
|
|
42
|
+
color: $error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Message display styling */
|
|
46
|
+
.message-tx {
|
|
47
|
+
color: $success;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.message-rx {
|
|
51
|
+
color: $success;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.message-frame {
|
|
55
|
+
color: $text-muted;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Main container */
|
|
59
|
+
#main-container {
|
|
60
|
+
height: 1fr;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Help menu styling */
|
|
64
|
+
#help-menu {
|
|
65
|
+
width: 35;
|
|
66
|
+
height: 1fr;
|
|
67
|
+
background: $background;
|
|
68
|
+
border: solid $success;
|
|
69
|
+
padding: 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#help-menu:focus {
|
|
73
|
+
border: solid $success;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#help-title {
|
|
77
|
+
color: $success;
|
|
78
|
+
text-align: center;
|
|
79
|
+
text-style: bold;
|
|
80
|
+
margin-bottom: 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#help-table {
|
|
84
|
+
background: $background;
|
|
85
|
+
height: auto;
|
|
86
|
+
color: $success;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
DataTable {
|
|
90
|
+
background: $background;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
DataTable > .datatable--header {
|
|
94
|
+
background: $background;
|
|
95
|
+
color: $success;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
DataTable > .datatable--cursor {
|
|
99
|
+
background: $background;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
DataTable:focus > .datatable--cursor {
|
|
103
|
+
background: $background;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* Footer styling */
|
|
107
|
+
#footer-container {
|
|
108
|
+
dock: bottom;
|
|
109
|
+
height: 1;
|
|
110
|
+
background: $background;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
Footer {
|
|
114
|
+
width: auto;
|
|
115
|
+
background: $background;
|
|
116
|
+
color: $text;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#status-text {
|
|
120
|
+
dock: right;
|
|
121
|
+
width: auto;
|
|
122
|
+
padding: 0 1;
|
|
123
|
+
background: $background;
|
|
124
|
+
color: $text;
|
|
125
|
+
text-align: right;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#status-line {
|
|
129
|
+
dock: right;
|
|
130
|
+
width: auto;
|
|
131
|
+
padding: 0 1;
|
|
132
|
+
background: $background;
|
|
133
|
+
color: $text;
|
|
134
|
+
text-align: right;
|
|
135
|
+
}
|
xp/term/protocol.yml
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
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: "All 1 On"
|
|
59
|
+
telegrams:
|
|
60
|
+
- E02L01I08M
|
|
61
|
+
- E02L01I08B
|
|
62
|
+
|
|
63
|
+
"e":
|
|
64
|
+
name: "All 1 Off"
|
|
65
|
+
telegrams:
|
|
66
|
+
- E02L01I00M
|
|
67
|
+
- E02L01I00B
|
|
68
|
+
|
|
69
|
+
"f":
|
|
70
|
+
name: "All 2 On"
|
|
71
|
+
telegrams:
|
|
72
|
+
- E02L02I08M
|
|
73
|
+
- E02L02I08B
|
|
74
|
+
|
|
75
|
+
"g":
|
|
76
|
+
name: "All 2 Off"
|
|
77
|
+
telegrams:
|
|
78
|
+
- E02L02I00M
|
|
79
|
+
- E02L02I00B
|
|
80
|
+
|
|
81
|
+
"h":
|
|
82
|
+
name: "All 3 On"
|
|
83
|
+
telegrams:
|
|
84
|
+
- E02L03I08M
|
|
85
|
+
- E02L03I08B
|
|
86
|
+
|
|
87
|
+
"i":
|
|
88
|
+
name: "All 3 Off"
|
|
89
|
+
telegrams:
|
|
90
|
+
- E02L03I00M
|
|
91
|
+
- E02L03I00B
|
|
92
|
+
|
|
93
|
+
"j":
|
|
94
|
+
name: "All 4 On"
|
|
95
|
+
telegrams:
|
|
96
|
+
- E02L04I08M
|
|
97
|
+
- E02L04I08B
|
|
98
|
+
|
|
99
|
+
"k":
|
|
100
|
+
name: "All 4 Off"
|
|
101
|
+
telegrams:
|
|
102
|
+
- E02L04I00M
|
|
103
|
+
- E02L04I00B
|
|
104
|
+
|
|
105
|
+
"l":
|
|
106
|
+
name: "All 5 On"
|
|
107
|
+
telegrams:
|
|
108
|
+
- E02L05I08M
|
|
109
|
+
- E02L05I08B
|
|
110
|
+
|
|
111
|
+
"m":
|
|
112
|
+
name: "All 5 Off"
|
|
113
|
+
telegrams:
|
|
114
|
+
- E02L05I00M
|
|
115
|
+
- E02L05I00B
|
|
116
|
+
|
|
117
|
+
"n":
|
|
118
|
+
name: "All 6 On"
|
|
119
|
+
telegrams:
|
|
120
|
+
- E02L06I08M
|
|
121
|
+
- E02L06I08B
|
|
122
|
+
|
|
123
|
+
"o":
|
|
124
|
+
name: "All 6 Off"
|
|
125
|
+
telegrams:
|
|
126
|
+
- E02L06I00M
|
|
127
|
+
- E02L06I00B
|
|
128
|
+
|
|
129
|
+
"p":
|
|
130
|
+
name: "All 7 On"
|
|
131
|
+
telegrams:
|
|
132
|
+
- E02L07I08M
|
|
133
|
+
- E02L07I08B
|
|
134
|
+
|
|
135
|
+
"q":
|
|
136
|
+
name: "All 7 Off"
|
|
137
|
+
telegrams:
|
|
138
|
+
- E02L07I00M
|
|
139
|
+
- E02L07I00B
|
|
@@ -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.
|
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()
|
xp/tui/protocol.tcss
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
/* Protocol Monitor TUI Styling */
|
|
2
|
-
|
|
3
|
-
/* App-level styling */
|
|
4
|
-
Screen {
|
|
5
|
-
background: $background;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/* Protocol Log Widget */
|
|
9
|
-
ProtocolLogWidget {
|
|
10
|
-
border: solid $primary;
|
|
11
|
-
height: 1fr;
|
|
12
|
-
background: $surface;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
ProtocolLogWidget > .connection-status {
|
|
16
|
-
color: $text;
|
|
17
|
-
text-align: center;
|
|
18
|
-
padding: 1;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
ProtocolLogWidget > .connection-status.connecting {
|
|
22
|
-
color: $warning;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
ProtocolLogWidget > .connection-status.connected {
|
|
26
|
-
color: $success;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
ProtocolLogWidget > .connection-status.failed {
|
|
30
|
-
color: $error;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/* Message display styling */
|
|
34
|
-
.message-tx {
|
|
35
|
-
color: $success;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
.message-rx {
|
|
39
|
-
color: $success;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
.message-frame {
|
|
43
|
-
color: $text-muted;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/* Footer styling */
|
|
47
|
-
Footer {
|
|
48
|
-
background: $panel;
|
|
49
|
-
color: $text;
|
|
50
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/xp/{tui → term}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|