conson-xp 1.28.0__py3-none-any.whl → 1.32.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.28.0
3
+ Version: 1.32.0
4
4
  Summary: XP Protocol Communication Tools
5
5
  Author-Email: ldvchosal <ldvchosal@github.com>
6
6
  License: MIT License
@@ -325,6 +325,7 @@ xp conbus event
325
325
  xp conbus event list
326
326
  xp conbus event raw
327
327
 
328
+ xp conbus export
328
329
 
329
330
  xp conbus lightlevel
330
331
  xp conbus lightlevel get
@@ -1,8 +1,8 @@
1
- conson_xp-1.28.0.dist-info/METADATA,sha256=vm3hTbH67pUIRDsWHMEsHM4UGNUw3ZNL6V5Xh3KZxPE,10312
2
- conson_xp-1.28.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
- conson_xp-1.28.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
- conson_xp-1.28.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
- xp/__init__.py,sha256=Ww_plPH2wL8n-IB9Qn1--AEu9xjpxIPxX53TpCchpnM,181
1
+ conson_xp-1.32.0.dist-info/METADATA,sha256=7pNhSUQaahCS55NfZHY_NR7e7d3VZ73oDUXwcCirr4A,10329
2
+ conson_xp-1.32.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
+ conson_xp-1.32.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
+ conson_xp-1.32.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
+ xp/__init__.py,sha256=WvYNIdtUKhtQhMR_bDasGkHpOfRlda8a-wO0v7kBE5c,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
@@ -16,6 +16,7 @@ xp/cli/commands/conbus/conbus_custom_commands.py,sha256=lICT93ijMdhVRm8KjNMLo7kQ
16
16
  xp/cli/commands/conbus/conbus_datapoint_commands.py,sha256=r36OuTjREtbGKL-bskAGa0-WLw7x06td6woZn3GYJNA,3630
17
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
+ xp/cli/commands/conbus/conbus_export_commands.py,sha256=s3jgg3Wqi1P6rYujpE_9aPA47S4UBQfrZPTr9vzH-UA,2951
19
20
  xp/cli/commands/conbus/conbus_lightlevel_commands.py,sha256=FpCwogdxa7yFUjlrxM7e8Q2Ut32tKAHabngQQChvtJI,6763
20
21
  xp/cli/commands/conbus/conbus_linknumber_commands.py,sha256=KitaGDM5HpwVUz8rLpO8VZUypUTcAg3Bzl0DVm6gnSk,3391
21
22
  xp/cli/commands/conbus/conbus_modulenumber_commands.py,sha256=L7-6y3rDllOjQ9g6Bk_RiTKIhAOHVPLdxWif9exkngs,3463
@@ -43,7 +44,7 @@ xp/cli/commands/telegram/telegram_version_commands.py,sha256=WQyx1-B9yJ8V9WrFyBp
43
44
  xp/cli/commands/term/__init__.py,sha256=1NNST_8YJfj5LCujQISwQflK6LyEn7mDmZpMpvI9d-o,116
44
45
  xp/cli/commands/term/term.py,sha256=gjvsv2OE-F_KNWQrWi04fXQ5cGo0l8P-Ortbb5KTA-A,309
45
46
  xp/cli/commands/term/term_commands.py,sha256=CwqnLPEi7LuC7bCo7kIGKMZoVICY0nu42k8C554A1TA,1206
46
- xp/cli/main.py,sha256=ap5jU0DrSnrCKDKqGXcz9N-sngZodyyN-5ReWE8Fh1s,1817
47
+ xp/cli/main.py,sha256=3bksBas1mI-PZQGVxDJ7aoAG09L-cMQMK9Uf7y6_aF8,1963
47
48
  xp/cli/utils/__init__.py,sha256=gTGIj60Uai0iE7sr9_TtEpl04fD7krtTzbbigXUsUVU,46
48
49
  xp/cli/utils/click_tree.py,sha256=ilmM2IMa_c-TqUMsv2alrZXuS0BNhvVlrBlSfyN8lzM,1670
49
50
  xp/cli/utils/datapoint_type_choice.py,sha256=HcydhlqxZ7YyorEeTjFGkypF2JnYNPvOzkl1rhZ93Fc,1666
@@ -73,6 +74,7 @@ xp/models/conbus/conbus_datapoint.py,sha256=4ncR-vB2lRzRBAA30rYn8eguyTxsZoOKrrXt
73
74
  xp/models/conbus/conbus_discover.py,sha256=nxxUEKfEsH1kd0BF8ovMs7zLujRhrq1oL9ZJtysPr5o,2238
74
75
  xp/models/conbus/conbus_event_list.py,sha256=M8aHRHVB5VDIjqMzjO86xlERt7AMdfjIjt1b70RF52Y,958
75
76
  xp/models/conbus/conbus_event_raw.py,sha256=i5gc7z-0yeunWOZ4rw3AiBt4MANezmhBQKjOOQk3oDc,1567
77
+ xp/models/conbus/conbus_export.py,sha256=m2zrkpVifC9EZZBlJGFaaVyGq8w1a1nbapQM1n9mJo8,1078
76
78
  xp/models/conbus/conbus_lightlevel.py,sha256=GQGhzrCBEJROosNHInXIzBy6MD2AskEIMoFEGgZ60-0,1695
77
79
  xp/models/conbus/conbus_linknumber.py,sha256=uFzKzfB06oIzZEKCb5X2JEI80JjMPFuYglsT1W1k8j4,1815
78
80
  xp/models/conbus/conbus_logger_config.py,sha256=cFWjWn8tc_hPPI2kQAib_Akddar8O-3zkoj6wLBsdUo,3328
@@ -106,7 +108,7 @@ xp/models/telegram/telegram_type.py,sha256=GhqKP63oNMyh2tIvCPcsC5RFp4s4JjhmEqCLC
106
108
  xp/models/telegram/timeparam_type.py,sha256=Ar8xvSfPmOAgR2g2Je0FgvP01SL7bPvZn5_HrVDpmJM,1137
107
109
  xp/models/term/__init__.py,sha256=aFvzGZHr_dI6USb8MJuYLSLMvxi_ZWMVtokHDt8428s,263
108
110
  xp/models/term/connection_state.py,sha256=floDRMeMcfgMrYIVsyoVHBXHtxd3hqm-xOdr3oXtaHY,1793
109
- xp/models/term/module_state.py,sha256=p9pXCVRDIdMEbAf5jhAHoZ2dnqhpu2rli_ThA1WUCj0,864
111
+ xp/models/term/module_state.py,sha256=tg5V3HNicXhXE10WuDSCN8OleVrorXrOosXOEgEAVE0,934
110
112
  xp/models/term/protocol_keys_config.py,sha256=CTujcfI2_NOeltjvHy_cnsHzxLSVsGFXieMZlD-zj0Q,1204
111
113
  xp/models/term/status_message.py,sha256=DOmzL0dbig5mP1UEoXdgzGT4UG2RyAXa_yRVo5c4x8w,394
112
114
  xp/models/term/telegram_display.py,sha256=RJDrJh4tqRmT0i1-tfYy17paEmVb3HY3DMuFPsEhZyc,533
