conson-xp 1.29.0__py3-none-any.whl → 1.33.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.29.0
3
+ Version: 1.33.0
4
4
  Summary: XP Protocol Communication Tools
5
5
  Author-Email: ldvchosal <ldvchosal@github.com>
6
6
  License: MIT License
@@ -91,6 +91,9 @@ Comprehensive type safety and robust error handling
91
91
  # Install with PIP (recommended)
92
92
  pip install conson-xp
93
93
 
94
+ # Export your Conbus device configuration (recommended first step)
95
+ xp conbus export
96
+
94
97
  # Parse a telegram
95
98
  xp telegram parse "<E14L00I02MAK>"
96
99
 
@@ -133,7 +136,29 @@ xp telegram validate "<E14L00I02MAK>"
133
136
  ```
134
137
 
135
138
  **Device Communication**
139
+
140
+ > **⚠️ Important**: Bridge modules (XP130, XP230) accept **only one TCP connection at a time**.
141
+ > Close any existing connections (including the official app) before using xp commands.
142
+
136
143
  ```bash
144
+ # Export device configuration (RECOMMENDED - run this first!)
145
+ # Discovers all devices and exports complete configuration to export.yml
146
+ xp conbus export
147
+
148
+ # What it does:
149
+ # - Automatically discovers all devices on the Conbus network
150
+ # - Queries 7 datapoints per device (type, version, link number, etc.)
151
+ # - Generates export.yml in conson.yml format
152
+ # - Shows real-time progress for each device
153
+ # - Handles timeouts gracefully with partial exports
154
+ #
155
+ # Example output:
156
+ # Querying device 1/12: 0020041013...
157
+ # ✓ Module type: X130 (1)
158
+ # ✓ Link number: 1
159
+ # ✓ Software version: V2.3
160
+ # Export complete: export.yml (12 devices)
161
+
137
162
  # Discover XP servers on your network
138
163
  xp conbus discover
139
164
 
@@ -325,6 +350,7 @@ xp conbus event
325
350
  xp conbus event list
326
351
  xp conbus event raw
327
352
 
353
+ xp conbus export
328
354
 
329
355
  xp conbus lightlevel
330
356
  xp conbus lightlevel get
@@ -1,8 +1,8 @@
1
- conson_xp-1.29.0.dist-info/METADATA,sha256=k_I_mYoEaWaCsYx4kk3JOZ5FaBuAFkggSTKk28JMyGk,10312
2
- conson_xp-1.29.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
- conson_xp-1.29.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
- conson_xp-1.29.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
- xp/__init__.py,sha256=CTv0SXJngPEN2pQxHNESqU87b-94c8Ke6ShS2yRsqDA,181
1
+ conson_xp-1.33.0.dist-info/METADATA,sha256=d45wB_nZpHtmmR-UmL0LBDxxSrFHQPg7lEbjJ9xh4pA,11246
2
+ conson_xp-1.33.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
+ conson_xp-1.33.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
+ conson_xp-1.33.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
+ xp/__init__.py,sha256=xf8g0wtHGRi3IkElEPi7CY0QCBafApMNmgFEsTF41bU,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
@@ -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=3Zb58qqRDNR9gA4rQ_fyT--ZgRIK_lkqnXJFbQnrZOA,17300
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
@@ -195,10 +198,10 @@ xp/term/widgets/protocol_log.py,sha256=CJUpckWj7GC1kVqixDadteyGnI4aHyzd4kkH-pSbz
195
198
  xp/term/widgets/status_footer.py,sha256=bxrcqKzJ9V0aPSn_WwraVpJz7NxBUh3yIjA3fwv5nVA,3256
196
199
  xp/utils/__init__.py,sha256=_avMF_UOkfR3tNeDIPqQ5odmbq5raKkaq1rZ9Cn1CJs,332
197
200
  xp/utils/checksum.py,sha256=HDpiQxmdIedbCbZ4o_Box0i_Zig417BtCV_46ZyhiTk,1711
198
- xp/utils/dependencies.py,sha256=UmVAEpGqEG6Li0h6u6I-mFgBTu6dsTeWjWUnfaGFofQ,24227
201
+ xp/utils/dependencies.py,sha256=d91Xt4PwnyeMB_tLB-hNDpm95QGMg5uiq52yvOM9BBE,24557
199
202
  xp/utils/event_helper.py,sha256=W-A_xmoXlpWZBbJH6qdaN50o3-XrwFsDgvAGMJDiAgo,1001
