conson-xp 1.22.0__py3-none-any.whl → 1.24.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.22.0
3
+ Version: 1.24.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.22.0.dist-info/METADATA,sha256=SHz732F4Z0CbbWIukCpttstkgiNLFBgqYAZQg94OkEk,10298
2
- conson_xp-1.22.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
- conson_xp-1.22.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
- conson_xp-1.22.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
- xp/__init__.py,sha256=cGDFz-p2G9nzESEviCX7b8voSRj-fYSsRjnhBPsDwSE,181
1
+ conson_xp-1.24.0.dist-info/METADATA,sha256=OIqtn9P69KMHIOn2EV8vgrHgUSQRQnFd1VGMFOU5sdE,10298
2
+ conson_xp-1.24.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
+ conson_xp-1.24.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
+ conson_xp-1.24.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
+ xp/__init__.py,sha256=WnwD09XucegEUWyg8Au3w-_Cz33wTXvYu4cW_mzxpmE,181
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
@@ -14,7 +14,7 @@ xp/cli/commands/conbus/conbus_blink_commands.py,sha256=UK-Ey4K0FvaPQ96U0gyMid236
14
14
  xp/cli/commands/conbus/conbus_config_commands.py,sha256=BugIbgNX6_s4MySGvI6tWZkwguciajHUX2Xz8XBux7k,716
15
15
  xp/cli/commands/conbus/conbus_custom_commands.py,sha256=lICT93ijMdhVRm8KjNMLo7kQ2BLlnOZvMPbR3SxSmZ4,1692
16
16
  xp/cli/commands/conbus/conbus_datapoint_commands.py,sha256=r36OuTjREtbGKL-bskAGa0-WLw7x06td6woZn3GYJNA,3630
17
- xp/cli/commands/conbus/conbus_discover_commands.py,sha256=-y3TDgOnw1_cjvxvgyfQ1GQE2_WmYq-l8Md7DsdTXmo,1719
17
+ xp/cli/commands/conbus/conbus_discover_commands.py,sha256=MnTCzvERO5xerfs0fuuIBoo1O9h_0IfoJ6snLGVl0lA,1899
18
18
  xp/cli/commands/conbus/conbus_event_commands.py,sha256=7URf-2u8Kzcy0chLYShbZfCbKawf--i-8U88AjhxleQ,3177
19
19
  xp/cli/commands/conbus/conbus_lightlevel_commands.py,sha256=FpCwogdxa7yFUjlrxM7e8Q2Ut32tKAHabngQQChvtJI,6763
20
20
  xp/cli/commands/conbus/conbus_linknumber_commands.py,sha256=KitaGDM5HpwVUz8rLpO8VZUypUTcAg3Bzl0DVm6gnSk,3391
@@ -22,7 +22,7 @@ xp/cli/commands/conbus/conbus_modulenumber_commands.py,sha256=L7-6y3rDllOjQ9g6Bk
22
22
  xp/cli/commands/conbus/conbus_msactiontable_commands.py,sha256=fb9MQ4O04H0Dinpt7vSF5GtfntTZHelQ5TuUmSBbCTg,2899
23
23
  xp/cli/commands/conbus/conbus_output_commands.py,sha256=zdRVbHzVhMbZpG2x5WXtujc3wKTsoQUV4IgkVIbJbCc,5019
24
24
  xp/cli/commands/conbus/conbus_raw_commands.py,sha256=8BKUarwvHgz-sxML7n99YVsb8B1HJNExjQpRsuY_tQw,1829
25
- xp/cli/commands/conbus/conbus_receive_commands.py,sha256=2lZP0a3dte3Q_Vp28xYkqLAoxnvArS9SsQdeedOHcQw,1788
25
+ xp/cli/commands/conbus/conbus_receive_commands.py,sha256=_PsC-3xidmJBuOWUS60iDzhSHYYn5ZFmORXap-ljVGM,1902
26
26
  xp/cli/commands/conbus/conbus_scan_commands.py,sha256=JfXucOwOadvLEKT_fW9fwvqWKHaEODOojLjnO8JV_00,1730
27
27
  xp/cli/commands/file_commands.py,sha256=GV102X7FRZDUNKLlzvSsIGcoXAaofOzmjCp3HUpE9lw,5532
28
28
  xp/cli/commands/homekit/__init__.py,sha256=qqwY8ulxTx1S74Mzpb6EKjBLT6fWTNdf9PQ3HKuERKY,50
@@ -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
@@ -126,12 +128,12 @@ xp/services/conbus/conbus_blink_service.py,sha256=x9uM-sLnIEV8wSNsvJgo08E042g-Hh
126
128
  xp/services/conbus/conbus_custom_service.py,sha256=4aneYdPObiZOGxPFYg5Wr70cl_xFxlQIdJBPQSa0enI,5826
127
129
  xp/services/conbus/conbus_datapoint_queryall_service.py,sha256=p9R02cVimhdJILHQ6BoeZj8Hog4oRpqBnMo3t4R8ecY,6816
128
130
  xp/services/conbus/conbus_datapoint_service.py,sha256=SYhHj9RmTmaJ750tyZ1IW2kl7tgDQ1xm_EM1zUjk1aQ,6421