@@ -133,6 +135,7 @@ xp/services/conbus/conbus_datapoint_service.py,sha256=SYhHj9RmTmaJ750tyZ1IW2kl7t
133
135
  xp/services/conbus/conbus_discover_service.py,sha256=ZwjYBlgP6FgpHBJk7pcKr4JHfH7WUHDxe4he4F_HblQ,12740
134
136
  xp/services/conbus/conbus_event_list_service.py,sha256=0xyXXNU44epN5bFkU6oiZMyhxfUguul3evqClvPJDcA,3618
135
137
  xp/services/conbus/conbus_event_raw_service.py,sha256=FZFu-LNLInrTKTpiGLyootozvyIF5Si5FMrxNk2ALD0,7000
138
+ xp/services/conbus/conbus_export_service.py,sha256=-EIDiXOitLwXTKCfw1zv4XIuIKWaT-3FkxtQwms7PgM,17920
136
139
  xp/services/conbus/conbus_output_service.py,sha256=mHFOAPx2zo0TStZ3pokp6v94AQjIamcwZDeg5YH_-eo,7240
137
140
  xp/services/conbus/conbus_raw_service.py,sha256=4yZLLTIAOxpgByUTWZXw1ihGa6Xtl98ckj9T7VfprDI,4335
138
141
  xp/services/conbus/conbus_receive_service.py,sha256=7wOaEDrdoXwZE9MeUM89eB3hobYpvtbYk_YLv3MVAtc,5352
@@ -162,9 +165,10 @@ xp/services/protocol/telegram_protocol.py,sha256=Ki5DrXsKxiaqLcdP9WWUuhUI7cPu2Df
162
165
  xp/services/reverse_proxy_service.py,sha256=BUOlcLlTU-R5iuC_96rasug21xo19wK9_4fMQXxc0QM,15061
163
166
  xp/services/server/__init__.py,sha256=QEcCj-jK0goAukJCe15TKYFQfSAzWsduPT_wW0HxZU8,48
164
167
  xp/services/server/base_server_service.py,sha256=B-ntxp3swbwuri-9_2EuvBDi-4Uo9AH-AA4iAFGWIS4,12682
168
+ xp/services/server/client_buffer_manager.py,sha256=1d_MqfzuUqBwaQUiC1n5K76WwSxrdngYAmNH7he6u3o,2235
165
169
  xp/services/server/cp20_server_service.py,sha256=SXdI6Jt400T9sLdw86ovEqKRGeV3nYVaHEA9Gcj6W2A,2041
166
170
  xp/services/server/device_service_factory.py,sha256=Y4TvSFALeq0zYzHfCwcbikSpmIyYbLcvm9756n5Jm7Q,3744
167
- xp/services/server/server_service.py,sha256=JPRFMto2l956dW7vfSclQugu2vdF0fssxxUOYjHNtA4,15833
171
+ xp/services/server/server_service.py,sha256=2t3guPVX3YUyNJo7B5b1U80eRMyEgE7irT2X8MMQMag,16302
168
172
  xp/services/server/xp130_server_service.py,sha256=YnvetDp72-QzkyDGB4qfZZIwFs03HuibUOz2zb9XR0c,2191
169
173
  xp/services/server/xp20_server_service.py,sha256=1wJ7A-bRkN9O5Spu3q3LNDW31mNtNF2eNMQ5E6O2ltA,2928
170
174
  xp/services/server/xp230_server_service.py,sha256=k9ftCY5tjLFP31mKVCspq283RVaPkGx-Yq61Urk8JLs,1815
@@ -181,7 +185,7 @@ xp/services/telegram/telegram_service.py,sha256=XrP1CPi0ckxoKBaNwLA6lo-TogWxXgmX
181
185
  xp/services/telegram/telegram_version_service.py,sha256=M5HdOTsLdcwo122FP-jW6R740ktLrtKf2TiMDVz23h8,10528
182
186
  xp/services/term/__init__.py,sha256=BIeOK042bMR-0l6MA80wdW5VuHlpWOXtRER9IG5ilQA,245
183
187
  xp/services/term/protocol_monitor_service.py,sha256=PhEzLNzWf1XieQw94ua-hJu9ccwrAzhdxSZGe4kHghs,9945
184
- xp/services/term/state_monitor_service.py,sha256=gLvNdFMQ8TKX_fAu27TaIyCiEmf-hepkt7zYgFVTvng,12337
188
+ xp/services/term/state_monitor_service.py,sha256=PgwCH8nce1RODV33aJefiX3on-pSGEgP_4FDAoU5Trc,16218
185
189
  xp/term/__init__.py,sha256=Xg2DhBeI3xQJLfc7_BPWI1por-rUXemyer5OtOt9Cus,51
186
190
  xp/term/protocol.py,sha256=oLJAExvIaOSpy75A5TaYB_7R9skTTtNtPx8hiJLdy_U,3425
187
191
  xp/term/protocol.tcss,sha256=r_KfxrbpycGHLVXqZc6INBBcUJME0hLrAZkF1oqnab4,2126
@@ -189,15 +193,15 @@ xp/term/state.py,sha256=sR7I6t4gJSkO2YS3TwonAnGPR_f43coCk4xKdWETus0,3233
189
193
  xp/term/state.tcss,sha256=Njp7fc16cCunLq7hi5RvXjPi4jSCGi5aPDnusb9dq1Y,1401
190
194
  xp/term/widgets/__init__.py,sha256=ftWmN_fmjxy2E8Qfm-YSRmzKfgL0KTBCTpgvYWCPbUY,274
191
195
  xp/term/widgets/help_menu.py,sha256=w2NjwiC_s16St0rigZ9ef9S0V9Y4v0J5eCVCHAdRKF4,1789
192
- xp/term/widgets/modules_list.py,sha256=_B46p8lCOH4jr1kVqphS7Rixr6bobg6c_pD4RCj_NRE,7321
196
+ xp/term/widgets/modules_list.py,sha256=DD0PUnY4gv05hkCVxThWULHg1ZNNY8xr1XFaZrv9kS4,7666
193
197
  xp/term/widgets/protocol_log.py,sha256=CJUpckWj7GC1kVqixDadteyGnI4aHyzd4kkH-pSbzno,2600
194
198
  xp/term/widgets/status_footer.py,sha256=bxrcqKzJ9V0aPSn_WwraVpJz7NxBUh3yIjA3fwv5nVA,3256
195
199
  xp/utils/__init__.py,sha256=_avMF_UOkfR3tNeDIPqQ5odmbq5raKkaq1rZ9Cn1CJs,332
196
200
  xp/utils/checksum.py,sha256=HDpiQxmdIedbCbZ4o_Box0i_Zig417BtCV_46ZyhiTk,1711
197
- xp/utils/dependencies.py,sha256=UmVAEpGqEG6Li0h6u6I-mFgBTu6dsTeWjWUnfaGFofQ,24227
201
+ xp/utils/dependencies.py,sha256=d91Xt4PwnyeMB_tLB-hNDpm95QGMg5uiq52yvOM9BBE,24557
198
202
  xp/utils/event_helper.py,sha256=W-A_xmoXlpWZBbJH6qdaN50o3-XrwFsDgvAGMJDiAgo,1001
