conson-xp 1.21.0__py3-none-any.whl → 1.23.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.21.0
3
+ Version: 1.23.0
4
4
  Summary: XP Protocol Communication Tools
5
5
  Author-Email: ldvchosal <ldvchosal@github.com>
6
6
  License: MIT License
@@ -60,7 +60,7 @@ Description-Content-Type: text/markdown
60
60
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
61
61
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
62
62
 
63
- > **A powerful Python CLI and API toolkit for CONSON XP Protocol operations**
63
+ > **A powerful Python CLI toolkit for CONSON XP Protocol operations**
64
64
 
65
65
  Control and communicate with XP devices through console bus (Conbus), parse telegrams in real-time, and integrate with smart home systems like Apple HomeKit.
66
66
 
@@ -81,7 +81,7 @@ Bridge XP devices to Apple HomeKit for seamless smart home control
81
81
  Automatically discover XP servers and scan connected modules on your network
82
82
 
83
83
  ⚡ **Modern Architecture**
84
- FastAPI REST endpoints and comprehensive type safety
84
+ Comprehensive type safety and robust error handling
85
85
 
86
86
  ---
87
87
 
@@ -96,9 +96,6 @@ xp telegram parse "<E14L00I02MAK>"
96
96
 
97
97
  # Discover XP servers on your network
98
98
  xp conbus discover
99
-
100
- # Start the REST API server
101
- xp api start
102
99
  ```
103
100
 
104
101
  ## 📦 Installation
@@ -167,7 +164,32 @@ xp module search "push button"
167
164
  xp module list --group-by-category
168
165
  ```
169
166
 
167
+ ### 🖥️ Terminal UI (TUI)
168
+
169
+ **Real-time Protocol Monitor**
170
+
171
+ Launch an interactive terminal interface for live protocol monitoring and control:
170
172
 
173
+ ```bash
174
+ # Start the protocol monitor TUI
175
+ xp term protocol
176
+ ```
177
+
178
+ **Features:**
179
+ - 📊 **Live Telegram Stream**: Real-time RX/TX telegram monitoring from Conbus server
180
+ - ⌨️ **Keyboard Shortcuts**: Quick access controls for common operations
181
+ - `Q` - Quit application
182
+ - `C` - Toggle connection (connect/disconnect)
183
+ - `R` - Reset and clear log
184
+ - `0-9, a-q` - Send predefined protocol telegrams
185
+ - 🎨 **Visual Status Indicators**: Color-coded connection states
186
+ - 🟢 Green - Connected
187
+ - 🟡 Yellow - Connecting/Disconnecting
188
+ - 🔴 Red - Failed
189
+ - ⚪ White - Disconnected
190
+ - 📝 **Interactive Display**: Scrollable telegram log with detailed parsing information
191
+
192
+ The TUI provides a convenient way to monitor and interact with XP devices without juggling multiple terminal commands.
171
193
 
172
194
  ### 🔧 Advanced Features
173
195
 
@@ -201,13 +223,7 @@ xp checksum calculate "E14L00I02M" --algorithm crc32
201
223
  ```
202
224
  </details>
203
225
 
204
- ### 🌐 API & Integration
205
-
206
- **REST API Server**
207
- ```bash
208
- # Start API server with interactive docs at /docs
209
- xp api start
210
- ```
226
+ ### 🌐 Integration
211
227
 
212
228
  **HomeKit Smart Home Bridge**
213
229
  ```bash
@@ -232,7 +248,7 @@ xp reverse-proxy start
232
248
 
233
249
  **Layered Design**
234
250
  ```
235
- CLI Layer → API Layer → Services → Models → Connection Layer
251
+ CLI Layer → Services → Models → Connection Layer
236
252
  ```
237
253
 
238
254
  **Key Components**: Telegram processing • Real-time Conbus communication • HomeKit bridge • Multiple XP server support • Configuration management
@@ -256,9 +272,8 @@ pdm run check
256
272
  <details>
257
273
  <summary><b>Project Structure</b></summary>
258
274
 
259
- ```
275
+ ```
260
276
  src/xp/