129
- xp/services/conbus/conbus_discover_service.py,sha256=sSCSDNWWGtx5QOShwJfcbG54WCYH-BxWvgE10ghibN4,12326
131
+ xp/services/conbus/conbus_discover_service.py,sha256=ZwjYBlgP6FgpHBJk7pcKr4JHfH7WUHDxe4he4F_HblQ,12740
130
132
  xp/services/conbus/conbus_event_list_service.py,sha256=0xyXXNU44epN5bFkU6oiZMyhxfUguul3evqClvPJDcA,3618
131
133
  xp/services/conbus/conbus_event_raw_service.py,sha256=FZFu-LNLInrTKTpiGLyootozvyIF5Si5FMrxNk2ALD0,7000
132
134
  xp/services/conbus/conbus_output_service.py,sha256=mHFOAPx2zo0TStZ3pokp6v94AQjIamcwZDeg5YH_-eo,7240
133
135
  xp/services/conbus/conbus_raw_service.py,sha256=4yZLLTIAOxpgByUTWZXw1ihGa6Xtl98ckj9T7VfprDI,4335
134
- xp/services/conbus/conbus_receive_service.py,sha256=38lAZ0tc2AjBfcqI7qje-ES_QHiHZ3Ayybrp1ZC8ceM,5412
136
+ xp/services/conbus/conbus_receive_service.py,sha256=7wOaEDrdoXwZE9MeUM89eB3hobYpvtbYk_YLv3MVAtc,5352
135
137
  xp/services/conbus/conbus_scan_service.py,sha256=tHJ5qaxcNXxAZb2D2F1v6IrzydfxjJOYllM6Txt1eBE,5176
136
138
  xp/services/conbus/write_config_service.py,sha256=6feNdixI_Nli4MRLe15nea-7gTEXMUwZIvTqv_1OqHI,7157
137
139
  xp/services/homekit/__init__.py,sha256=xAMKmln_AmEFdOOJGKWYi96seRlKDQpKx3-hm7XbdIo,36
@@ -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,13 +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/protocol.py,sha256=ERntzYvNMyI-VDCYW7EnhpPu-V6WPmyEk0xcQfm7LFM,4399
180
- xp/term/protocol.tcss,sha256=biaIv6X-bmD1tbXENFUNsXOF8ABkuoRAUgeSDqwudtQ,2126
181
- xp/term/protocol.yml,sha256=kiTe_QSMPmLvLA0ZyIhNaDPwBdi6khh5C1NSR7I9TN0,2124
181
+ xp/term/protocol.py,sha256=acYYsv7_4z5ePrnslSC1exKzqbKOE5ZGds4J33Q2XNs,4784
182
+ xp/term/protocol.tcss,sha256=r_KfxrbpycGHLVXqZc6INBBcUJME0hLrAZkF1oqnab4,2126
183
+ xp/term/protocol.yml,sha256=BI1dyWfYsINsJnbSR-z4fzFOsYcY27dS6it8eo7AVnU,2124
182
184
  xp/term/widgets/__init__.py,sha256=ftWmN_fmjxy2E8Qfm-YSRmzKfgL0KTBCTpgvYWCPbUY,274
183
185
  xp/term/widgets/help_menu.py,sha256=bdT5AYRdtKt_tvZTVbG7-DPMb1mj78kggtjjsa-95BA,1780
184
- xp/term/widgets/protocol_log.py,sha256=yuSfc61azgQmWQxLvBcmVik3cJAW1Hocj2Sk8qr3hSg,14343
185
- xp/term/widgets/status_footer.py,sha256=VTN5owCprMbYmNiEbNWS_4CE8yxysDi8IBCECuU9EQY,1663
186
+ xp/term/widgets/protocol_log.py,sha256=4wh6tpaaKiEUOQTqqpUml6b5WpV3YVmXlG-W8_UYuLA,11693
187
+ xp/term/widgets/status_footer.py,sha256=eRZHkrG5aZCMulibX56KfFyGHS8IgL-7psvr9f9S6FI,1992
186
188
  xp/utils/__init__.py,sha256=_avMF_UOkfR3tNeDIPqQ5odmbq5raKkaq1rZ9Cn1CJs,332
187
189
  xp/utils/checksum.py,sha256=HDpiQxmdIedbCbZ4o_Box0i_Zig417BtCV_46ZyhiTk,1711
188
190
  xp/utils/dependencies.py,sha256=ECS6p0eXzocM5INLwJeckHXn_Dim18uOjXTJ29qQvkQ,22001
@@ -191,4 +193,4 @@ xp/utils/logging.py,sha256=rZDXwlBrYK8A6MPq5StsMNpgsRowzJXM6fvROPwJdGM,3750
191
193
  xp/utils/serialization.py,sha256=RWHHk86feaB4ZP7rjE4qOWK0900yg2joUBDkP76gfOY,4618
192
194
  xp/utils/state_machine.py,sha256=Oe2sLwCh9z_vr1tF6X0ZRGTeuckRQAGzmef7xc9CNdc,2413