199
203
  xp/utils/logging.py,sha256=rZDXwlBrYK8A6MPq5StsMNpgsRowzJXM6fvROPwJdGM,3750
200
204
  xp/utils/serialization.py,sha256=RWHHk86feaB4ZP7rjE4qOWK0900yg2joUBDkP76gfOY,4618
201
205
  xp/utils/state_machine.py,sha256=Oe2sLwCh9z_vr1tF6X0ZRGTeuckRQAGzmef7xc9CNdc,2413
202
206
  xp/utils/time_utils.py,sha256=dEyViDlAG9GWU-J3D_YVa-sGma6yiyyMTgN4h2x3PY4,3781
203
- conson_xp-1.28.0.dist-info/RECORD,,
207
+ conson_xp-1.32.0.dist-info/RECORD,,
xp/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  conson-xp package.
4
4
  """
5
5
 
6
- __version__ = "1.28.0"
6
+ __version__ = "1.32.0"
7
7
  __manufacturer__ = "salchichon"
8
8
  __model__ = "xp.cli"
9
9
  __serial__ = "2025.09.23.000"
@@ -0,0 +1,88 @@
1
+ """Conbus export CLI command."""
2
+
3
+ from contextlib import suppress
4
+
5
+ import click
6
+
7
+ from xp.cli.commands.conbus.conbus import conbus
8
+ from xp.cli.utils.decorators import connection_command
9
+ from xp.models.conbus.conbus_export import ConbusExportResponse
10
+ from xp.models.homekit.homekit_conson_config import ConsonModuleConfig
11
+ from xp.services.conbus.conbus_export_service import ConbusExportService
12
+
13
+
14
+ @conbus.command("export")
15
+ @click.pass_context
16
+ @connection_command()
17
+ def export_conbus_config(ctx: click.Context) -> None:
18
+ r"""Export Conbus device configuration to YAML file.
19
+
20
+ Discovers all devices on the Conbus network and queries their configuration
21
+ datapoints to generate a complete export.yml file in conson.yml format.
22
+
23
+ Args:
24
+ ctx: Click context object.
25
+
26
+ Examples:
27
+ \b
28
+ # Export to export.yml in current directory
29
+ xp conbus export
30
+ """
31
+
32
+ def on_progress(serial_number: str, current: int, total: int) -> None:
33
+ """Handle progress updates during export.
34
+
35
+ Args:
36
+ serial_number: Serial number of discovered device.
37
+ current: Current device number.
38
+ total: Total devices discovered.
39
+ """
40
+ click.echo(f"Querying device {current}/{total}: {serial_number}...")
41
+
42
+ def on_device_exported(module: ConsonModuleConfig) -> None:
43
+ """Handle device export completion.
44
+
45
+ Args:
46
+ module: Exported module configuration.
47
+ """
48
+ module_type = module.module_type or "UNKNOWN"
49
+ module_code = (
50
+ module.module_type_code if module.module_type_code is not None else "?"
51
+ )
52
+ click.echo(f" ✓ Module type: {module_type} ({module_code})")
53
+
54
+ if module.link_number is not None:
55
+ click.echo(f" ✓ Link number: {module.link_number}")
56
+ if module.sw_version:
57
+ click.echo(f" ✓ Software version: {module.sw_version}")
58
+
59
+ def on_finish(result: ConbusExportResponse) -> None:
60
+ """Handle export completion.
61
+
62
+ Args:
63
+ result: Export result.
64
+
65
+ Raises:
66
+ ClickException: When export fails with error message from result.
67
+ """
68
+ # Try to stop reactor (may already be stopped)
69
+ with suppress(Exception):
70
+ service.stop_reactor()
71
+
72
+ if result.success:
73
+ click.echo(
74
+ f"\nExport complete: {result.output_file} ({result.device_count} devices)"
75
+ )
76
+ else:
77
+ click.echo(f"Error: {result.error}", err=True)
78
+ raise click.ClickException(result.error or "Export failed")
79
+
80
+ service: ConbusExportService = (
81
+ ctx.obj.get("container").get_container().resolve(ConbusExportService)
82
+ )
83
+ with service:
84
+ service.on_progress.connect(on_progress)
85
+ service.on_device_exported.connect(on_device_exported)
86
+ service.on_finish.connect(on_finish)
87
+ service.set_timeout(5)
88
+ service.start_reactor()
xp/cli/main.py CHANGED
@@ -4,11 +4,13 @@ import click
4
4
  from click_help_colors import HelpColorsGroup
5
5
 
6
6
  from xp.cli.commands import homekit
7
+
8
+ # Import all conbus command modules to register their commands
9
+ from xp.cli.commands.conbus import conbus_discover_commands # noqa: F401
10
+ from xp.cli.commands.conbus import conbus_export_commands # noqa: F401
7
11
  from xp.cli.commands.conbus.conbus import conbus
8
12
  from xp.cli.commands.file_commands import file
9
13
  from xp.cli.commands.module_commands import module
10
-
11
- # Import all conbus command modules to register their commands
12
14
  from xp.cli.commands.reverse_proxy_commands import reverse_proxy
13
15
  from xp.cli.commands.server.server_commands import server
14
16
 
@@ -0,0 +1,31 @@
1
+ """Conbus export response model."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional
5
+
6
+ from xp.models.homekit.homekit_conson_config import ConsonModuleListConfig
7
+
8
+
9
+ @dataclass
10
+ class ConbusExportResponse:
11
+ """Response from Conbus export operation.
12
+
13
+ Attributes:
14
+ success: Whether the operation was successful.
15
+ config: Exported module configuration list.
16
+ device_count: Number of devices exported.
17
+ output_file: Path to output file.
18
+ export_status: Export status (OK, FAILED_TIMEOUT, FAILED_NO_DEVICES, etc.).
19
+ error: Error message if operation failed.
20
+ sent_telegrams: List of telegrams sent during export.
21
+ received_telegrams: List of telegrams received during export.
22
+ """
23
+
24
+ success: bool
25
+ config: Optional[ConsonModuleListConfig] = None
26
+ device_count: int = 0
27
+ output_file: str = "export.yml"
28
+ export_status: str = "OK"
29
+ error: Optional[str] = None
30
+ sent_telegrams: list[str] = field(default_factory=list)
31
+ received_telegrams: list[str] = field(default_factory=list)
@@ -13,6 +13,7 @@ class ModuleState:
13
13
  name: Module name/identifier (e.g., A01, A02).
14
14
  serial_number: Module serial number.
15
15
  module_type: Module type designation (e.g., XP130, XP230, XP24).
16
+ link_number: Link number for the module.
16
17
  outputs: Output states as space-separated binary values. Empty string for modules without outputs.
17
18
  auto_report: Auto-report enabled status (Y/N).
18
19
  error_status: Module status ("OK" or error code like "E10").
@@ -22,6 +23,7 @@ class ModuleState:
22
23
  name: str
23
24
  serial_number: str
24
25
  module_type: str