200
203
  xp/utils/logging.py,sha256=rZDXwlBrYK8A6MPq5StsMNpgsRowzJXM6fvROPwJdGM,3750
201
204
  xp/utils/serialization.py,sha256=RWHHk86feaB4ZP7rjE4qOWK0900yg2joUBDkP76gfOY,4618
202
205
  xp/utils/state_machine.py,sha256=Oe2sLwCh9z_vr1tF6X0ZRGTeuckRQAGzmef7xc9CNdc,2413
203
206
  xp/utils/time_utils.py,sha256=dEyViDlAG9GWU-J3D_YVa-sGma6yiyyMTgN4h2x3PY4,3781
204
- conson_xp-1.29.0.dist-info/RECORD,,
207
+ conson_xp-1.33.0.dist-info/RECORD,,
xp/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  conson-xp package.
4
4
  """
5
5
 
6
- __version__ = "1.29.0"
6
+ __version__ = "1.33.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)
@@ -0,0 +1,452 @@
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: Device configurations (ConsonModuleConfig instances).
35
+ export_result: Final export result.
36
+ export_status: Export status (OK, FAILED_TIMEOUT, etc.).
37
+ on_progress: Signal emitted on device discovery (serial, current, total).
38
+ on_device_exported: Signal emitted when device export completes.
39
+ on_finish: Signal emitted when export finishes.
40
+ DATAPOINT_SEQUENCE: Sequence of 7 datapoints to query for each device.
41
+ """
42
+
43
+ # Signals (class attributes)
44
+ on_progress: Signal = Signal(str, int, int) # serial, current, total
45
+ on_device_exported: Signal = Signal(ConsonModuleConfig)
46
+ on_finish: Signal = Signal(ConbusExportResponse)
47
+
48
+ # Datapoint sequence to query for each device
49
+ DATAPOINT_SEQUENCE = [
50
+ DataPointType.MODULE_TYPE,
51
+ DataPointType.MODULE_TYPE_CODE,
52
+ DataPointType.LINK_NUMBER,
53
+ DataPointType.MODULE_NUMBER,
54
+ DataPointType.SW_VERSION,
55
+ DataPointType.HW_VERSION,
56
+ DataPointType.AUTO_REPORT_STATUS,
57
+ ]
58
+
59
+ def __init__(self, conbus_protocol: ConbusEventProtocol) -> None:
60
+ """Initialize the Conbus export service.
61
+
62
+ Args:
63
+ conbus_protocol: Protocol for Conbus communication.
64
+ """
65
+ self.logger = logging.getLogger(__name__)
66
+ self.conbus_protocol = conbus_protocol
67
+ self.telegram_service = TelegramService()
68
+
69
+ # State management
70
+ self.discovered_devices: list[str] = []
71
+ self.device_configs: dict[str, ConsonModuleConfig] = {}
72
+ self.export_result = ConbusExportResponse(success=False)
73
+ self.export_status = "OK"
74
+ self._finalized = False # Track if export has been finalized
75
+
76
+ # Connect protocol signals
77
+ self.conbus_protocol.on_connection_made.connect(self.connection_made)
78
+ self.conbus_protocol.on_telegram_sent.connect(self.telegram_sent)
79
+ self.conbus_protocol.on_telegram_received.connect(self.telegram_received)
80
+ self.conbus_protocol.on_timeout.connect(self.timeout)
81
+ self.conbus_protocol.on_failed.connect(self.failed)
82
+
83
+ def connection_made(self) -> None:
84
+ """Handle connection established event."""
85
+ self.logger.debug("Connection established, starting discovery")
86
+
87
+ # Send DISCOVERY telegram
88
+ self.conbus_protocol.send_telegram(
89
+ telegram_type=TelegramType.SYSTEM,
90
+ serial_number="0000000000",
91
+ system_function=SystemFunction.DISCOVERY,
92
+ data_value="00",
93
+ )
94
+
95
+ def telegram_sent(self, telegram: str) -> None:
96
+ """Handle telegram sent event.
97
+
98
+ Args:
99
+ telegram: Telegram that was sent.
100
+ """
101
+ self.export_result.sent_telegrams.append(telegram)
102
+
103
+ def telegram_received(self, event: TelegramReceivedEvent) -> None:
104
+ """Handle telegram received event.
105
+
106
+ Args:
107
+ event: Telegram received event.
108
+ """
109
+ self.export_result.received_telegrams.append(event.telegram)
110
+
111
+ # Only process valid reply telegrams
112
+ if not event.checksum_valid or event.telegram_type != TelegramType.REPLY.value:
113
+ return
114
+
115
+ # Parse telegram using TelegramService
116
+ try:
117
+ parsed: ReplyTelegram = self.telegram_service.parse_reply_telegram(
118
+ event.frame
119
+ )
120
+ except Exception as e:
121
+ self.logger.debug(f"Failed to parse telegram: {e}")
122
+ return
123
+
124
+ # Check for discovery response (F01D)
125
+ if parsed.system_function == SystemFunction.DISCOVERY:
126
+ self._handle_discovery_response(parsed.serial_number)
127
+
128
+ # Check for datapoint response (F02D)
129
+ elif parsed.system_function == SystemFunction.READ_DATAPOINT:
130
+ if parsed.datapoint_type and parsed.data_value:
131
+ self._handle_datapoint_response(
132
+ parsed.serial_number, parsed.datapoint_type.value, parsed.data_value
133
+ )
134
+
135
+ def _handle_discovery_response(self, serial_number: str) -> None:
136
+ """Handle discovery response and query all datapoints.
137
+
138
+ Args:
139
+ serial_number: Serial number of discovered device.
140
+ """
141
+ if serial_number in self.discovered_devices:
142
+ self.logger.debug(f"Ignoring duplicate discovery: {serial_number}")
143
+ return
144
+
145
+ self.logger.debug(f"Device discovered: {serial_number}")
146
+ self.discovered_devices.append(serial_number)
147
+
148
+ # Create ConsonModuleConfig with placeholder values for required fields
149
+ module = ConsonModuleConfig(
150
+ name="UNKNOWN", # Will be updated when link_number arrives
151
+ serial_number=serial_number,
152
+ module_type="UNKNOWN", # Required field
153
+ module_type_code=0, # Required field
154
+ link_number=0, # Required field
155
+ )
156
+ self.device_configs[serial_number] = module
157
+
158
+ # Emit progress signal
159
+ current = len(self.discovered_devices)
160
+ total = current # We don't know total until timeout
161
+ self.on_progress.emit(serial_number, current, total)
162
+
163
+ # Send all datapoint queries immediately (protocol handles throttling)
164
+ self.logger.debug(
165
+ f"Sending {len(self.DATAPOINT_SEQUENCE)} queries for {serial_number}"
166
+ )
167
+ for datapoint in self.DATAPOINT_SEQUENCE:
168
+ self.conbus_protocol.send_telegram(
169
+ telegram_type=TelegramType.SYSTEM,
170
+ serial_number=serial_number,
171
+ system_function=SystemFunction.READ_DATAPOINT,
172
+ data_value=datapoint.value,
173
+ )
174
+
175
+ def _handle_datapoint_response(
176
+ self, serial_number: str, datapoint_code: str, value: str
177
+ ) -> None:
178
+ """Handle datapoint response and store value.
179
+
180
+ Args:
181
+ serial_number: Serial number of device.
182
+ datapoint_code: Datapoint type code.
183
+ value: Datapoint value.
184
+ """
185
+ if serial_number not in self.device_configs:
186
+ self.logger.warning(
187
+ f"Received datapoint for unknown device: {serial_number}"
188
+ )
189
+ return
190
+
191
+ self.logger.debug(f"Datapoint {datapoint_code}={value} for {serial_number}")
192
+
193
+ # Store value in device config
194
+ datapoint = DataPointType.from_code(datapoint_code)
195
+ if datapoint:
196
+ self._store_datapoint_value(serial_number, datapoint, value)
197
+ self._check_device_complete(serial_number)
198
+ else:
199
+ self.logger.warning(f"Unknown datapoint code: {datapoint_code}")
200
+
201
+ def _store_datapoint_value(
202
+ self, serial_number: str, datapoint: DataPointType, value: str
203
+ ) -> None:
204
+ """Store datapoint value in device config.
205
+
206
+ Args:
207
+ serial_number: Serial number of device.
208
+ datapoint: Datapoint type.
209
+ value: Datapoint value.
210
+ """
211
+ module = self.device_configs[serial_number]
212
+
213
+ try:
214
+ if datapoint == DataPointType.MODULE_TYPE:
215
+ module.module_type = value
216
+ elif datapoint == DataPointType.MODULE_TYPE_CODE:
217
+ module.module_type_code = int(value)
218
+ elif datapoint == DataPointType.LINK_NUMBER:
219
+ link = int(value)
220
+ module.link_number = link
221
+ module.name = f"A{link}"
222
+ elif datapoint == DataPointType.MODULE_NUMBER:
223
+ module.module_number = int(value)
224
+ elif datapoint == DataPointType.SW_VERSION:
225
+ module.sw_version = value
226
+ elif datapoint == DataPointType.HW_VERSION:
227
+ module.hw_version = value
228
+ elif datapoint == DataPointType.AUTO_REPORT_STATUS:
229
+ module.auto_report_status = value
230
+ except (ValueError, TypeError) as e:
231
+ self.logger.warning(f"Invalid value '{value}' for {datapoint.name}: {e}")
232
+
233
+ def _is_device_complete(self, serial_number: str) -> bool:
234
+ """Check if a device has all required datapoints.
235
+
236
+ Args:
237
+ serial_number: Serial number of device.
238
+
239
+ Returns:
240
+ True if device is complete, False otherwise.
241
+ """
242
+ module = self.device_configs[serial_number]
243
+ return all(
244
+ [
245
+ module.module_type not in ("UNKNOWN", None, ""),
246
+ module.module_type_code is not None and module.module_type_code > 0,
247
+ module.link_number is not None and module.link_number > 0,
248
+ module.sw_version is not None,
249
+ module.hw_version is not None,
250
+ module.auto_report_status is not None,
251
+ module.module_number is not None,
252
+ ]
253
+ )
254
+
255
+ def _check_device_complete(self, serial_number: str) -> None:
256
+ """Check if device has all datapoints and emit completion signal.
257
+
258
+ Args:
259
+ serial_number: Serial number of device.
260
+ """
261
+ if self._is_device_complete(serial_number):
262
+ self.logger.debug(f"Device {serial_number} complete (7/7 datapoints)")
263
+ module = self.device_configs[serial_number]
264
+ self.on_device_exported.emit(module)
265
+
266
+ # Check if all devices complete
267
+ if all(self._is_device_complete(sn) for sn in self.discovered_devices):
268
+ self.logger.debug("All devices complete")
269
+ self._finalize_export()
270
+
271
+ def _finalize_export(self) -> None:
272
+ """Finalize export and write file."""
273
+ # Only finalize once
274
+ if self._finalized:
275
+ return
276
+
277
+ self._finalized = True
278
+ self.logger.info("Finalizing export")
279
+
280
+ if not self.discovered_devices:
281
+ self.export_status = "FAILED_NO_DEVICES"
282
+ self.export_result.success = False
283
+ self.export_result.error = "No devices found"
284
+ self.export_result.export_status = self.export_status
285
+ self.on_finish.emit(self.export_result)
286
+ return
287
+
288
+ # Convert dict values to list (already ConsonModuleConfig instances!)
289
+ modules = list(self.device_configs.values())
290
+
291
+ # Sort modules by link_number
292
+ modules.sort(key=lambda m: m.link_number if m.link_number is not None else 999)
293
+
294
+ # Create ConsonModuleListConfig
295
+ try:
296
+ module_list = ConsonModuleListConfig(root=modules)
297
+ self.export_result.config = module_list
298
+ self.export_result.device_count = len(modules)
299
+
300
+ # Write to file
301
+ self._write_export_file("export.yml")
302
+
303
+ self.export_result.success = True
304
+ self.export_result.export_status = self.export_status
305
+ self.on_finish.emit(self.export_result)
306
+
307
+ except Exception as e:
308
+ self.logger.error(f"Failed to create export: {e}")
309
+ self.export_status = "FAILED_WRITE"
310
+ self.export_result.success = False
311
+ self.export_result.error = str(e)
312
+ self.export_result.export_status = self.export_status
313
+ self.on_finish.emit(self.export_result)
314
+
315
+ def _write_export_file(self, path: str) -> None:
316
+ """Write export to YAML file.
317
+
318
+ Args:
319
+ path: Output file path.
320
+
321
+ Raises:
322
+ Exception: If file write fails.
323
+ """
324
+ try:
325
+ output_path = Path(path)
326
+
327
+ if self.export_result.config:
328
+ # Use Pydantic's model_dump to serialize, excluding only internal fields
329
+ data = self.export_result.config.model_dump(
330
+ exclude={
331
+ "root": {
332
+ "__all__": {
333
+ "enabled",
334
+ "conbus_ip",
335
+ "conbus_port",
336
+ "action_table",
337
+ }
338
+ }
339
+ },
340
+ exclude_none=True,
341
+ )
342
+
343
+ # Export as list at root level (not wrapped in 'root:' key)
344
+ modules_list = data.get("root", [])
345
+
346
+ with output_path.open("w") as f:
347
+ # Dump each module separately with blank lines between them
348
+ for i, module in enumerate(modules_list):
349
+ # Add blank line before each module except the first
350
+ if i > 0:
351
+ f.write("\n")
352
+
353
+ # Dump single item as list element
354
+ yaml_str = yaml.safe_dump(
355
+ [module],
356
+ default_flow_style=False,
357
+ sort_keys=False,
358
+ allow_unicode=True,
359
+ )
360
+ # Remove the trailing newline and write
361
+ f.write(yaml_str.rstrip("\n") + "\n")
362
+
363
+ self.logger.info(f"Export written to {path}")
364
+ self.export_result.output_file = path
365
+
366
+ except Exception as e:
367
+ self.logger.error(f"Failed to write export file: {e}")
368
+ self.export_status = "FAILED_WRITE"
369
+ raise
370
+
371
+ def timeout(self) -> None:
372
+ """Handle timeout event."""
373
+ timeout = self.conbus_protocol.timeout_seconds
374
+ self.logger.info(f"Export timeout after {timeout}s")
375
+
376
+ # Check if any devices incomplete
377
+ incomplete = [
378
+ sn for sn in self.discovered_devices if not self._is_device_complete(sn)
379
+ ]
380
+
381
+ if incomplete:
382
+ self.logger.warning(f"Partial export: {len(incomplete)} incomplete devices")
383
+ self.export_status = "FAILED_TIMEOUT"
384
+
385
+ self._finalize_export()
386
+
387
+ def failed(self, message: str) -> None:
388
+ """Handle connection failure event.
389
+
390
+ Args:
391
+ message: Failure message.
392
+ """
393
+ self.logger.error(f"Connection failed: {message}")
394
+ self.export_status = "FAILED_CONNECTION"
395
+ self.export_result.success = False
396
+ self.export_result.error = message
397
+ self.export_result.export_status = self.export_status
398
+ self.on_finish.emit(self.export_result)
399
+
400
+ def set_timeout(self, timeout_seconds: float) -> None:
401
+ """Set timeout for export operation.
402
+
403
+ Args:
404
+ timeout_seconds: Timeout in seconds.
405
+ """
406
+ self.logger.debug(f"Set timeout: {timeout_seconds}s")
407
+ self.conbus_protocol.timeout_seconds = timeout_seconds
408
+
409
+ def set_event_loop(self, event_loop: asyncio.AbstractEventLoop) -> None:
410
+ """Set event loop for async operations.
411
+
412
+ Args:
413
+ event_loop: Event loop to use.
414
+ """
415
+ self.logger.debug("Set event loop")
416
+ self.conbus_protocol.set_event_loop(event_loop)
417
+
418
+ def start_reactor(self) -> None:
419
+ """Start the reactor."""
420
+ self.conbus_protocol.start_reactor()
421
+
422
+ def stop_reactor(self) -> None:
423
+ """Stop the reactor."""
424
+ self.conbus_protocol.stop_reactor()
425
+
426
+ def __enter__(self) -> "ConbusExportService":
427
+ """Enter context manager.
428
+
429
+ Returns:
430
+ Self for context manager protocol.
431
+ """
432
+ # Reset state for reuse
433
+ self.discovered_devices = []
434
+ self.device_configs = {}
435
+ self.export_result = ConbusExportResponse(success=False)
436
+ self.export_status = "OK"
437
+ self._finalized = False
438
+ return self
439
+
440
+ def __exit__(
441
+ self, _exc_type: Optional[type], _exc_val: Optional[Exception], _exc_tb: Any
442
+ ) -> None:
443
+ """Exit context manager and disconnect signals."""
444
+ self.conbus_protocol.on_connection_made.disconnect(self.connection_made)
445
+ self.conbus_protocol.on_telegram_sent.disconnect(self.telegram_sent)
446
+ self.conbus_protocol.on_telegram_received.disconnect(self.telegram_received)
447
+ self.conbus_protocol.on_timeout.disconnect(self.timeout)
448
+ self.conbus_protocol.on_failed.disconnect(self.failed)
449
+ self.on_progress.disconnect()
450
+ self.on_device_exported.disconnect()
451
+ self.on_finish.disconnect()
452
+ self.stop_reactor()
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,