193
195
  xp/utils/time_utils.py,sha256=dEyViDlAG9GWU-J3D_YVa-sGma6yiyyMTgN4h2x3PY4,3781
194
- conson_xp-1.22.0.dist-info/RECORD,,
196
+ conson_xp-1.24.0.dist-info/RECORD,,
xp/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  conson-xp package.
4
4
  """
5
5
 
6
- __version__ = "1.22.0"
6
+ __version__ = "1.24.0"
7
7
  __manufacturer__ = "salchichon"
8
8
  __model__ = "xp.cli"
9
9
  __serial__ = "2025.09.23.000"
@@ -36,6 +36,7 @@ def send_discover_telegram(ctx: click.Context) -> None:
36
36
  discovered_devices: Discover response with all found devices.
37
37
  """
38
38
  click.echo(json.dumps(discovered_devices.to_dict(), indent=2))
39
+ service.stop_reactor()
39
40
 
40
41
  def on_device_discovered(discovered_device: DiscoveredDevice) -> None:
41
42
  """Handle discovery of sa single module.
@@ -57,5 +58,9 @@ def send_discover_telegram(ctx: click.Context) -> None:
57
58
  service: ConbusDiscoverService = (
58
59
  ctx.obj.get("container").get_container().resolve(ConbusDiscoverService)
59
60
  )
60
- service.run(progress, on_device_discovered, on_finish, 5)
61
- service.start_reactor()
61
+ with service:
62
+ service.on_progress.connect(progress)
63
+ service.on_device_discovered.connect(on_device_discovered)
64
+ service.on_finish.connect(on_finish)
65
+ service.set_timeout(5)
66
+ service.start_reactor()
@@ -43,8 +43,9 @@ def receive_telegrams(ctx: Context, timeout: float) -> None:
43
43
  response_received: Receive response object with telegrams.
44
44
  """
45
45
  click.echo(json.dumps(response_received.to_dict(), indent=2))
46
+ service.stop_reactor()
46
47
 
47
- def progress(telegram_received: str) -> None:
48
+ def on_progress(telegram_received: str) -> None:
48
49
  """Handle progress updates during telegram receive operation.
49
50
 
50
51
  Args:
@@ -56,5 +57,7 @@ def receive_telegrams(ctx: Context, timeout: float) -> None:
56
57
  ctx.obj.get("container").get_container().resolve(ConbusReceiveService)
57
58
  )
58
59
  with service:
59
- service.init(progress, on_finish, timeout)
60
+ service.on_progress.connect(on_progress)
61
+ service.on_finish.connect(on_finish)
62
+ service.set_timeout(timeout)
60
63
  service.start_reactor()
@@ -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
@@ -4,8 +4,11 @@ This service implements a TCP client that connects to Conbus servers and sends
4
4
  discover telegrams to find modules on the network.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import logging
8
- from typing import Callable, Optional
9
+ from typing import Any, Optional
10
+
11
+ from psygnal import Signal
9
12
 
10
13
  from xp.models import ConbusDiscoverResponse
11
14
  from xp.models.conbus.conbus_discover import DiscoveredDevice
@@ -26,9 +29,15 @@ class ConbusDiscoverService:
26
29
 
27
30
  Attributes:
28
31
  conbus_protocol: Protocol instance for Conbus communication.
32
+ on_progress: Signal emitted when discovery progress is made (with serial number).
33
+ on_finish: Signal emitted when discovery finishes (with result).
34
+ on_device_discovered: Signal emitted when a device is discovered (with device info).
29
35
  """
30
36
 
31
37
  conbus_protocol: ConbusEventProtocol
38
+ on_progress: Signal = Signal(str)
39
+ on_finish: Signal = Signal(DiscoveredDevice)
40
+ on_device_discovered: Signal = Signal(ConbusDiscoverResponse)
32
41
 
33
42
  def __init__(self, conbus_protocol: ConbusEventProtocol) -> None:
34
43
  """Initialize the Conbus discover service.
@@ -36,12 +45,6 @@ class ConbusDiscoverService:
36
45
  Args:
37
46
  conbus_protocol: ConbusProtocol.
38
47
  """
39
- self.progress_callback: Optional[Callable[[str], None]] = None
40
- self.device_discover_callback: Optional[Callable[[DiscoveredDevice], None]] = (
41
- None
42
- )
43
- self.finish_callback: Optional[Callable[[ConbusDiscoverResponse], None]] = None
44
-
45
48
  self.conbus_protocol: ConbusEventProtocol = conbus_protocol
46
49
  self.conbus_protocol.on_connection_made.connect(self.connection_made)
47
50
  self.conbus_protocol.on_telegram_sent.connect(self.telegram_sent)
@@ -135,9 +138,7 @@ class ConbusDiscoverService:
135
138
  "module_type_name": None,
136
139
  }
137
140
  self.discovered_device_result.discovered_devices.append(device)
138
-
139
- if self.device_discover_callback:
140
- self.device_discover_callback(device)
141
+ self.on_device_discovered.emit(device)
141
142
 
142
143
  # Send READ_DATAPOINT telegram to query module type