26
+ link_number: int
25
27
  outputs: str
26
28
  auto_report: bool
27
29
  error_status: str
@@ -0,0 +1,460 @@
1
+ """Conbus export service for exporting device configurations."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ import yaml
9
+ from psygnal import Signal
10
+
11
+ from xp.models.conbus.conbus_export import ConbusExportResponse
12
+ from xp.models.homekit.homekit_conson_config import (
13
+ ConsonModuleConfig,
14
+ ConsonModuleListConfig,
15
+ )
16
+ from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
17
+ from xp.models.telegram.datapoint_type import DataPointType
18
+ from xp.models.telegram.reply_telegram import ReplyTelegram
19
+ from xp.models.telegram.system_function import SystemFunction
20
+ from xp.models.telegram.telegram_type import TelegramType
21
+ from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
22
+ from xp.services.telegram.telegram_service import TelegramService
23
+
24
+
25
+ class ConbusExportService:
26
+ """Service for exporting Conbus device configurations.
27
+
28
+ Discovers all devices on the Conbus network and queries their configuration
29
+ datapoints to generate a structured export file compatible with conson.yml format.
30
+
31
+ Attributes:
32
+ conbus_protocol: Protocol for Conbus communication.
33
+ discovered_devices: List of discovered device serial numbers.
34
+ device_configs: Partial device configurations being built.
35
+ device_datapoints_received: Set of datapoints received per device.
36
+ export_result: Final export result.
37
+ export_status: Export status (OK, FAILED_TIMEOUT, etc.).
38
+ on_progress: Signal emitted on device discovery (serial, current, total).
39
+ on_device_exported: Signal emitted when device export completes.
40
+ on_finish: Signal emitted when export finishes.
41
+ DATAPOINT_SEQUENCE: Sequence of 7 datapoints to query for each device.
42
+ """
43
+
44
+ # Signals (class attributes)
45
+ on_progress: Signal = Signal(str, int, int) # serial, current, total
46
+ on_device_exported: Signal = Signal(ConsonModuleConfig)
47
+ on_finish: Signal = Signal(ConbusExportResponse)
48
+
49
+ # Datapoint sequence to query for each device
50
+ DATAPOINT_SEQUENCE = [
51
+ DataPointType.MODULE_TYPE,
52
+ DataPointType.MODULE_TYPE_CODE,
53
+ DataPointType.LINK_NUMBER,
54
+ DataPointType.MODULE_NUMBER,
55
+ DataPointType.SW_VERSION,
56
+ DataPointType.HW_VERSION,
57
+ DataPointType.AUTO_REPORT_STATUS,
58
+ ]
59
+
60
+ def __init__(self, conbus_protocol: ConbusEventProtocol) -> None:
61
+ """Initialize the Conbus export service.
62
+
63
+ Args:
64
+ conbus_protocol: Protocol for Conbus communication.
65
+ """
66
+ self.logger = logging.getLogger(__name__)
67
+ self.conbus_protocol = conbus_protocol
68
+ self.telegram_service = TelegramService()
69
+
70
+ # State management
71
+ self.discovered_devices: list[str] = []
72
+ self.device_configs: dict[str, dict[str, Any]] = {}
73
+ self.device_datapoints_received: dict[str, set[str]] = {}
74
+ self.export_result = ConbusExportResponse(success=False)
75
+ self.export_status = "OK"
76
+ self._finalized = False # Track if export has been finalized
77
+
78
+ # Connect protocol signals
79
+ self.conbus_protocol.on_connection_made.connect(self.connection_made)
80
+ self.conbus_protocol.on_telegram_sent.connect(self.telegram_sent)
81
+ self.conbus_protocol.on_telegram_received.connect(self.telegram_received)
82
+ self.conbus_protocol.on_timeout.connect(self.timeout)
83
+ self.conbus_protocol.on_failed.connect(self.failed)
84
+
85
+ def connection_made(self) -> None:
86
+ """Handle connection established event."""
87
+ self.logger.debug("Connection established, starting discovery")
88
+
89
+ # Send DISCOVERY telegram
90
+ self.conbus_protocol.send_telegram(
91
+ telegram_type=TelegramType.SYSTEM,
92
+ serial_number="0000000000",
93
+ system_function=SystemFunction.DISCOVERY,
94
+ data_value="00",
95
+ )
96
+
97
+ def telegram_sent(self, telegram: str) -> None:
98
+ """Handle telegram sent event.
99
+
100
+ Args:
101
+ telegram: Telegram that was sent.
102
+ """
103
+ self.export_result.sent_telegrams.append(telegram)
104
+
105
+ def telegram_received(self, event: TelegramReceivedEvent) -> None:
106
+ """Handle telegram received event.
107
+
108
+ Args:
109
+ event: Telegram received event.
110
+ """
111
+ self.export_result.received_telegrams.append(event.telegram)
112
+
113
+ # Only process valid reply telegrams
114
+ if not event.checksum_valid or event.telegram_type != TelegramType.REPLY.value:
115
+ return
116
+
117
+ # Parse telegram using TelegramService
118
+ try:
119
+ parsed: ReplyTelegram = self.telegram_service.parse_reply_telegram(
120
+ event.frame
121
+ )
122
+ except Exception as e:
123
+ self.logger.debug(f"Failed to parse telegram: {e}")
124
+ return
125
+
126
+ # Check for discovery response (F01D)
127
+ if parsed.system_function == SystemFunction.DISCOVERY:
128
+ self._handle_discovery_response(parsed.serial_number)
129
+
130
+ # Check for datapoint response (F02D)
131
+ elif parsed.system_function == SystemFunction.READ_DATAPOINT:
132
+ if parsed.datapoint_type and parsed.data_value:
133
+ self._handle_datapoint_response(
134
+ parsed.serial_number, parsed.datapoint_type.value, parsed.data_value
135
+ )
136
+
137
+ def _handle_discovery_response(self, serial_number: str) -> None:
138
+ """Handle discovery response and query all datapoints.
139
+
140
+ Args:
141
+ serial_number: Serial number of discovered device.
142
+ """
143
+ if serial_number in self.discovered_devices:
144
+ self.logger.debug(f"Ignoring duplicate discovery: {serial_number}")
145
+ return
146
+
147
+ self.logger.debug(f"Device discovered: {serial_number}")
148
+ self.discovered_devices.append(serial_number)
149
+ self.device_configs[serial_number] = {"serial_number": serial_number}
150
+ self.device_datapoints_received[serial_number] = set()
151
+
152
+ # Emit progress signal
153
+ current = len(self.discovered_devices)
154
+ total = current # We don't know total until timeout
155
+ self.on_progress.emit(serial_number, current, total)
156
+
157
+ # Send all datapoint queries immediately (protocol handles throttling)
158
+ self.logger.debug(
159
+ f"Sending {len(self.DATAPOINT_SEQUENCE)} queries for {serial_number}"
160
+ )
161
+ for datapoint in self.DATAPOINT_SEQUENCE:
162
+ self.conbus_protocol.send_telegram(
163
+ telegram_type=TelegramType.SYSTEM,
164
+ serial_number=serial_number,
165
+ system_function=SystemFunction.READ_DATAPOINT,
166
+ data_value=datapoint.value,
167
+ )
168
+
169
+ def _handle_datapoint_response(
170
+ self, serial_number: str, datapoint_code: str, value: str
171
+ ) -> None:
172
+ """Handle datapoint response and store value.
173
+
174
+ Args:
175
+ serial_number: Serial number of device.
176
+ datapoint_code: Datapoint type code.
177
+ value: Datapoint value.
178
+ """
179
+ if serial_number not in self.device_configs:
180
+ self.logger.warning(
181
+ f"Received datapoint for unknown device: {serial_number}"
182
+ )
183
+ return
184
+
185
+ self.logger.debug(f"Datapoint {datapoint_code}={value} for {serial_number}")
186
+
187
+ # Store value in device config
188
+ datapoint = DataPointType.from_code(datapoint_code)
189
+ if datapoint:
190
+ self._store_datapoint_value(serial_number, datapoint, value)
191
+ self.device_datapoints_received[serial_number].add(datapoint_code)
192
+ self._check_device_complete(serial_number)
193
+ else:
194
+ self.logger.warning(f"Unknown datapoint code: {datapoint_code}")
195
+
196
+ def _store_datapoint_value(
197
+ self, serial_number: str, datapoint: DataPointType, value: str
198
+ ) -> None:
199
+ """Store datapoint value in device config.
200
+
201
+ Args:
202
+ serial_number: Serial number of device.
203
+ datapoint: Datapoint type.
204
+ value: Datapoint value.
205
+ """
206
+ config = self.device_configs[serial_number]
207
+
208
+ if datapoint == DataPointType.MODULE_TYPE:
209
+ config["module_type"] = value
210
+ elif datapoint == DataPointType.MODULE_TYPE_CODE:
211
+ try:
212
+ config["module_type_code"] = int(value)
213
+ except ValueError:
214
+ self.logger.warning(f"Invalid module_type_code: {value}")
215
+ elif datapoint == DataPointType.LINK_NUMBER:
216
+ try:
217
+ config["link_number"] = int(value)
218
+ except ValueError:
219
+ self.logger.warning(f"Invalid link_number: {value}")
220
+ elif datapoint == DataPointType.MODULE_NUMBER:
221
+ try:
222
+ config["module_number"] = int(value)
223
+ except ValueError:
224
+ self.logger.warning(f"Invalid module_number: {value}")
225
+ elif datapoint == DataPointType.SW_VERSION:
226
+ config["sw_version"] = value
227
+ elif datapoint == DataPointType.HW_VERSION:
228
+ config["hw_version"] = value
229
+ elif datapoint == DataPointType.AUTO_REPORT_STATUS:
230
+ config["auto_report_status"] = value
231
+
232
+ def _check_device_complete(self, serial_number: str) -> None:
233
+ """Check if device has all datapoints and emit completion signal.
234
+
235
+ Args:
236
+ serial_number: Serial number of device.
237
+ """
238
+ received = self.device_datapoints_received[serial_number]
239
+ expected = {dp.value for dp in self.DATAPOINT_SEQUENCE}
240
+
241
+ if received == expected:
242
+ self.logger.debug(f"Device {serial_number} complete (7/7 datapoints)")
243
+ config = self.device_configs[serial_number]
244
+
245
+ # Build ConsonModuleConfig with name based on link_number
246
+ try:
247
+ # Add required 'name' field as A{link_number}
248
+ if "name" not in config:
249
+ link_number = config.get("link_number", 0)
250
+ config["name"] = f"A{link_number}"
251
+ module_config = ConsonModuleConfig(**config)
252
+ self.on_device_exported.emit(module_config)
253
+ except Exception as e:
254
+ self.logger.error(f"Failed to build config for {serial_number}: {e}")
255
+
256
+ # Check if all devices complete
257
+ if all(
258
+ len(self.device_datapoints_received[sn]) == len(self.DATAPOINT_SEQUENCE)
259
+ for sn in self.discovered_devices
260
+ ):
261
+ self.logger.debug("All devices complete")
262
+ self._finalize_export()
263
+
264
+ def _finalize_export(self) -> None:
265
+ """Finalize export and write file."""
266
+ # Only finalize once
267
+ if self._finalized:
268
+ return
269
+
270
+ self._finalized = True
271
+ self.logger.info("Finalizing export")
272
+
273
+ if not self.discovered_devices:
274
+ self.export_status = "FAILED_NO_DEVICES"
275
+ self.export_result.success = False
276
+ self.export_result.error = "No devices found"
277
+ self.export_result.export_status = self.export_status
278
+ self.on_finish.emit(self.export_result)
279
+ return
280
+
281
+ # Build module list (including partial devices)
282
+ modules = []
283
+ for serial_number in self.discovered_devices:
284
+ config = self.device_configs[serial_number].copy()
285
+ try:
286
+ # Add required 'name' field as A{link_number} if not present
287
+ if "name" not in config:
288
+ link_number = config.get("link_number", 0)
289
+ config["name"] = f"A{link_number}"
290
+ # Only include fields that were received
291
+ module_config = ConsonModuleConfig(**config)
292
+ modules.append(module_config)
293
+ except Exception as e:
294
+ self.logger.warning(f"Partial device {serial_number}: {e}")
295
+
296
+ # Sort modules by link_number
297
+ modules.sort(key=lambda m: m.link_number if m.link_number is not None else 999)
298
+
299
+ # Create ConsonModuleListConfig
300
+ try:
301
+ module_list = ConsonModuleListConfig(root=modules)
302
+ self.export_result.config = module_list
303
+ self.export_result.device_count = len(modules)
304
+
305
+ # Write to file
306
+ self._write_export_file("export.yml")
307
+
308
+ self.export_result.success = True
309
+ self.export_result.export_status = self.export_status
310
+ self.on_finish.emit(self.export_result)
311
+
312
+ except Exception as e:
313
+ self.logger.error(f"Failed to create export: {e}")
314
+ self.export_status = "FAILED_WRITE"
315
+ self.export_result.success = False
316
+ self.export_result.error = str(e)
317
+ self.export_result.export_status = self.export_status
318
+ self.on_finish.emit(self.export_result)
319
+
320
+ def _write_export_file(self, path: str) -> None:
321
+ """Write export to YAML file.
322
+
323
+ Args:
324
+ path: Output file path.
325
+
326
+ Raises:
327
+ Exception: If file write fails.
328
+ """
329
+ try:
330
+ output_path = Path(path)
331
+
332
+ if self.export_result.config:
333
+ # Use Pydantic's model_dump to serialize, excluding only internal fields
334
+ data = self.export_result.config.model_dump(
335
+ exclude={
336
+ "root": {
337
+ "__all__": {
338
+ "enabled",
339
+ "conbus_ip",
340
+ "conbus_port",
341
+ "action_table",
342
+ }
343
+ }
344
+ },
345
+ exclude_none=True,
346
+ )
347
+
348
+ # Export as list at root level (not wrapped in 'root:' key)
349
+ modules_list = data.get("root", [])
350
+
351
+ with output_path.open("w") as f:
352
+ # Dump each module separately with blank lines between them
353
+ for i, module in enumerate(modules_list):
354
+ # Add blank line before each module except the first
355
+ if i > 0:
356
+ f.write("\n")
357
+
358
+ # Dump single item as list element
359
+ yaml_str = yaml.safe_dump(
360
+ [module],
361
+ default_flow_style=False,
362
+ sort_keys=False,
363
+ allow_unicode=True,
364
+ )
365
+ # Remove the trailing newline and write
366
+ f.write(yaml_str.rstrip("\n") + "\n")
367
+
368
+ self.logger.info(f"Export written to {path}")
369
+ self.export_result.output_file = path
370
+
371
+ except Exception as e:
372
+ self.logger.error(f"Failed to write export file: {e}")
373
+ self.export_status = "FAILED_WRITE"
374
+ raise
375
+
376
+ def timeout(self) -> None:
377
+ """Handle timeout event."""
378
+ timeout = self.conbus_protocol.timeout_seconds
379
+ self.logger.info(f"Export timeout after {timeout}s")
380
+
381
+ # Check if any devices incomplete
382
+ incomplete = [
383
+ sn
384
+ for sn in self.discovered_devices
385
+ if len(self.device_datapoints_received[sn]) < len(self.DATAPOINT_SEQUENCE)
386
+ ]
387
+
388
+ if incomplete:
389
+ self.logger.warning(f"Partial export: {len(incomplete)} incomplete devices")
390
+ self.export_status = "FAILED_TIMEOUT"
391
+
392
+ self._finalize_export()
393
+
394
+ def failed(self, message: str) -> None:
395
+ """Handle connection failure event.
396
+
397
+ Args:
398
+ message: Failure message.
399
+ """
400
+ self.logger.error(f"Connection failed: {message}")
401
+ self.export_status = "FAILED_CONNECTION"
402
+ self.export_result.success = False
403
+ self.export_result.error = message
404
+ self.export_result.export_status = self.export_status
405
+ self.on_finish.emit(self.export_result)
406
+
407
+ def set_timeout(self, timeout_seconds: float) -> None:
408
+ """Set timeout for export operation.
409
+
410
+ Args:
411
+ timeout_seconds: Timeout in seconds.
412
+ """
413
+ self.logger.debug(f"Set timeout: {timeout_seconds}s")
414
+ self.conbus_protocol.timeout_seconds = timeout_seconds
415
+
416
+ def set_event_loop(self, event_loop: asyncio.AbstractEventLoop) -> None:
417
+ """Set event loop for async operations.
418
+
419
+ Args:
420
+ event_loop: Event loop to use.
421
+ """
422
+ self.logger.debug("Set event loop")
423
+ self.conbus_protocol.set_event_loop(event_loop)
424
+
425
+ def start_reactor(self) -> None:
426
+ """Start the reactor."""
427
+ self.conbus_protocol.start_reactor()
428
+
429
+ def stop_reactor(self) -> None:
430
+ """Stop the reactor."""
431
+ self.conbus_protocol.stop_reactor()
432
+
433
+ def __enter__(self) -> "ConbusExportService":
434
+ """Enter context manager.
435
+
436
+ Returns:
437
+ Self for context manager protocol.
438
+ """
439
+ # Reset state for reuse
440
+ self.discovered_devices = []
441
+ self.device_configs = {}
442
+ self.device_datapoints_received = {}
443
+ self.export_result = ConbusExportResponse(success=False)
444
+ self.export_status = "OK"
445
+ self._finalized = False
446
+ return self
447
+
448
+ def __exit__(
449
+ self, _exc_type: Optional[type], _exc_val: Optional[Exception], _exc_tb: Any
450
+ ) -> None:
451
+ """Exit context manager and disconnect signals."""
452
+ self.conbus_protocol.on_connection_made.disconnect(self.connection_made)
453
+ self.conbus_protocol.on_telegram_sent.disconnect(self.telegram_sent)
454
+ self.conbus_protocol.on_telegram_received.disconnect(self.telegram_received)
455
+ self.conbus_protocol.on_timeout.disconnect(self.timeout)
456
+ self.conbus_protocol.on_failed.disconnect(self.failed)
457
+ self.on_progress.disconnect()
458
+ self.on_device_exported.disconnect()
459
+ self.on_finish.disconnect()
460
+ self.stop_reactor()
@@ -0,0 +1,69 @@
1
+ """Client buffer manager for broadcasting telegrams to connected clients.
2
+
3
+ This module provides thread-safe management of per-client telegram queues,
4
+ enabling broadcast of telegrams from device services to all connected clients.
5
+ """
6
+
7
+ import queue
8
+ import socket
9
+ import threading
10
+ from typing import Dict, Optional
11
+
12
+
13
+ class ClientBufferManager:
14
+ """
15
+ Thread-safe manager for client telegram queues.
16
+
17
+ Manages individual queues for each connected client, providing thread-safe
18
+ operations for client registration, unregistration, and telegram broadcasting.
19
+ """
20
+
21
+ def __init__(self) -> None:
22
+ """Initialize the client buffer manager."""
23
+ self._buffers: Dict[socket.socket, queue.Queue[str]] = {}
24
+ self._lock = threading.Lock()
25
+
26
+ def register_client(self, client_socket: socket.socket) -> queue.Queue[str]:
27
+ """Register a new client and create its telegram queue.
28
+
29
+ Args:
30
+ client_socket: The socket of the connecting client.
31
+
32
+ Returns:
33
+ The newly created queue for this client.
34
+ """
35
+ with self._lock:
36
+ client_queue: queue.Queue[str] = queue.Queue()
37
+ self._buffers[client_socket] = client_queue
38
+ return client_queue
39
+
40
+ def unregister_client(self, client_socket: socket.socket) -> None:
41
+ """Unregister a client and remove its telegram queue.
42
+
43
+ Args:
44
+ client_socket: The socket of the disconnecting client.
45
+ """
46
+ with self._lock:
47
+ self._buffers.pop(client_socket, None)
48
+
49
+ def broadcast(self, telegram: str) -> None:
50
+ """Broadcast a telegram to all connected clients.
51
+
52
+ Args:
53
+ telegram: The telegram string to broadcast.
54
+ """
55
+ with self._lock:
56
+ for client_queue in self._buffers.values():
57
+ client_queue.put(telegram)
58
+
59
+ def get_queue(self, client_socket: socket.socket) -> Optional[queue.Queue[str]]:
60
+ """Retrieve the queue for a specific client.
61
+
62
+ Args:
63
+ client_socket: The socket of the client.
64
+
65
+ Returns:
66
+ The client's queue if registered, None otherwise.
67
+ """
68
+ with self._lock:
69
+ return self._buffers.get(client_socket)
@@ -5,6 +5,7 @@ Discover Request telegrams with configurable device information.
5
5
  """
