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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: conson-xp
3
- Version: 1.20.0
3
+ Version: 1.21.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.20.0.dist-info/METADATA,sha256=vAvQzvNII8d7oxEEbo1rSLyswX3lROWxh7cERQ-y3_Y,9584
2
- conson_xp-1.20.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
- conson_xp-1.20.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
- conson_xp-1.20.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
- xp/__init__.py,sha256=T304nC2FLiJmOCyBS3xq0x0Bb4Nv_XAMMRN4xZtqUec,181
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=3qwiGlEEgMIz5AG7fq5U_9SZSWSDfrKwYggHbiv5kRk,738
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=48KCTkJLDHV1ijVXHf0TraY663Nk3_dEV3lkZpvduDo,13671
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/tui/__init__.py,sha256=Xg2DhBeI3xQJLfc7_BPWI1por-rUXemyer5OtOt9Cus,51
177
- xp/tui/app.py,sha256=NBCdFCgftckey5TgxDFn-SeOLSFggapshIwKDcLM5QY,2227
178
- xp/tui/protocol.tcss,sha256=njMgFgz4oD4Qjw3dyoX1SfCuvlfGg6QrrkZ2COFZ0yM,768
179
- xp/tui/widgets/__init__.py,sha256=Ewiza9u6k5K50zZRIMD7jjOHY1IvGhoX1ViwlqhdGms,27
180
- xp/tui/widgets/protocol_log.py,sha256=0twJHiOTNcOOw3eQtYkjb1x37adecypPPPT1fuWb2q4,11541
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=5ol2JrnFtjs9QtBYW4KeKYzcFzCxbCn8BsIq3aIF4H4,3395
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.20.0.dist-info/RECORD,,
192
+ conson_xp-1.21.0.dist-info/RECORD,,
xp/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  conson-xp package.
4
4
  """
5
5
 
6
- __version__ = "1.20.0"
6
+ __version__ = "1.21.0"
7
7
  __manufacturer__ = "salchichon"
8
8
  __model__ = "xp.cli"
9
9
  __serial__ = "2025.09.23.000"
@@ -21,7 +21,7 @@ def protocol_monitor(ctx: Context) -> None:
21
21
  \b
22
22
  xp term protocol
23
23
  """
24
- from xp.tui.app import ProtocolMonitorApp
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,11 @@
1
+ """Terminal UI models."""
2
+
3
+ from xp.models.term.protocol_keys_config import (
4
+ ProtocolKeyConfig,
5
+ ProtocolKeysConfig,
6
+ )
7
+
8
+ __all__ = [
9
+ "ProtocolKeyConfig",
10
+ "ProtocolKeysConfig",
11
+ ]
@@ -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=True, markup=True)
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
- await self._start_connection_async()
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
- # Set state to connecting
110
- self.connection_state = ConnectionState.CONNECTING
111
- if self.log_widget:
112
- self.log_widget.write("[yellow]Connecting to Conbus server...[/yellow]")
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
- # Schedule the connection on the running asyncio loop
150
- # This ensures connectTCP is called in the context of the running loop
151
-
152
- def do_connect() -> None:
153
- """Execute TCP connection in event loop context."""
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
- self.connection_state = ConnectionState.FAILED
177
- if self.log_widget:
178
- self.log_widget.write(f"[red]Connection error: {e}[/red]")
179
- # Exit app after brief delay
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.connection_state = ConnectionState.CONNECTED
193
- if self.log_widget:
194
- self.log_widget.write("[green]Connected to Conbus server[/green]")
195
- self.log_widget.write("[dim]---[/dim]")
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] in green, frame in gray
205
- self.log_widget.write(f"[green]\\[RX][/green] [dim]{event.frame}[/dim]")
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] in green, frame in gray
215
- self.log_widget.write(f"[green]\\[TX][/green] [dim]{telegram}[/dim]")
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
- self.connection_state = ConnectionState.FAILED
231
- self.logger.error(f"Connection failed: {error}")
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
- if self.log_widget:
234
- self.log_widget.write(f"[red]Connection failed: {error}[/red]")
294
+ def connect(self) -> None:
295
+ """Connect to Conbus server.
235
296
 
236
- # Exit app after brief delay to show error
237
- self.set_timer(2.0, self.app.exit)
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
- def send_discover(self) -> None:
249
- """Send discover telegram.
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
- Sends predefined discover telegram <S0000000000F01D00FA> to the bus.
252
- Called when user presses 'd' key.
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 discover: not connected")
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
- # Send discover telegram
264
- # Note: The telegram includes framing <>, but protocol may add it
265
- # Check if protocol expects with or without brackets
266
- from xp.models.telegram.system_function import SystemFunction
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 discover: {e}")
282
- if self.log_widget:
283
- self.log_widget.write(f"[red]Failed to send discover: {e}[/red]")
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 console and file logging with configured levels."""
24
- # Setup file logging for term app
25
- self.setup_console_logging(
26
- self.logging_config.log_format, self.logging_config.date_format
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