261
- ├── api/ # FastAPI REST endpoints
262
277
  ├── cli/ # Command-line interface
263
278
  ├── models/ # Core data models
264
279
  ├── services/ # Business logic
@@ -407,7 +422,7 @@ xp term protocol
407
422
  ```
408
423
  </details>
409
424
 
410
- **Requirements**: Python 3.10+ • FastAPI • Pydantic • Click • HAP-python
425
+ **Requirements**: Python 3.10+ • Pydantic • Click • HAP-python
411
426
 
412
427
  ## License
413
428
 
@@ -1,8 +1,8 @@
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
1
+ conson_xp-1.23.0.dist-info/METADATA,sha256=z9DdbWCsagoeEX32rY76lsao7924etwx1cYvdWevwSg,10298
2
+ conson_xp-1.23.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
+ conson_xp-1.23.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
+ conson_xp-1.23.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
+ xp/__init__.py,sha256=a3NpXD1LnawKeJVM7klz-0Z1EzpYFllOS4ifHJUbU_4,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=5LwxOp1oHqki3iWPHXm6enPCF4De6uDg5N1QDVxjROs,739
45
+ xp/cli/commands/term/term_commands.py,sha256=ccBdvvyxjh2-cptmI9ohVIa02OOfG0dzO1JFb4KTowQ,744
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
@@ -105,7 +105,9 @@ xp/models/telegram/telegram.py,sha256=IJUxHX6ftLcET9C1pjvLhUO5Db5JO6W7rUItzdEW30
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
107
  xp/models/term/__init__.py,sha256=c1AMtVitYk80o9K_zWjYNzZYpFDASqM8S1Djm1PD4Qo,192
108
+ xp/models/term/connection_state.py,sha256=floDRMeMcfgMrYIVsyoVHBXHtxd3hqm-xOdr3oXtaHY,1793
108
109
  xp/models/term/protocol_keys_config.py,sha256=CTujcfI2_NOeltjvHy_cnsHzxLSVsGFXieMZlD-zj0Q,1204
110
+ xp/models/term/status_message.py,sha256=DOmzL0dbig5mP1UEoXdgzGT4UG2RyAXa_yRVo5c4x8w,394
109
111
  xp/models/write_config_type.py,sha256=T2RaO52RpzoJ4782uMHE-fX7Ymx3CaIQAEwByydXq1M,881
110
112
  xp/services/__init__.py,sha256=W9YZyrkh7vm--ZHhAXNQiOYQs5yhhmUHXP5I0Lf1XBg,782
111
113
  xp/services/actiontable/__init__.py,sha256=z6js4EuJ6xKHaseTEhuEvKo1tr9K1XyQiruReJtBiPY,26
@@ -151,7 +153,7 @@ xp/services/homekit/homekit_service.py,sha256=0lW-hg40ETco3gDBEYkR_sX-UIYsLSKCD4
151
153
  xp/services/log_file_service.py,sha256=fvPcZQj8XOKUA-4ep5R8n0PelvwvRlTLlVxvIWM5KR4,10506
152
154
  xp/services/module_type_service.py,sha256=xWhr1EAZMykL5uNWHWdpa5T8yNruGKH43XRTOS8GwZg,7477
153
155
  xp/services/protocol/__init__.py,sha256=qRufBmqRKGzpuzZ5bxBbmwf510TT00Ke8s5HcWGnqRY,818
154
- xp/services/protocol/conbus_event_protocol.py,sha256=t_ovcLbwXays-y8u-EqFpDSfo2Xc_BNl3jAj9PqxRwg,13885
156
+ xp/services/protocol/conbus_event_protocol.py,sha256=h9ZdnN9CWSBXQqE-M9qmPPbMT3r25cxzXuJsvURp1WQ,14390
155
157
  xp/services/protocol/conbus_protocol.py,sha256=JO7yLkD_ohPT0ETjnAIx4CGpZyobf4LdbuozM_43btE,10276
156
158
  xp/services/protocol/protocol_factory.py,sha256=PmjN9AtW9sxNo3voqUiNgQA-pTvX1RW4XXFlHKfFr5Q,2470
157
159
  xp/services/protocol/telegram_protocol.py,sha256=Ki5DrXsKxiaqLcdP9WWUuhUI7cPu2DfwyZkh-Gv9Lb8,9496
@@ -176,11 +178,13 @@ xp/services/telegram/telegram_output_service.py,sha256=UaUv_14fR8o5K2PxQBXrCzx-H
176
178
  xp/services/telegram/telegram_service.py,sha256=XrP1CPi0ckxoKBaNwLA6lo-TogWxXgmXDOsU4Xl8BlY,13237
177
179
  xp/services/telegram/telegram_version_service.py,sha256=M5HdOTsLdcwo122FP-jW6R740ktLrtKf2TiMDVz23h8,10528
178
180
  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
+ xp/term/protocol.py,sha256=acYYsv7_4z5ePrnslSC1exKzqbKOE5ZGds4J33Q2XNs,4784
182
+ xp/term/protocol.tcss,sha256=r_KfxrbpycGHLVXqZc6INBBcUJME0hLrAZkF1oqnab4,2126
183
+ xp/term/protocol.yml,sha256=kiTe_QSMPmLvLA0ZyIhNaDPwBdi6khh5C1NSR7I9TN0,2124
184
+ xp/term/widgets/__init__.py,sha256=ftWmN_fmjxy2E8Qfm-YSRmzKfgL0KTBCTpgvYWCPbUY,274
185
+ xp/term/widgets/help_menu.py,sha256=bdT5AYRdtKt_tvZTVbG7-DPMb1mj78kggtjjsa-95BA,1780
186
+ xp/term/widgets/protocol_log.py,sha256=OeVg9VCvIl9HXm6j_HPAGOIAYxO0MxlKcS9QmBEOKx4,13018
187
+ xp/term/widgets/status_footer.py,sha256=eRZHkrG5aZCMulibX56KfFyGHS8IgL-7psvr9f9S6FI,1992
184
188
  xp/utils/__init__.py,sha256=_avMF_UOkfR3tNeDIPqQ5odmbq5raKkaq1rZ9Cn1CJs,332
185
189
  xp/utils/checksum.py,sha256=HDpiQxmdIedbCbZ4o_Box0i_Zig417BtCV_46ZyhiTk,1711
186
190
  xp/utils/dependencies.py,sha256=ECS6p0eXzocM5INLwJeckHXn_Dim18uOjXTJ29qQvkQ,22001
@@ -189,4 +193,4 @@ xp/utils/logging.py,sha256=rZDXwlBrYK8A6MPq5StsMNpgsRowzJXM6fvROPwJdGM,3750
189
193
  xp/utils/serialization.py,sha256=RWHHk86feaB4ZP7rjE4qOWK0900yg2joUBDkP76gfOY,4618
190
194
  xp/utils/state_machine.py,sha256=Oe2sLwCh9z_vr1tF6X0ZRGTeuckRQAGzmef7xc9CNdc,2413
191
195
  xp/utils/time_utils.py,sha256=dEyViDlAG9GWU-J3D_YVa-sGma6yiyyMTgN4h2x3PY4,3781
192
- conson_xp-1.21.0.dist-info/RECORD,,
196
+ conson_xp-1.23.0.dist-info/RECORD,,
xp/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  conson-xp package.
4
4
  """
5
5
 
6
- __version__ = "1.21.0"
6
+ __version__ = "1.23.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.term.app import ProtocolMonitorApp
24
+ from xp.term.protocol import ProtocolMonitorApp
25
25
 
26
26
  # Resolve ServiceContainer from context
27
27
  container = ctx.obj.get("container").get_container()
@@ -0,0 +1,58 @@
1
+ """Connection state management module."""
2
+
3
+ from enum import Enum
4
+
5
+ from xp.utils.state_machine import StateMachine
6
+
7
+
8
+ class ConnectionState(str, Enum):
9
+ """Connection state enumeration.
10
+
11
+ Attributes:
12
+ DISCONNECTING: Disconnecting to server.
13
+ DISCONNECTED: Not connected to server.
14
+ CONNECTING: Connection in progress.
15
+ CONNECTED: Successfully connected.
16
+ FAILED: Connection failed.
17
+ """
18
+
19
+ DISCONNECTING = "DISCONNECTING"
20
+ DISCONNECTED = "DISCONNECTED"
21
+ CONNECTING = "CONNECTING"
22
+ CONNECTED = "CONNECTED"
23
+ FAILED = "FAILED"
24
+
25
+ @staticmethod
26
+ def create_state_machine() -> StateMachine:
27
+ """Create and configure state machine for connection management.
28
+
29
+ Returns:
30
+ Configured StateMachine with connection state transitions.
31
+ """
32
+ sm = StateMachine(ConnectionState.DISCONNECTED)
33
+
34
+ # Define valid transitions
35
+ sm.define_transition(
36
+ "connect", {ConnectionState.DISCONNECTED, ConnectionState.FAILED}
37
+ )
38
+ sm.define_transition(
39
+ "disconnect", {ConnectionState.CONNECTED, ConnectionState.CONNECTING}
40
+ )
41
+ sm.define_transition(
42
+ "connecting", {ConnectionState.DISCONNECTED, ConnectionState.FAILED}
43
+ )
44
+ sm.define_transition("connected", {ConnectionState.CONNECTING})
45
+ sm.define_transition(
46
+ "disconnecting", {ConnectionState.CONNECTED, ConnectionState.CONNECTING}
47
+ )
48
+ sm.define_transition("disconnected", {ConnectionState.DISCONNECTING})
49
+ sm.define_transition(
50
+ "failed",
51
+ {
52
+ ConnectionState.CONNECTING,
53
+ ConnectionState.CONNECTED,
54
+ ConnectionState.DISCONNECTING,
55
+ },
56
+ )
57
+
58
+ return sm
@@ -0,0 +1,16 @@
1
+ """Status message models for terminal UI."""
2
+
3
+ from textual.message import Message
4
+
5
+
6
+ class StatusMessageChanged(Message):
7
+ """Message posted when status message changes."""
8
+
9
+ def __init__(self, message: str) -> None:
10
+ """Initialize the message.
11
+
12
+ Args:
13
+ message: The status message to display.
14
+ """
15
+ super().__init__()
16
+ self.message = message
@@ -17,7 +17,7 @@ from twisted.internet.interfaces import IAddress, IConnector
17
17
  from twisted.internet.posixbase import PosixReactorBase
18
18
  from twisted.python.failure import Failure
19
19
 
20
- from xp.models import ConbusClientConfig
20
+ from xp.models import ConbusClientConfig, ModuleTypeCode
21
21
  from xp.models.protocol.conbus_protocol import (
22
22
  TelegramReceivedEvent,
23
23
  )
@@ -168,9 +168,6 @@ class ConbusEventProtocol(protocol.Protocol, protocol.ClientFactory):
168
168
 
169
169
  Args:
170
170
  data: Raw telegram payload (without checksum/framing).
171
-
172
- Raises:
173
- IOError: If transport is not open.
174
171
  """
175
172
  self.on_send_frame.emit(data)
176
173
 
@@ -180,8 +177,9 @@ class ConbusEventProtocol(protocol.Protocol, protocol.ClientFactory):
180
177
  frame = b"<" + frame_data.encode() + b">"
181
178
 
182
179
  if not self.transport:
183
- self.logger.info("Invalid transport")
184
- raise IOError("Transport is not open")
180
+ self.logger.info("Invalid transport, connection closed.")
181
+ self.on_connection_failed.emit(Failure("Invalid transport."))
182
+ return
185
183
 
186
184
  self.logger.debug(f"Sending frame: {frame.decode()}")
187
185
  self.transport.write(frame) # type: ignore
@@ -211,6 +209,21 @@ class ConbusEventProtocol(protocol.Protocol, protocol.ClientFactory):
211
209
  )
212
210
  self.send_raw_telegram(payload)
213
211
 
212
+ def send_event_telegram(
213
+ self, module_type_code: ModuleTypeCode, link_number: int, input_number: int
214
+ ) -> None:
215
+ """Send telegram with specified parameters.
216
+
217
+ Args:
218
+ module_type_code: Type code of module.
219
+ link_number: Link number.
220
+ input_number: Input number.
221
+ """
222
+ payload = (
223
+ f"E" f"{module_type_code}" f"L{link_number:02d}" f"I{input_number:02d}"
224
+ )
225
+ self.send_raw_telegram(payload)
226
+
214
227
  def send_raw_telegram(self, payload: str) -> None:
215
228
  """Send telegram with specified parameters.
216
229
 
@@ -4,11 +4,13 @@ from pathlib import Path
4
4
  from typing import Any, Optional
5
5
 
6
6
  from textual.app import App, ComposeResult
7
- from textual.containers import Horizontal, Vertical
8
- from textual.widgets import DataTable, Footer, Static
7
+ from textual.containers import Horizontal
9
8
 
10
9
  from xp.models.term import ProtocolKeysConfig
10
+ from xp.models.term.status_message import StatusMessageChanged
11
+ from xp.term.widgets.help_menu import HelpMenuWidget
11
12
  from xp.term.widgets.protocol_log import ProtocolLogWidget
13
+ from xp.term.widgets.status_footer import StatusFooterWidget
12
14
 
13
15
 
14
16
  class ProtocolMonitorApp(App[None]):
@@ -45,9 +47,8 @@ class ProtocolMonitorApp(App[None]):
45
47
  super().__init__()
46
48
  self.container = container
47
49
  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
50
+ self.help_menu: Optional[HelpMenuWidget] = None
51
+ self.footer_widget: Optional[StatusFooterWidget] = None
51
52
  self.protocol_keys = self._load_protocol_keys()
52
53
 
53
54
  def _load_protocol_keys(self) -> ProtocolKeysConfig:
@@ -70,18 +71,13 @@ class ProtocolMonitorApp(App[None]):
70
71
  yield self.protocol_widget
71
72
 
72
73
  # 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
74
+ self.help_menu = HelpMenuWidget(
75
+ protocol_keys=self.protocol_keys, id="help-menu"
76
+ )
77
+ yield self.help_menu
78
+
79
+ self.footer_widget = StatusFooterWidget(id="footer-container")
80
+ yield self.footer_widget
85
81
 
86
82
  def action_toggle_connection(self) -> None:
87
83
  """Toggle connection on 'c' key press.
@@ -122,37 +118,20 @@ class ProtocolMonitorApp(App[None]):
122
118
  self._update_status,
123
119
  )
124
120
 
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
121
  def _update_status(self, state: Any) -> None:
132
122
  """Update status line with connection state.
133
123
 
134
124
  Args:
135
125
  state: Current connection state.
136
126
  """
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:
127
+ if self.footer_widget:
128
+ self.footer_widget.update_status(state)
129
+
130
+ def on_status_message_changed(self, message: StatusMessageChanged) -> None:
152
131
  """Handle status message changes from protocol widget.
153
132
 
154
133
  Args:
155
134
  message: Message containing the status text.
156
135
  """
157
- if self.status_text_widget:
158
- self.status_text_widget.update(message.message)
136
+ if self.footer_widget:
137
+ self.footer_widget.update_message(message.message)
xp/term/protocol.tcss CHANGED
@@ -119,7 +119,7 @@ Footer {
119
119
  #status-text {
120
120
  dock: right;
121
121
  width: auto;
122
- padding: 0 1;
122
+ padding: 0 3;
123
123
  background: $background;
124
124
  color: $text;
125
125
  text-align: right;
xp/term/protocol.yml CHANGED
@@ -55,85 +55,85 @@ protocol:
55
55
  - E02L00I08B
56
56
 
57
57
  "d":
58
- name: "All 1 On"
58
+ name: "Link 1 On"
59
59
  telegrams:
60
60
  - E02L01I08M
61
61
  - E02L01I08B
62
62
 
63
63
  "e":
64
- name: "All 1 Off"
64
+ name: "Link 1 Off"
65
65
  telegrams:
66
66
  - E02L01I00M
67
67
  - E02L01I00B
68
68
 
69
69
  "f":
70
- name: "All 2 On"
70
+ name: "Link 2 On"
71
71
  telegrams:
72
72
  - E02L02I08M
73
73
  - E02L02I08B
74
74
 
75
75
  "g":
76
- name: "All 2 Off"
76
+ name: "Link 2 Off"
77
77
  telegrams:
78
78
  - E02L02I00M
79
79
  - E02L02I00B
80
80
 
81
81
  "h":
82
- name: "All 3 On"
82
+ name: "Link 3 On"
83
83
  telegrams:
84
84
  - E02L03I08M
85
85
  - E02L03I08B
86
86
 
87
87
  "i":
88
- name: "All 3 Off"
88
+ name: "Link 3 Off"
89
89
  telegrams:
90
90
  - E02L03I00M
91
91
  - E02L03I00B
92
92
 
93
93
  "j":
94
- name: "All 4 On"
94
+ name: "Link 4 On"
95
95
  telegrams:
96
96
  - E02L04I08M
97
97
  - E02L04I08B
98
98
 
99
99
  "k":
100
- name: "All 4 Off"
100
+ name: "Link 4 Off"
101
101
  telegrams:
102
102
  - E02L04I00M
103
103
  - E02L04I00B
104
104
 
105
105
  "l":
106
- name: "All 5 On"
106
+ name: "Link 5 On"
107
107
  telegrams:
108
108
  - E02L05I08M
109
109
  - E02L05I08B
110
110
 
111
111
  "m":
112
- name: "All 5 Off"
112
+ name: "Link 5 Off"
113
113
  telegrams:
114
114
  - E02L05I00M
115
115
  - E02L05I00B
116
116
 
117
117
  "n":
118
- name: "All 6 On"
118
+ name: "Link 6 On"
119
119
  telegrams:
120
120
  - E02L06I08M
121
121
  - E02L06I08B
122
122
 
123
123
  "o":
124
- name: "All 6 Off"
124
+ name: "Link 6 Off"
125
125
  telegrams:
126
126
  - E02L06I00M
127
127
  - E02L06I00B
128
128
 
129
129
  "p":
130
- name: "All 7 On"
130
+ name: "Link 7 On"
131
131
  telegrams:
132
132
  - E02L07I08M
133
133
  - E02L07I08B
134
134
 
135
135
  "q":
136
- name: "All 7 Off"
136
+ name: "Link 7 Off"
137
137
  telegrams:
138
138
  - E02L07I00M
139
139
  - E02L07I00B
@@ -1 +1,7 @@
1
1
  """TUI widgets package."""
2
+
3
+ from xp.term.widgets.help_menu import HelpMenuWidget
4
+ from xp.term.widgets.protocol_log import ProtocolLogWidget
5
+ from xp.term.widgets.status_footer import StatusFooterWidget
6
+
7
+ __all__ = ["HelpMenuWidget", "ProtocolLogWidget", "StatusFooterWidget"]
@@ -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)
@@ -2,71 +2,18 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- from enum import Enum
6
5
  from typing import Any, Optional
7
6
 
8
- from textual.message import Message
9
7
  from textual.reactive import reactive
10
8
  from textual.widget import Widget
11
9
  from textual.widgets import RichLog
10
+ from twisted.python.failure import Failure
12
11
 
13
12
  from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
13
+ from xp.models.term.connection_state import ConnectionState
14
+ from xp.models.term.status_message import StatusMessageChanged
14
15
  from xp.services.conbus.conbus_receive_service import ConbusReceiveService
15
16
  from xp.services.protocol import ConbusEventProtocol
16
- from xp.utils.state_machine import StateMachine
17
-
18
-
19
- class ConnectionState(str, Enum):
20
- """Connection state enumeration.
21
-
22
- Attributes:
23
- DISCONNECTING: Disconnecting to server.
24
- DISCONNECTED: Not connected to server.
25
- CONNECTING: Connection in progress.
26
- CONNECTED: Successfully connected.
27
- FAILED: Connection failed.
28
- """
29
-
30
- DISCONNECTING = "DISCONNECTING"
31
- DISCONNECTED = "DISCONNECTED"
32
- CONNECTING = "CONNECTING"
33
- CONNECTED = "CONNECTED"
34
- FAILED = "FAILED"
35
-
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
17
 
71
18
 
72
19
  class ProtocolLogWidget(Widget):
@@ -84,18 +31,6 @@ class ProtocolLogWidget(Widget):
84
31
  log_widget: RichLog widget for displaying messages.
85
32
  """
86
33
 
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
-
99
34
  connection_state = reactive(ConnectionState.DISCONNECTED)
100
35
 
101
36
  def __init__(self, container: Any) -> None:
@@ -111,7 +46,7 @@ class ProtocolLogWidget(Widget):
111
46
  self.service: Optional[ConbusReceiveService] = None
112
47
  self.logger = logging.getLogger(__name__)
113
48
  self.log_widget: Optional[RichLog] = None
114
- self._state_machine = create_connection_state_machine()
49
+ self._state_machine = ConnectionState.create_state_machine()
115
50
 
116
51
  def compose(self) -> Any:
117
52
  """Compose the widget layout.
@@ -134,6 +69,7 @@ class ProtocolLogWidget(Widget):
134
69
 
135
70
  # Connect psygnal signals
136
71
  self.protocol.on_connection_made.connect(self._on_connection_made)
72
+ self.protocol.on_connection_failed.connect(self._on_connection_failed)
137
73
  self.protocol.on_telegram_received.connect(self._on_telegram_received)
138
74
  self.protocol.on_telegram_sent.connect(self._on_telegram_sent)
139
75
  self.protocol.on_timeout.connect(self._on_timeout)
@@ -169,10 +105,8 @@ class ProtocolLogWidget(Widget):
169
105
  # Transition to CONNECTING
170
106
  if self._state_machine.transition("connecting", ConnectionState.CONNECTING):
171
107
  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
- )
108
+ self.post_status(
109
+ f"Connecting to {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}..."
176
110
  )
177
111
 
178
112
  # Store protocol reference
@@ -222,15 +156,16 @@ class ProtocolLogWidget(Widget):
222
156
 
223
157
  except Exception as e:
224
158
  self.logger.error(f"Connection failed: {e}")
159
+ self.post_status(f"Connection failed: {e}")
225
160
  # Transition to FAILED
226
161
  if self._state_machine.transition("failed", ConnectionState.FAILED):
227
162
  self.connection_state = ConnectionState.FAILED
228
- self.post_message(self.StatusMessageChanged(f"Connection error: {e}"))
229
163
 
230
164
  def _start_connection(self) -> None:
231
165
  """Start connection (sync wrapper for async method)."""
232
166
  # Use run_worker to run async method from sync context
233
167
  self.logger.debug("Start connection")
168
+ self.post_status("Start connection")
234
169
  self.run_worker(self._start_connection_async(), exclusive=True)
235
170
 
236
171
  def _on_connection_made(self) -> None:
@@ -239,16 +174,26 @@ class ProtocolLogWidget(Widget):
239
174
  Sets state to CONNECTED and displays success message.
240
175
  """
241
176
  self.logger.debug("Connection made")
177
+ self.post_status("Connection made")
242
178
  # Transition to CONNECTED
243
179
  if self._state_machine.transition("connected", ConnectionState.CONNECTED):
244
180
  self.connection_state = ConnectionState.CONNECTED
245
181
  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
- )
182
+ self.post_status(
183
+ f"Connected to {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}"
250
184
  )
251
185
 
186
+ def _on_connection_failed(self, failure: Failure) -> None:
187
+ """Handle connection failed signal.
188
+
189
+ Sets state to DISCONNECTED and displays success message.
190
+ """
191
+ self.logger.debug("Connection failed")
192
+ self.post_status(failure.getErrorMessage())
193
+ # Transition to CONNECTED
194
+ if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
195
+ self.connection_state = ConnectionState.DISCONNECTED
196
+
252
197
  def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
253
198
  """Handle telegram received signal.
254
199
 
@@ -289,7 +234,15 @@ class ProtocolLogWidget(Widget):
289
234
  if self._state_machine.transition("failed", ConnectionState.FAILED):
290
235
  self.connection_state = ConnectionState.FAILED
291
236
  self.logger.error(f"Connection failed: {error}")
292
- self.post_message(self.StatusMessageChanged(f"Failed: {error}"))
237
+ self.post_status(f"Failed: {error}")
238
+
239
+ def post_status(self, message: str) -> None:
240
+ """Post status message.
241
+
242
+ Args:
243
+ message: message to be sent to status bar.
244
+ """
245
+ self.post_message(StatusMessageChanged(message))
293
246
 
294
247
  def connect(self) -> None:
295
248
  """Connect to Conbus server.
@@ -326,7 +279,7 @@ class ProtocolLogWidget(Widget):
326
279
  "disconnecting", ConnectionState.DISCONNECTING
327
280
  ):
328
281
  self.connection_state = ConnectionState.DISCONNECTING
329
- self.post_message(self.StatusMessageChanged("Disconnecting..."))
282
+ self.post_status("Disconnecting...")
330
283
 
331
284
  if self.protocol:
332
285
  self.protocol.disconnect()
@@ -334,7 +287,7 @@ class ProtocolLogWidget(Widget):
334
287
  # Transition to DISCONNECTED
335
288
  if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
336
289
  self.connection_state = ConnectionState.DISCONNECTED
337
- self.post_message(self.StatusMessageChanged("Disconnected"))
290
+ self.post_status("Disconnected")
338
291
 
339
292
  def send_telegram(self, name: str, telegram: str) -> None:
340
293
  """Send a raw telegram string.
@@ -349,19 +302,19 @@ class ProtocolLogWidget(Widget):
349
302
 
350
303
  try:
351
304
  # Remove angle brackets if present
352
- self.post_message(self.StatusMessageChanged(f"Sending {name}..."))
305
+ self.post_status(f"{name} sent.")
353
306
  # Send raw telegram
354
307
  self.protocol.send_raw_telegram(telegram)
355
308
 
356
309
  except Exception as e:
357
310
  self.logger.error(f"Failed to send telegram: {e}")
358
- self.post_message(self.StatusMessageChanged(f"Failed: {e}"))
311
+ self.post_status(f"Failed: {e}")
359
312
 
360
313
  def clear_log(self) -> None:
361
314
  """Clear the protocol log widget."""
362
315
  if self.log_widget:
363
316
  self.log_widget.clear()
364
- self.post_message(self.StatusMessageChanged("Log cleared"))
317
+ self.post_status("Log cleared")
365
318
 
366
319
  def on_unmount(self) -> None:
367
320
  """Clean up when widget unmounts.
@@ -0,0 +1,63 @@
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_text_widget: Static = Static("", id="status-text")
29
+ self.status_widget: Static = Static("○", id="status-line")
30
+
31
+ def compose(self) -> ComposeResult:
32
+ """Compose the footer layout.
33
+
34
+ Yields:
35
+ Footer and status indicator widgets.
36
+ """
37
+ yield Footer()
38
+ yield self.status_text_widget
39
+ yield self.status_widget
40
+
41
+ def update_status(self, state: Any) -> None:
42
+ """Update status indicator with connection state.
43
+
44
+ Args:
45
+ state: Current connection state (ConnectionState enum).
46
+ """
47
+ # Map states to colored dots
48
+ dot = {
49
+ "CONNECTED": "[green]●[/green]",
50
+ "CONNECTING": "[yellow]●[/yellow]",
51
+ "DISCONNECTING": "[yellow]●[/yellow]",
52
+ "FAILED": "[red]●[/red]",
53
+ "DISCONNECTED": "○",
54
+ }.get(state.value, "○")
55
+ self.status_widget.update(dot)
56
+
57
+ def update_message(self, message: str) -> None:
58
+ """Update status text with message.
59
+
60
+ Args:
61
+ message: Status message to display.
62
+ """
63
+ self.status_text_widget.update(message)