6
6
 
7
7
  import logging
8
+ import queue
8
9
  import socket
9
10
  import threading
10
11
  from pathlib import Path
@@ -15,6 +16,7 @@ from xp.models.homekit.homekit_conson_config import (
15
16
  ConsonModuleListConfig,
16
17
  )
17
18
  from xp.services.server.base_server_service import BaseServerService
19
+ from xp.services.server.client_buffer_manager import ClientBufferManager
18
20
  from xp.services.server.device_service_factory import DeviceServiceFactory
19
21
  from xp.services.telegram.telegram_discover_service import TelegramDiscoverService
20
22
  from xp.services.telegram.telegram_service import TelegramService
@@ -68,7 +70,7 @@ class ServerService:
68
70
  None # Background thread for storm
69
71
  )
70
72
  self.collector_stop_event = threading.Event() # Event to stop thread
71
- self.collector_buffer: list[str] = [] # All collected buffers
73
+ self.client_buffers = ClientBufferManager() # Per-client queue manager
72
74
 
73
75
  # Set up logging
74
76
  self.logger = logging.getLogger(__name__)
@@ -191,6 +193,9 @@ class ServerService:
191
193
  self, client_socket: socket.socket, client_address: tuple[str, int]
192
194
  ) -> None:
193
195
  """Handle individual client connection."""