143
144
  self.logger.debug(f"Sending module type query for {serial_number}")
@@ -147,8 +148,7 @@ class ConbusDiscoverService:
147
148
  system_function=SystemFunction.READ_DATAPOINT,
148
149
  data_value=DataPointType.MODULE_TYPE.value,
149
150
  )
150
- if self.progress_callback:
151
- self.progress_callback(serial_number)
151
+ self.on_progress.emit(serial_number)
152
152
 
153
153
  def handle_module_type_code_response(
154
154
  self, serial_number: str, module_type_code: str
@@ -194,8 +194,7 @@ class ConbusDiscoverService:
194
194
  device["module_type_code"] = code
195
195
  device["module_type_name"] = module_type_name
196
196
 
197
- if self.device_discover_callback:
198
- self.device_discover_callback(device)
197
+ self.on_device_discovered.emit(device)
199
198
 
200
199
  self.logger.debug(
201
200
  f"Updated device {serial_number} with module_type {module_type_name}"
@@ -231,9 +230,7 @@ class ConbusDiscoverService:
231
230
  self.logger.debug(
232
231
  f"Updated device {serial_number} with module_type {module_type}"
233
232
  )
234
- if self.device_discover_callback:
235
- self.device_discover_callback(device)
236
-
233
+ self.on_device_discovered.emit(device)
237
234
  break
238
235
 
239
236
  self.conbus_protocol.send_telegram(
@@ -249,10 +246,7 @@ class ConbusDiscoverService:
249
246
  self.logger.info("Discovery stopped after: %ss", timeout)
250
247
  self.discovered_device_result.success = False
251
248
  self.discovered_device_result.error = "Discovered device timeout"
252
- if self.finish_callback:
253
- self.finish_callback(self.discovered_device_result)
254
-
255
- self.stop_reactor()
249
+ self.on_finish.emit(self.discovered_device_result)
256
250
 
257
251
  def failed(self, message: str) -> None:
258
252
  """Handle failed connection event.
@@ -263,50 +257,64 @@ class ConbusDiscoverService:
263
257
  self.logger.debug(f"Failed: {message}")
264
258
  self.discovered_device_result.success = False
265
259
  self.discovered_device_result.error = message
266
- if self.finish_callback:
267
- self.finish_callback(self.discovered_device_result)
268
-
269
- self.stop_reactor()
260
+ self.on_finish.emit(self.discovered_device_result)
270
261
 
271
262
  def succeed(self) -> None:
272
263
  """Handle discovered device success event."""
273
264
  self.logger.debug("Succeed")
274
265
  self.discovered_device_result.success = True
275
266
  self.discovered_device_result.error = None
276
- if self.finish_callback:
277
- self.finish_callback(self.discovered_device_result)
267
+ self.on_finish.emit(self.discovered_device_result)
278
268
 
279
- self.stop_reactor()
269
+ def set_timeout(self, timeout_seconds: float) -> None:
270
+ """Setup callbacks and timeout for receiving telegrams.
280
271
 
281
- def stop_reactor(self) -> None:
282
- """Stop reactor."""
283
- self.logger.info("Stopping reactor")
284
- self.conbus_protocol.stop_reactor()
285
-
286
- def start_reactor(self) -> None:
287
- """Start reactor."""
288
- self.logger.info("Starting reactor")
289
- self.conbus_protocol.start_reactor()
272
+ Args:
273
+ timeout_seconds: Optional timeout in seconds.
274
+ """
275
+ self.logger.debug("Set timeout")
276
+ self.conbus_protocol.timeout_seconds = timeout_seconds
290
277
 
291
- def run(
278
+ def set_event_loop(
292
279
  self,
293
- progress_callback: Callable[[str], None],
294
- device_discover_callback: Callable[[DiscoveredDevice], None],
295
- finish_callback: Callable[[ConbusDiscoverResponse], None],
296
- timeout_seconds: Optional[float] = None,
280
+ event_loop: asyncio.AbstractEventLoop,
297
281
  ) -> None:
298
- """Run reactor in dedicated thread with its own event loop.
282
+ """Setup callbacks and timeout for receiving telegrams.
299
283
 
300
284
  Args:
301
- progress_callback: Callback for each discovered device.
302
- device_discover_callback: Callback for each discovered device.
303
- finish_callback: Callback when discovery completes.
304
- timeout_seconds: Optional timeout in seconds.
285
+ event_loop: Optional event loop to use for async operations.
305
286
  """
306
- self.logger.info("Starting discovery")
287
+ self.logger.debug("Set eventloop")
288
+ self.conbus_protocol.set_event_loop(event_loop)
307
289
 
308
- if timeout_seconds:
309
- self.conbus_protocol.timeout_seconds = timeout_seconds
310
- self.progress_callback = progress_callback
311
- self.device_discover_callback = device_discover_callback
312
- self.finish_callback = finish_callback
290
+ def start_reactor(self) -> None:
291
+ """Start the reactor."""
292
+ self.conbus_protocol.start_reactor()
293
+
294
+ def stop_reactor(self) -> None:
295
+ """Start the reactor."""
296
+ self.conbus_protocol.stop_reactor()
297
+
298
+ def __enter__(self) -> "ConbusDiscoverService":
299
+ """Enter context manager.
300
+
301
+ Returns:
302
+ Self for context manager protocol.
303
+ """
304
+ # Reset state for singleton reuse
305
+ self.receive_response = ConbusDiscoverResponse(success=True)
306
+ return self
307
+
308
+ def __exit__(
309
+ self, _exc_type: Optional[type], _exc_val: Optional[Exception], _exc_tb: Any
310
+ ) -> None:
311
+ """Exit context manager and disconnect signals."""
312
+ self.conbus_protocol.on_connection_made.disconnect(self.connection_made)
313
+ self.conbus_protocol.on_telegram_sent.disconnect(self.telegram_sent)
314
+ self.conbus_protocol.on_telegram_received.disconnect(self.telegram_received)
315
+ self.conbus_protocol.on_timeout.disconnect(self.timeout)
316
+ self.conbus_protocol.on_failed.disconnect(self.failed)
317
+ self.on_device_discovered.disconnect()
318
+ self.on_progress.disconnect()
319
+ self.on_finish.disconnect()
320
+ self.stop_reactor()
@@ -6,7 +6,9 @@ allowing clients to receive waiting event telegrams using empty telegram sends.
6
6
 
7
7
  import asyncio
8
8
  import logging
9
- from typing import Any, Callable, Optional
9
+ from typing import Any, Optional
10
+
11
+ from psygnal import Signal
10
12
 
11
13
  from xp.models.conbus.conbus_receive import ConbusReceiveResponse
12
14
  from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
@@ -22,9 +24,13 @@ class ConbusReceiveService:
22
24
 
23
25
  Attributes:
24
26
  conbus_protocol: Protocol instance for Conbus communication.
27
+ on_progress: Signal emitted when a telegram is received (with telegram frame).
28
+ on_finish: Signal emitted when receiving finishes (with result).
25
29
  """
26
30
 
27
31
  conbus_protocol: ConbusEventProtocol
32
+ on_progress: Signal = Signal(str)
33
+ on_finish: Signal = Signal(ConbusReceiveResponse)
28
34
 
29
35
  def __init__(self, conbus_protocol: ConbusEventProtocol) -> None:
30
36
  """Initialize the Conbus receive service.
@@ -32,8 +38,6 @@ class ConbusReceiveService:
32
38
  Args:
33
39
  conbus_protocol: ConbusEventProtocol instance.
34
40
  """
35
- self.progress_callback: Optional[Callable[[str], None]] = None
36
- self.finish_callback: Optional[Callable[[ConbusReceiveResponse], None]] = None
37
41
  self.receive_response: ConbusReceiveResponse = ConbusReceiveResponse(
38
42
  success=True
39
43
  )
@@ -67,8 +71,7 @@ class ConbusReceiveService:
67
71
  telegram_received: The telegram received event.
68
72
  """
69
73
  self.logger.debug(f"Telegram received: {telegram_received}")
70
- if self.progress_callback:
71
- self.progress_callback(telegram_received.frame)
74
+ self.on_progress.emit(telegram_received.frame)
72
75
 
73
76
  if not self.receive_response.received_telegrams:
74
77
  self.receive_response.received_telegrams = []
@@ -79,8 +82,7 @@ class ConbusReceiveService:
79
82
  timeout = self.conbus_protocol.timeout_seconds
80
83
  self.logger.info("Receive stopped after: %ss", timeout)
81
84
  self.receive_response.success = True
82
- if self.finish_callback:
83
- self.finish_callback(self.receive_response)
85
+ self.on_finish.emit(self.receive_response)
84
86
 
85
87
  def failed(self, message: str) -> None:
86
88
  """Handle failed connection event.
@@ -91,37 +93,37 @@ class ConbusReceiveService:
91
93
  self.logger.debug("Failed %s:", message)
92
94
  self.receive_response.success = False
93
95
  self.receive_response.error = message
94
- if self.finish_callback:
95
- self.finish_callback(self.receive_response)
96
+ self.on_finish.emit(self.receive_response)
97
+
98
+ def set_timeout(self, timeout_seconds: float) -> None:
99
+ """Setup callbacks and timeout for receiving telegrams.
96
100
 
97
- def init(
101
+ Args:
102
+ timeout_seconds: Optional timeout in seconds.
103
+ """
104
+ self.logger.debug("Set timeout")
105
+ self.conbus_protocol.timeout_seconds = timeout_seconds
106
+
107
+ def set_event_loop(
98
108
  self,
99
- progress_callback: Callable[[str], None],
100
- finish_callback: Callable[[ConbusReceiveResponse], None],
101
- timeout_seconds: Optional[float] = None,
102
- event_loop: Optional[asyncio.AbstractEventLoop] = None,
109
+ event_loop: asyncio.AbstractEventLoop,
103
110
  ) -> None:
104
111
  """Setup callbacks and timeout for receiving telegrams.
105
112
 
106
113
  Args:
107
- progress_callback: Callback for each received telegram.
108
- finish_callback: Callback when receiving completes.
109
- timeout_seconds: Optional timeout in seconds.
110
114
  event_loop: Optional event loop to use for async operations.
111
115
  """
112
- self.logger.info("Starting receive")
113
- if timeout_seconds:
114
- self.conbus_protocol.timeout_seconds = timeout_seconds
115
- self.progress_callback = progress_callback
116
- self.finish_callback = finish_callback
117
-
118
- if event_loop:
119
- self.conbus_protocol.set_event_loop(event_loop)
116
+ self.logger.debug("Set eventloop")
117
+ self.conbus_protocol.set_event_loop(event_loop)
120
118
 
121
119
  def start_reactor(self) -> None:
122
120
  """Start the reactor."""
123
121
  self.conbus_protocol.start_reactor()
124
122
 
123
+ def stop_reactor(self) -> None:
124
+ """Start the reactor."""
125
+ self.conbus_protocol.stop_reactor()
126
+
125
127
  def __enter__(self) -> "ConbusReceiveService":
126
128
  """Enter context manager.
127
129
 
@@ -141,3 +143,6 @@ class ConbusReceiveService:
141
143
  self.conbus_protocol.on_telegram_received.disconnect(self.telegram_received)
142
144
  self.conbus_protocol.on_timeout.disconnect(self.timeout)
143
145
  self.conbus_protocol.on_failed.disconnect(self.failed)
146
+ self.on_progress.disconnect()
147
+ self.on_finish.disconnect()
148
+ self.stop_reactor()
@@ -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
 
xp/term/protocol.py CHANGED
@@ -7,6 +7,7 @@ from textual.app import App, ComposeResult
7
7
  from textual.containers import Horizontal
8
8
 
9
9
  from xp.models.term import ProtocolKeysConfig
10
+ from xp.models.term.status_message import StatusMessageChanged
10
11
  from xp.term.widgets.help_menu import HelpMenuWidget
11
12
  from xp.term.widgets.protocol_log import ProtocolLogWidget
12
13
  from xp.term.widgets.status_footer import StatusFooterWidget
@@ -125,3 +126,12 @@ class ProtocolMonitorApp(App[None]):
125
126
  """
126
127
  if self.footer_widget:
127
128
  self.footer_widget.update_status(state)
129
+
130
+ def on_status_message_changed(self, message: StatusMessageChanged) -> None:
131
+ """Handle status message changes from protocol widget.
132
+
133
+ Args:
134
+ message: Message containing the status text.
135
+ """
136
+ if self.footer_widget:
137
+ self.footer_widget.update_message(message.message)
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
@@ -63,8 +63,8 @@ protocol:
63
63
  "e":
64
64
  name: "Link 1 Off"
65
65
  telegrams:
66
- - E02L01I00M
67
- - E02L01I00B
66
+ - E02L01I09M
67
+ - E02L01I09B
68
68
 
69
69
  "f":
70
70
  name: "Link 2 On"
@@ -75,8 +75,8 @@ protocol:
75
75
  "g":
76
76
  name: "Link 2 Off"
77
77
  telegrams:
78
- - E02L02I00M
79
- - E02L02I00B
78
+ - E02L02I09M
79
+ - E02L02I09B
80
80
 
81
81
  "h":
82
82
  name: "Link 3 On"
@@ -87,8 +87,8 @@ protocol:
87
87
  "i":
88
88
  name: "Link 3 Off"
89
89
  telegrams:
90
- - E02L03I00M
91
- - E02L03I00B
90
+ - E02L03I09M
91
+ - E02L03I09B
92
92
 
93
93
  "j":
94
94
  name: "Link 4 On"
@@ -99,8 +99,8 @@ protocol:
99
99
  "k":
100
100
  name: "Link 4 Off"
101
101
  telegrams:
102
- - E02L04I00M
103
- - E02L04I00B
102
+ - E02L04I09M
103
+ - E02L04I09B
104
104
 
105
105
  "l":
106
106
  name: "Link 5 On"
@@ -111,8 +111,8 @@ protocol:
111
111
  "m":
112
112
  name: "Link 5 Off"
113
113
  telegrams:
114
- - E02L05I00M
115
- - E02L05I00B
114
+ - E02L05I09M
115
+ - E02L05I09B
116
116
 
117
117
  "n":
118
118
  name: "Link 6 On"
@@ -123,8 +123,8 @@ protocol:
123
123
  "o":
124
124
  name: "Link 6 Off"
125
125
  telegrams:
126
- - E02L06I00M
127
- - E02L06I00B
126
+ - E02L06I09M
127
+ - E02L06I09B
128
128
 
129
129
  "p":
130
130
  name: "Link 7 On"
@@ -135,5 +135,5 @@ protocol:
135
135
  "q":
136
136
  name: "Link 7 Off"
137
137
  telegrams:
138
- - E02L07I00M
139
- - E02L07I00B
138
+ - E02L07I09M
139
+ - E02L07I09B
@@ -2,71 +2,17 @@
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
14
- from xp.services.conbus.conbus_receive_service import ConbusReceiveService
13
+ from xp.models.term.connection_state import ConnectionState
14
+ from xp.models.term.status_message import StatusMessageChanged
15
15
  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
16
 
71
17
 
72
18
  class ProtocolLogWidget(Widget):
@@ -79,23 +25,10 @@ class ProtocolLogWidget(Widget):
79
25
  container: ServiceContainer for dependency injection.
80
26
  connection_state: Current connection state (reactive).
81
27
  protocol: Reference to ConbusEventProtocol (prevents duplicate connections).
82
- service: ConbusReceiveService instance.
83
28
  logger: Logger instance for this widget.
84
29
  log_widget: RichLog widget for displaying messages.
85
30
  """
86
31
 
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
32
  connection_state = reactive(ConnectionState.DISCONNECTED)
100
33
 
101
34
  def __init__(self, container: Any) -> None:
@@ -108,10 +41,9 @@ class ProtocolLogWidget(Widget):
108
41
  self.border_title = "Protocol"
109
42
  self.container = container
110
43
  self.protocol: Optional[ConbusEventProtocol] = None
111
- self.service: Optional[ConbusReceiveService] = None
112
44
  self.logger = logging.getLogger(__name__)
113
45
  self.log_widget: Optional[RichLog] = None
114
- self._state_machine = create_connection_state_machine()
46
+ self._state_machine = ConnectionState.create_state_machine()
115
47
 
116
48
  def compose(self) -> Any:
117
49
  """Compose the widget layout.
@@ -129,11 +61,11 @@ class ProtocolLogWidget(Widget):
129
61
  Resolves ConbusReceiveService and connects signals.
130
62
  """
131
63
  # Resolve service from container (singleton)
132
- self.service = self.container.resolve(ConbusReceiveService)
133
- self.protocol = self.service.conbus_protocol
64
+ self.protocol = self.container.resolve(ConbusEventProtocol)
134
65
 
135
66
  # Connect psygnal signals
136
67
  self.protocol.on_connection_made.connect(self._on_connection_made)
68
+ self.protocol.on_connection_failed.connect(self._on_connection_failed)
137
69
  self.protocol.on_telegram_received.connect(self._on_telegram_received)
138
70
  self.protocol.on_telegram_sent.connect(self._on_telegram_sent)
139
71
  self.protocol.on_timeout.connect(self._on_timeout)
@@ -150,10 +82,6 @@ class ProtocolLogWidget(Widget):
150
82
  Integrates Twisted reactor with Textual's asyncio loop cleanly.
151
83
  """
152
84
  # Guard against duplicate connections (race condition)
153
- if self.service is None:
154
- self.logger.error("Service not initialized")
155
- return
156
-
157
85
  if self.protocol is None:
158
86
  self.logger.error("Protocol not initialized")
159
87
  return
@@ -169,10 +97,8 @@ class ProtocolLogWidget(Widget):
169
97
  # Transition to CONNECTING
170
98
  if self._state_machine.transition("connecting", ConnectionState.CONNECTING):
171
99
  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
- )
100
+ self.post_status(
101
+ f"Connecting to {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}..."
176
102
  )
177
103
 
178
104
  # Store protocol reference
@@ -180,41 +106,12 @@ class ProtocolLogWidget(Widget):
180
106
  self.logger.info(f"Reactor object: {self.protocol._reactor}")
181
107
  self.logger.info(f"Reactor running: {self.protocol._reactor.running}")
182
108
 
183
- # Setup service callbacks
184
- def progress_callback(telegram: str) -> None:
185
- """Handle progress updates for telegram reception.
186
-
187
- Args:
188
- telegram: Received telegram string.
189
- """
190
- pass
191
-
192
- def finish_callback(response: Any) -> None:
193
- """Handle completion of telegram reception.
194
-
195
- Args:
196
- response: Response object from telegram reception.
197
- """
198
- pass
199
-
200
109
  # Get the currently running asyncio event loop (Textual's loop)
201
110
  event_loop = asyncio.get_running_loop()
202
111
  self.logger.info(f"Current running loop: {event_loop}")
203
112
  self.logger.info(f"Loop is running: {event_loop.is_running()}")
204
113
 
205
- self.service.init(
206
- progress_callback=progress_callback,
207
- finish_callback=finish_callback,
208
- timeout_seconds=None, # Continuous monitoring
209
- event_loop=event_loop,
210
- )
211
-
212
- reactor = self.service.conbus_protocol._reactor
213
- reactor.connectTCP(
214
- self.protocol.cli_config.ip,
215
- self.protocol.cli_config.port,
216
- self.protocol,
217
- )
114
+ self.protocol.connect()
218
115
 
219
116
  # Wait for connection to establish
220
117
  await asyncio.sleep(1.0)
@@ -222,15 +119,16 @@ class ProtocolLogWidget(Widget):
222
119
 
223
120
  except Exception as e:
224
121
  self.logger.error(f"Connection failed: {e}")
122
+ self.post_status(f"Connection failed: {e}")
225
123
  # Transition to FAILED
226
124
  if self._state_machine.transition("failed", ConnectionState.FAILED):
227
125
  self.connection_state = ConnectionState.FAILED
228
- self.post_message(self.StatusMessageChanged(f"Connection error: {e}"))
229
126
 
230
127
  def _start_connection(self) -> None:
231
128
  """Start connection (sync wrapper for async method)."""
232
129
  # Use run_worker to run async method from sync context
233
130
  self.logger.debug("Start connection")
131
+ self.post_status("Start connection")
234
132
  self.run_worker(self._start_connection_async(), exclusive=True)
235
133
 
236
134
  def _on_connection_made(self) -> None:
@@ -239,16 +137,26 @@ class ProtocolLogWidget(Widget):
239
137
  Sets state to CONNECTED and displays success message.
240
138
  """
241
139
  self.logger.debug("Connection made")
140
+ self.post_status("Connection made")
242
141
  # Transition to CONNECTED
243
142
  if self._state_machine.transition("connected", ConnectionState.CONNECTED):
244
143
  self.connection_state = ConnectionState.CONNECTED
245
144
  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
- )
145
+ self.post_status(
146
+ f"Connected to {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}"
250
147
  )
251
148
 
149
+ def _on_connection_failed(self, failure: Failure) -> None:
150
+ """Handle connection failed signal.
151
+
152
+ Sets state to DISCONNECTED and displays success message.
153
+ """
154
+ self.logger.debug("Connection failed")
155
+ self.post_status(failure.getErrorMessage())
156
+ # Transition to CONNECTED
157
+ if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
158
+ self.connection_state = ConnectionState.DISCONNECTED
159
+
252
160
  def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
253
161
  """Handle telegram received signal.
254
162
 
@@ -289,7 +197,15 @@ class ProtocolLogWidget(Widget):
289
197
  if self._state_machine.transition("failed", ConnectionState.FAILED):
290
198
  self.connection_state = ConnectionState.FAILED
291
199
  self.logger.error(f"Connection failed: {error}")
292
- self.post_message(self.StatusMessageChanged(f"Failed: {error}"))
200
+ self.post_status(f"Failed: {error}")
201
+
202
+ def post_status(self, message: str) -> None:
203
+ """Post status message.
204
+
205
+ Args:
206
+ message: message to be sent to status bar.
207
+ """
208
+ self.post_message(StatusMessageChanged(message))
293
209
 
294
210
  def connect(self) -> None:
295
211
  """Connect to Conbus server.
@@ -326,7 +242,7 @@ class ProtocolLogWidget(Widget):
326
242
  "disconnecting", ConnectionState.DISCONNECTING
327
243
  ):
328
244
  self.connection_state = ConnectionState.DISCONNECTING
329
- self.post_message(self.StatusMessageChanged("Disconnecting..."))
245
+ self.post_status("Disconnecting...")
330
246
 
331
247
  if self.protocol:
332
248
  self.protocol.disconnect()
@@ -334,7 +250,7 @@ class ProtocolLogWidget(Widget):
334
250
  # Transition to DISCONNECTED
335
251
  if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
336
252
  self.connection_state = ConnectionState.DISCONNECTED
337
- self.post_message(self.StatusMessageChanged("Disconnected"))
253
+ self.post_status("Disconnected")
338
254
 
339
255
  def send_telegram(self, name: str, telegram: str) -> None:
340
256
  """Send a raw telegram string.
@@ -349,19 +265,19 @@ class ProtocolLogWidget(Widget):
349
265
 
350
266
  try:
351
267
  # Remove angle brackets if present
352
- self.post_message(self.StatusMessageChanged(f"Sending {name}..."))
268
+ self.post_status(f"{name} sent.")
353
269
  # Send raw telegram
354
270
  self.protocol.send_raw_telegram(telegram)
355
271
 
356
272
  except Exception as e:
357
273
  self.logger.error(f"Failed to send telegram: {e}")
358
- self.post_message(self.StatusMessageChanged(f"Failed: {e}"))
274
+ self.post_status(f"Failed: {e}")
359
275
 
360
276
  def clear_log(self) -> None:
361
277
  """Clear the protocol log widget."""
362
278
  if self.log_widget:
363
279
  self.log_widget.clear()
364
- self.post_message(self.StatusMessageChanged("Log cleared"))
280
+ self.post_status("Log cleared")
365
281
 
366
282
  def on_unmount(self) -> None:
367
283
  """Clean up when widget unmounts.
@@ -25,6 +25,7 @@ class StatusFooterWidget(Horizontal):
25
25
  kwargs: Additional keyword arguments for Horizontal.
26
26
  """
27
27
  super().__init__(*args, **kwargs)
28
+ self.status_text_widget: Static = Static("", id="status-text")
28
29
  self.status_widget: Static = Static("○", id="status-line")
29
30
 
30
31
  def compose(self) -> ComposeResult:
@@ -34,6 +35,7 @@ class StatusFooterWidget(Horizontal):
34
35
  Footer and status indicator widgets.
35
36
  """
36
37
  yield Footer()
38
+ yield self.status_text_widget
37
39
  yield self.status_widget
38
40
 
39
41
  def update_status(self, state: Any) -> None:
@@ -51,3 +53,11 @@ class StatusFooterWidget(Horizontal):
51
53
  "DISCONNECTED": "○",
52
54
  }.get(state.value, "○")
53
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)