196
+ # Register client and get its dedicated queue
197
+ client_queue = self.client_buffers.register_client(client_socket)
198
+
194
199
  try:
195
200
 
196
201
  idle_timeout = 300
@@ -202,11 +207,14 @@ class ServerService:
202
207
 
203
208
  while True:
204
209
 
205
- # send waiting buffer
206
- for i in range(len(self.collector_buffer)):
207
- buffer = self.collector_buffer.pop()
208
- client_socket.send(buffer.encode("latin-1"))
209
- self.logger.debug(f"Sent buffer to {client_address}")
210
+ # send waiting buffer from client's dedicated queue
211
+ while not client_queue.empty():
212
+ try:
213
+ buffer = client_queue.get_nowait()
214
+ client_socket.send(buffer.encode("latin-1"))
215
+ self.logger.debug(f"Sent buffer to {client_address}")
216
+ except queue.Empty:
217
+ break
210
218
 
211
219
  # Receive data from client
212
220
  data = None
@@ -230,11 +238,8 @@ class ServerService:
230
238
 
231
239
  # Process request (discover or data request)
232
240
  responses = self._process_request(message)
233
-
234
- # Send responses
235
241
  for response in responses:
236
- client_socket.send(response.encode("latin-1"))
237
- self.logger.debug(f"Sent to {client_address}: {response[:-1]}")
242
+ self.client_buffers.broadcast(response)
238
243
 
239
244
  except socket.timeout:
240
245
  self.logger.debug(f"Client {client_address} timed out")
@@ -242,6 +247,8 @@ class ServerService:
242
247
  self.logger.error(f"Error handling client {client_address}: {e}")
243
248
  finally:
244
249
  try:
250
+ # Unregister client before closing socket
251
+ self.client_buffers.unregister_client(client_socket)
245
252
  client_socket.close()
246
253
  self.logger.info(f"Client {client_address} disconnected")
247
254
  except Exception as e:
@@ -330,6 +337,8 @@ class ServerService:
330
337
  self.logger.warning(f"Failed to parse telegram: {telegram}")
331
338
  return responses
332
339
 
340
+ self.client_buffers.broadcast(parsed_telegram.raw_telegram)
341
+
333
342
  # Handle discover requests
334
343
  if self.discover_service.is_discover_request(parsed_telegram):
335
344
  for device_service in self.device_services.values():
@@ -421,7 +430,8 @@ class ServerService:
421
430
  collected = 0
422
431
  for device_service in self.device_services.values():
423
432
  telegram_buffer = device_service.collect_telegram_buffer()
424
- self.collector_buffer.extend(telegram_buffer)
433
+ for telegram in telegram_buffer:
434
+ self.client_buffers.broadcast(telegram)
425
435
  collected += len(telegram_buffer)
426
436
 
427
437
  # Wait a bit before checking again
@@ -2,13 +2,14 @@
2
2
 
3
3
  import logging
4
4
  from datetime import datetime
5
- from typing import Dict, List
5
+ from typing import Dict, List, Optional
6
6
 
7
7
  from psygnal import Signal
8
8
 
9
9
  from xp.models.homekit.homekit_conson_config import ConsonModuleListConfig
10
10
  from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
11
11
  from xp.models.telegram.datapoint_type import DataPointType
12
+ from xp.models.telegram.module_type_code import ModuleTypeCode
12
13
  from xp.models.telegram.system_function import SystemFunction
13
14
  from xp.models.telegram.telegram_type import TelegramType
14
15
  from xp.models.term.connection_state import ConnectionState
@@ -78,6 +79,7 @@ class StateMonitorService:
78
79
  name=module_config.name,
79
80
  serial_number=module_config.serial_number,
80
81
  module_type=module_config.module_type,
82
+ link_number=module_config.link_number,
81
83
  outputs="", # Empty initially
82
84
  auto_report=auto_report,
83
85
  error_status="OK",
@@ -239,15 +241,24 @@ class StateMonitorService:
239
241
  def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
240
242
  """Handle telegram received event.
241
243
 
242
- Parse output states from telegram and update module state.
244
+ Routes telegrams to appropriate handlers based on type.
245
+ Processes reply telegrams for datapoint queries and event telegrams for state changes.
243
246
 
244
247
  Args:
245
248
  event: Telegram received event.
246
249
  """
247
- # Only process reply telegrams
248
- if event.telegram_type != TelegramType.REPLY:
249
- return
250
+ # Route based on telegram type
251
+ if event.telegram_type == TelegramType.REPLY:
252
+ self._handle_reply_telegram(event)
253
+ elif event.telegram_type == TelegramType.EVENT:
254
+ self._handle_event_telegram(event)
255
+
256
+ def _handle_reply_telegram(self, event: TelegramReceivedEvent) -> None:
257
+ """Handle reply telegram for datapoint queries.
250
258
 
259
+ Args:
260
+ event: Telegram received event.
261
+ """
251
262
  serial_number = event.serial_number
252
263
  if not serial_number or serial_number not in self._module_states:
253
264
  return
@@ -289,6 +300,95 @@ class StateMonitorService:
289
300
  self.on_connection_state_changed.emit(self._connection_state)
290
301
  self.on_status_message.emit(f"Protocol error: {failure}")
291
302
 
303
+ def _find_module_by_link(self, link_number: int) -> Optional[ModuleState]:
304
+ """Find module state by link number.
305
+
306
+ Args:
307
+ link_number: Link number to search for.
308
+
309
+ Returns:
310
+ ModuleState if found, None otherwise.
311
+ """
312
+ for module_state in self._module_states.values():
313
+ if module_state.link_number == link_number:
314
+ return module_state
315
+ return None
316
+
317
+ def _update_output_bit(
318
+ self, module_state: ModuleState, output_number: int, output_state: bool
319
+ ) -> None:
320
+ """Update a single output bit in module state.
321
+
322
+ Args:
323
+ module_state: Module state to update.
324
+ output_number: Output number (0-3 for XP24).
325
+ output_state: True for ON, False for OFF.
326
+ """
327
+ # Parse existing outputs string "0 1 0 0" → [0, 1, 0, 0]
328
+ outputs = module_state.outputs.split() if module_state.outputs else []
329
+
330
+ # Ensure we have enough outputs
331
+ while len(outputs) <= output_number:
332
+ outputs.append("0")
333
+
334
+ # Update the specific output
335
+ outputs[output_number] = "1" if output_state else "0"
336
+
337
+ # Convert back to string format
338
+ module_state.outputs = " ".join(outputs)
339
+
340
+ def _handle_event_telegram(self, event: TelegramReceivedEvent) -> None:
341
+ """Handle event telegram for output state changes.
342
+
343
+ Processes XP24 output event telegrams to update module state in real-time.
344
+ Output events use input_number field with values 80-83 to represent outputs 0-3.
345
+
346
+ Args:
347
+ event: Telegram received event containing event telegram.
348
+ """
349
+ # Parse the event telegram
350
+ event_telegram = self._telegram_service.parse_event_telegram(event.frame)
351
+ if not event_telegram:
352
+ self.logger.debug("Failed to parse event telegram")
353
+ return
354
+
355
+ # Only process XP24 output events
356
+ if event_telegram.module_type != ModuleTypeCode.XP24.value:
357
+ self.logger.debug(
358
+ f"Ignoring event from module type {event_telegram.module_type}"
359
+ )
360
+ return
361
+
362
+ # Validate output number range (80-83 for XP24 outputs 0-3)
363
+ if not (80 <= event_telegram.input_number <= 83):
364
+ self.logger.debug(
365
+ f"Ignoring input event I{event_telegram.input_number:02d}"
366
+ )
367
+ return
368
+
369
+ # Find module by link number
370
+ module_state = self._find_module_by_link(event_telegram.link_number)
371
+ if not module_state:
372
+ self.logger.debug(
373
+ f"Module not found for link number {event_telegram.link_number}"
374
+ )
375
+ return
376
+
377
+ # Convert input_number to output_number (80→0, 81→1, 82→2, 83→3)
378
+ output_number = event_telegram.input_number - 80
379
+ output_state = event_telegram.is_button_press # M=True, B=False
380
+
381
+ # Update output state
382
+ self._update_output_bit(module_state, output_number, output_state)
383
+ module_state.last_update = datetime.now()
384
+
385
+ # Emit signal for UI update
386
+ self.on_module_state_changed.emit(module_state)
387
+ self.logger.debug(
388
+ f"Updated {module_state.name} output {output_number} to "
389
+ f"{'ON' if output_state else 'OFF'}"
390
+ )
391
+
292
392
  def cleanup(self) -> None:
293
393
  """Clean up service resources."""
294
394
  self._disconnect_signals()
@@ -3,6 +3,7 @@
3
3
  from datetime import datetime
4
4
  from typing import Any, List, Optional
5
5
 
6
+ from rich.text import Text
6
7
  from textual.app import ComposeResult
7
8
  from textual.widgets import DataTable, Static
8
9
 
@@ -14,7 +15,7 @@ class ModulesListWidget(Static):
14
15
  """Widget displaying module states in a data table.
15
16
 
16
17
  Shows module information with real-time updates from StateMonitorService.
17
- Table displays: name, serial_number, module_type, outputs, report, status, last_update.
18
+ Table displays: name, serial_number, module_type, link_number, outputs, report, status, last_update.
18
19
 
19
20
  Attributes:
20
21
  service: StateMonitorService for module state updates.
@@ -54,11 +55,9 @@ class ModulesListWidget(Static):
54
55
  self.border_title = "Modules"
55
56
 
56
57
  if self.table:
57
- # Set table to full width
58
- self.table.styles.width = "100%"
59
-
60
58
  # Setup table columns
61
59
  self.table.add_column("name", key="name")
60
+ self.table.add_column("link", key="link_number")
62
61
  self.table.add_column("serial number", key="serial_number")
63
62
  self.table.add_column("module type", key="module_type")
64
63
  self.table.add_column("outputs", key="outputs")
@@ -115,13 +114,17 @@ class ModulesListWidget(Static):
115
114
  row_key, "outputs", self._format_outputs(module_state.outputs)
116
115
  )
117
116
  self.table.update_cell(
118
- row_key, "report", self._format_report(module_state.auto_report)
117
+ row_key,
118
+ "report",
119
+ Text(self._format_report(module_state.auto_report), justify="center"),
119
120
  )
120
121
  self.table.update_cell(row_key, "status", module_state.error_status)
121
122
  self.table.update_cell(
122
123
  row_key,
123
124
  "last_update",
124
- self._format_last_update(module_state.last_update),
125
+ Text(
126
+ self._format_last_update(module_state.last_update), justify="center"
127
+ ),
125
128
  )
126
129
  else:
127
130
  # Add new row
@@ -138,12 +141,13 @@ class ModulesListWidget(Static):
138
141
 
139
142
  row_key = self.table.add_row(
140
143
  module_state.name,
144
+ Text(str(module_state.link_number), justify="right"),
141
145
  module_state.serial_number,
142
146
  module_state.module_type,
143
147
  self._format_outputs(module_state.outputs),
144
- self._format_report(module_state.auto_report),
148
+ Text(self._format_report(module_state.auto_report), justify="center"),
145
149
  module_state.error_status,
146
- self._format_last_update(module_state.last_update),
150
+ Text(self._format_last_update(module_state.last_update), justify="center"),
147
151
  )
148
152
  self._row_keys[module_state.serial_number] = row_key
149
153
 
@@ -213,5 +217,8 @@ class ModulesListWidget(Static):
213
217
  self.table.update_cell(
214
218
  row_key,
215
219
  "last_update",
216
- self._format_last_update(module_state.last_update),
220
+ Text(
221
+ self._format_last_update(module_state.last_update),
222
+ justify="center",
223
+ ),
217
224
  )
xp/utils/dependencies.py CHANGED
@@ -47,6 +47,7 @@ from xp.services.conbus.conbus_datapoint_service import (
47
47
  from xp.services.conbus.conbus_discover_service import ConbusDiscoverService
48
48
  from xp.services.conbus.conbus_event_list_service import ConbusEventListService
49
49
  from xp.services.conbus.conbus_event_raw_service import ConbusEventRawService
50
+ from xp.services.conbus.conbus_export_service import ConbusExportService
50
51
  from xp.services.conbus.conbus_output_service import ConbusOutputService
51
52
  from xp.services.conbus.conbus_raw_service import ConbusRawService
52
53
  from xp.services.conbus.conbus_receive_service import ConbusReceiveService
@@ -201,6 +202,14 @@ class ServiceContainer:
201
202
  scope=punq.Scope.singleton,
202
203
  )
203
204
 
205
+ self.container.register(
206
+ ConbusExportService,
207
+ factory=lambda: ConbusExportService(
208
+ conbus_protocol=self.container.resolve(ConbusEventProtocol)
209
+ ),
210
+ scope=punq.Scope.singleton,
211
+ )
212
+
204
213
  # Terminal UI
205
214
  self.container.register(
206
215
  ProtocolMonitorService,