conson-xp 1.18.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.
- conson_xp-1.18.0.dist-info/METADATA +412 -0
- conson_xp-1.18.0.dist-info/RECORD +176 -0
- conson_xp-1.18.0.dist-info/WHEEL +4 -0
- conson_xp-1.18.0.dist-info/entry_points.txt +5 -0
- conson_xp-1.18.0.dist-info/licenses/LICENSE +29 -0
- xp/__init__.py +9 -0
- xp/cli/__init__.py +5 -0
- xp/cli/__main__.py +6 -0
- xp/cli/commands/__init__.py +153 -0
- xp/cli/commands/conbus/__init__.py +25 -0
- xp/cli/commands/conbus/conbus.py +128 -0
- xp/cli/commands/conbus/conbus_actiontable_commands.py +233 -0
- xp/cli/commands/conbus/conbus_autoreport_commands.py +108 -0
- xp/cli/commands/conbus/conbus_blink_commands.py +163 -0
- xp/cli/commands/conbus/conbus_config_commands.py +29 -0
- xp/cli/commands/conbus/conbus_custom_commands.py +57 -0
- xp/cli/commands/conbus/conbus_datapoint_commands.py +113 -0
- xp/cli/commands/conbus/conbus_discover_commands.py +61 -0
- xp/cli/commands/conbus/conbus_event_commands.py +81 -0
- xp/cli/commands/conbus/conbus_lightlevel_commands.py +207 -0
- xp/cli/commands/conbus/conbus_linknumber_commands.py +102 -0
- xp/cli/commands/conbus/conbus_modulenumber_commands.py +104 -0
- xp/cli/commands/conbus/conbus_msactiontable_commands.py +94 -0
- xp/cli/commands/conbus/conbus_output_commands.py +163 -0
- xp/cli/commands/conbus/conbus_raw_commands.py +62 -0
- xp/cli/commands/conbus/conbus_receive_commands.py +59 -0
- xp/cli/commands/conbus/conbus_scan_commands.py +58 -0
- xp/cli/commands/file_commands.py +186 -0
- xp/cli/commands/homekit/__init__.py +3 -0
- xp/cli/commands/homekit/homekit.py +118 -0
- xp/cli/commands/homekit/homekit_start_commands.py +43 -0
- xp/cli/commands/module_commands.py +187 -0
- xp/cli/commands/reverse_proxy_commands.py +178 -0
- xp/cli/commands/server/__init__.py +3 -0
- xp/cli/commands/server/server_commands.py +135 -0
- xp/cli/commands/telegram/__init__.py +5 -0
- xp/cli/commands/telegram/telegram.py +41 -0
- xp/cli/commands/telegram/telegram_blink_commands.py +79 -0
- xp/cli/commands/telegram/telegram_checksum_commands.py +112 -0
- xp/cli/commands/telegram/telegram_discover_commands.py +41 -0
- xp/cli/commands/telegram/telegram_linknumber_commands.py +86 -0
- xp/cli/commands/telegram/telegram_parse_commands.py +75 -0
- xp/cli/commands/telegram/telegram_version_commands.py +52 -0
- xp/cli/main.py +87 -0
- xp/cli/utils/__init__.py +1 -0
- xp/cli/utils/click_tree.py +57 -0
- xp/cli/utils/datapoint_type_choice.py +57 -0
- xp/cli/utils/decorators.py +351 -0
- xp/cli/utils/error_handlers.py +201 -0
- xp/cli/utils/formatters.py +312 -0
- xp/cli/utils/module_type_choice.py +56 -0
- xp/cli/utils/serial_number_type.py +52 -0
- xp/cli/utils/system_function_choice.py +57 -0
- xp/cli/utils/xp_module_type.py +53 -0
- xp/connection/__init__.py +13 -0
- xp/connection/exceptions.py +22 -0
- xp/models/__init__.py +36 -0
- xp/models/actiontable/__init__.py +1 -0
- xp/models/actiontable/actiontable.py +43 -0
- xp/models/actiontable/msactiontable_xp20.py +53 -0
- xp/models/actiontable/msactiontable_xp24.py +58 -0
- xp/models/actiontable/msactiontable_xp33.py +65 -0
- xp/models/conbus/__init__.py +1 -0
- xp/models/conbus/conbus.py +87 -0
- xp/models/conbus/conbus_autoreport.py +67 -0
- xp/models/conbus/conbus_blink.py +80 -0
- xp/models/conbus/conbus_client_config.py +55 -0
- xp/models/conbus/conbus_connection_status.py +40 -0
- xp/models/conbus/conbus_custom.py +58 -0
- xp/models/conbus/conbus_datapoint.py +89 -0
- xp/models/conbus/conbus_discover.py +64 -0
- xp/models/conbus/conbus_event_raw.py +47 -0
- xp/models/conbus/conbus_lightlevel.py +52 -0
- xp/models/conbus/conbus_linknumber.py +54 -0
- xp/models/conbus/conbus_output.py +57 -0
- xp/models/conbus/conbus_raw.py +45 -0
- xp/models/conbus/conbus_receive.py +42 -0
- xp/models/conbus/conbus_writeconfig.py +60 -0
- xp/models/homekit/__init__.py +1 -0
- xp/models/homekit/homekit_accessory.py +35 -0
- xp/models/homekit/homekit_config.py +106 -0
- xp/models/homekit/homekit_conson_config.py +86 -0
- xp/models/log_entry.py +130 -0
- xp/models/protocol/__init__.py +1 -0
- xp/models/protocol/conbus_protocol.py +312 -0
- xp/models/response.py +42 -0
- xp/models/telegram/__init__.py +1 -0
- xp/models/telegram/action_type.py +31 -0
- xp/models/telegram/datapoint_type.py +82 -0
- xp/models/telegram/event_telegram.py +140 -0
- xp/models/telegram/event_type.py +15 -0
- xp/models/telegram/input_action_type.py +69 -0
- xp/models/telegram/input_type.py +17 -0
- xp/models/telegram/module_type.py +188 -0
- xp/models/telegram/module_type_code.py +205 -0
- xp/models/telegram/output_telegram.py +103 -0
- xp/models/telegram/reply_telegram.py +297 -0
- xp/models/telegram/system_function.py +116 -0
- xp/models/telegram/system_telegram.py +94 -0
- xp/models/telegram/telegram.py +28 -0
- xp/models/telegram/telegram_type.py +19 -0
- xp/models/telegram/timeparam_type.py +51 -0
- xp/models/write_config_type.py +33 -0
- xp/services/__init__.py +26 -0
- xp/services/actiontable/__init__.py +1 -0
- xp/services/actiontable/actiontable_serializer.py +273 -0
- xp/services/actiontable/msactiontable_serializer.py +7 -0
- xp/services/actiontable/msactiontable_xp20_serializer.py +169 -0
- xp/services/actiontable/msactiontable_xp24_serializer.py +120 -0
- xp/services/actiontable/msactiontable_xp33_serializer.py +239 -0
- xp/services/conbus/__init__.py +1 -0
- xp/services/conbus/actiontable/__init__.py +1 -0
- xp/services/conbus/actiontable/actiontable_download_service.py +158 -0
- xp/services/conbus/actiontable/actiontable_list_service.py +91 -0
- xp/services/conbus/actiontable/actiontable_show_service.py +89 -0
- xp/services/conbus/actiontable/actiontable_upload_service.py +211 -0
- xp/services/conbus/actiontable/msactiontable_service.py +232 -0
- xp/services/conbus/conbus_blink_all_service.py +181 -0
- xp/services/conbus/conbus_blink_service.py +158 -0
- xp/services/conbus/conbus_custom_service.py +156 -0
- xp/services/conbus/conbus_datapoint_queryall_service.py +182 -0
- xp/services/conbus/conbus_datapoint_service.py +170 -0
- xp/services/conbus/conbus_discover_service.py +312 -0
- xp/services/conbus/conbus_event_raw_service.py +181 -0
- xp/services/conbus/conbus_output_service.py +194 -0
- xp/services/conbus/conbus_raw_service.py +122 -0
- xp/services/conbus/conbus_receive_service.py +115 -0
- xp/services/conbus/conbus_scan_service.py +150 -0
- xp/services/conbus/write_config_service.py +194 -0
- xp/services/homekit/__init__.py +1 -0
- xp/services/homekit/homekit_cache_service.py +307 -0
- xp/services/homekit/homekit_conbus_service.py +93 -0
- xp/services/homekit/homekit_config_validator.py +310 -0
- xp/services/homekit/homekit_conson_validator.py +121 -0
- xp/services/homekit/homekit_dimminglight.py +182 -0
- xp/services/homekit/homekit_dimminglight_service.py +148 -0
- xp/services/homekit/homekit_hap_service.py +342 -0
- xp/services/homekit/homekit_lightbulb.py +120 -0
- xp/services/homekit/homekit_lightbulb_service.py +86 -0
- xp/services/homekit/homekit_module_service.py +56 -0
- xp/services/homekit/homekit_outlet.py +168 -0
- xp/services/homekit/homekit_outlet_service.py +121 -0
- xp/services/homekit/homekit_service.py +359 -0
- xp/services/log_file_service.py +309 -0
- xp/services/module_type_service.py +257 -0
- xp/services/protocol/__init__.py +21 -0
- xp/services/protocol/conbus_event_protocol.py +360 -0
- xp/services/protocol/conbus_protocol.py +318 -0
- xp/services/protocol/protocol_factory.py +78 -0
- xp/services/protocol/telegram_protocol.py +264 -0
- xp/services/reverse_proxy_service.py +435 -0
- xp/services/server/__init__.py +1 -0
- xp/services/server/base_server_service.py +366 -0
- xp/services/server/cp20_server_service.py +65 -0
- xp/services/server/device_service_factory.py +94 -0
- xp/services/server/server_service.py +428 -0
- xp/services/server/xp130_server_service.py +67 -0
- xp/services/server/xp20_server_service.py +92 -0
- xp/services/server/xp230_server_service.py +58 -0
- xp/services/server/xp24_server_service.py +245 -0
- xp/services/server/xp33_server_service.py +535 -0
- xp/services/telegram/__init__.py +1 -0
- xp/services/telegram/telegram_blink_service.py +138 -0
- xp/services/telegram/telegram_checksum_service.py +149 -0
- xp/services/telegram/telegram_datapoint_service.py +82 -0
- xp/services/telegram/telegram_discover_service.py +277 -0
- xp/services/telegram/telegram_link_number_service.py +216 -0
- xp/services/telegram/telegram_output_service.py +322 -0
- xp/services/telegram/telegram_service.py +380 -0
- xp/services/telegram/telegram_version_service.py +288 -0
- xp/utils/__init__.py +12 -0
- xp/utils/checksum.py +61 -0
- xp/utils/dependencies.py +531 -0
- xp/utils/event_helper.py +31 -0
- xp/utils/serialization.py +205 -0
- xp/utils/time_utils.py +134 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""XP33 Server Service for device emulation.
|
|
2
|
+
|
|
3
|
+
This service provides XP33-specific device emulation functionality,
|
|
4
|
+
including response generation and device configuration handling for
|
|
5
|
+
3-channel light dimmer modules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Dict, Optional
|
|
11
|
+
|
|
12
|
+
from xp.models import ModuleTypeCode
|
|
13
|
+
from xp.models.actiontable.msactiontable_xp33 import Xp33MsActionTable
|
|
14
|
+
from xp.models.telegram.datapoint_type import DataPointType
|
|
15
|
+
from xp.models.telegram.system_function import SystemFunction
|
|
16
|
+
from xp.models.telegram.system_telegram import SystemTelegram
|
|
17
|
+
from xp.services.actiontable.msactiontable_xp33_serializer import (
|
|
18
|
+
Xp33MsActionTableSerializer,
|
|
19
|
+
)
|
|
20
|
+
from xp.services.server.base_server_service import BaseServerService
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class XP33ServerError(Exception):
|
|
24
|
+
"""Raised when XP33 server operations fail."""
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class XP33ServerService(BaseServerService):
|
|
30
|
+
"""
|
|
31
|
+
XP33 device emulation service.
|
|
32
|
+
|
|
33
|
+
Generates XP33-specific responses, handles XP33 device configuration,
|
|
34
|
+
and implements XP33 telegram format for 3-channel dimmer modules.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
serial_number: str,
|
|
40
|
+
variant: str = "XP33LR",
|
|
41
|
+
msactiontable_serializer: Optional[Xp33MsActionTableSerializer] = None,
|
|
42
|
+
):
|
|
43
|
+
"""Initialize XP33 server service.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
serial_number: The device serial number.
|
|
47
|
+
variant: Device variant (XP33, XP33LR, or XP33LED).
|
|
48
|
+
msactiontable_serializer: MsActionTable serializer (injected via DI).
|
|
49
|
+
"""
|
|
50
|
+
super().__init__(serial_number)
|
|
51
|
+
self.variant = variant # XP33 or XP33LR or XP33LED
|
|
52
|
+
self.device_type = "XP33"
|
|
53
|
+
self.module_type_code = ModuleTypeCode.XP33 # XP33 module type
|
|
54
|
+
|
|
55
|
+
# XP33 device characteristics (anonymized for interoperability testing)
|
|
56
|
+
if variant == "XP33LED":
|
|
57
|
+
self.firmware_version = "XP33LED_V0.00.00"
|
|
58
|
+
self.ean_code = "1234567890123" # Test EAN - not a real product code
|
|
59
|
+
self.max_power = 300 # 3 x 100VA
|
|
60
|
+
self.module_type_code = ModuleTypeCode.XP33LED # XP33LR module type
|
|
61
|
+
elif variant == "XP33LR": # XP33LR
|
|
62
|
+
self.firmware_version = "XP33LR_V0.00.00"
|
|
63
|
+
self.ean_code = "1234567890124" # Test EAN - not a real product code
|
|
64
|
+
self.max_power = 640 # Total 640VA
|
|
65
|
+
self.module_type_code = ModuleTypeCode.XP33LR # XP33LR module type
|
|
66
|
+
else: # XP33
|
|
67
|
+
self.firmware_version = "XP33_V0.04.02"
|
|
68
|
+
self.ean_code = "1234567890125" # Test EAN - not a real product code
|
|
69
|
+
self.max_power = 100 # Total 640VA
|
|
70
|
+
self.module_type_code = ModuleTypeCode.XP33 # XP33 module type
|
|
71
|
+
|
|
72
|
+
self.device_status = "00" # Normal status
|
|
73
|
+
self.link_number = 4 # 4 links configured
|
|
74
|
+
self.autoreport_status = True
|
|
75
|
+
|
|
76
|
+
# Channel states (3 channels, 0-100% dimming)
|
|
77
|
+
self.channel_states = [0, 0, 0] # All channels at 0%
|
|
78
|
+
|
|
79
|
+
# Scene configuration (4 scenes)
|
|
80
|
+
self.scenes = {
|
|
81
|
+
1: [50, 30, 20], # Scene 1: 50%, 30%, 20%
|
|
82
|
+
2: [100, 100, 100], # Scene 2: All full
|
|
83
|
+
3: [25, 25, 25], # Scene 3: Low level
|
|
84
|
+
4: [0, 0, 0], # Scene 4: Off
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Storm mode state (XP33 Storm Simulator)
|
|
88
|
+
self.storm_mode = False # Track if device is in storm mode
|
|
89
|
+
self.last_response: Optional[str] = None # Cache last response for storm replay
|
|
90
|
+
self.storm_thread: Optional[threading.Thread] = (
|
|
91
|
+
None # Background thread for storm
|
|
92
|
+
)
|
|
93
|
+
self.storm_stop_event = threading.Event() # Event to stop storm thread
|
|
94
|
+
self.client_sockets: set[socket.socket] = set() # All active client sockets
|
|
95
|
+
self.client_sockets_lock = threading.Lock() # Lock for socket set
|
|
96
|
+
self.storm_packets_sent = 0 # Counter for packets sent during storm
|
|
97
|
+
|
|
98
|
+
# MsActionTable support
|
|
99
|
+
self.msactiontable_serializer = (
|
|
100
|
+
msactiontable_serializer or Xp33MsActionTableSerializer()
|
|
101
|
+
)
|
|
102
|
+
self.msactiontable = self._get_default_msactiontable()
|
|
103
|
+
|
|
104
|
+
def _handle_device_specific_action_request(
|
|
105
|
+
self, request: SystemTelegram
|
|
106
|
+
) -> Optional[str]:
|
|
107
|
+
"""Handle XP33-specific action requests."""
|
|
108
|
+
telegrams = self._handle_action_channel_dimming(request.data)
|
|
109
|
+
self.logger.debug(f"Generated {self.device_type} action responses: {telegrams}")
|
|
110
|
+
return telegrams
|
|
111
|
+
|
|
112
|
+
def _handle_action_channel_dimming(self, data_value: str) -> str:
|
|
113
|
+
"""Handle XP33-specific channel dimming action.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
data_value: Action data in format channel_number:dimming_level.
|
|
117
|
+
E.g., "00:050" means channel 0, 50% dimming.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Response telegram(s) - ACK/NAK, optionally with event telegram.
|
|
121
|
+
"""
|
|
122
|
+
if ":" not in data_value or len(data_value) < 6:
|
|
123
|
+
return self._build_ack_nak_response_telegram(False)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
parts = data_value.split(":")
|
|
127
|
+
channel_number = int(parts[0])
|
|
128
|
+
dimming_level = int(parts[1])
|
|
129
|
+
except (ValueError, IndexError):
|
|
130
|
+
return self._build_ack_nak_response_telegram(False)
|
|
131
|
+
|
|
132
|
+
if channel_number not in range(len(self.channel_states)):
|
|
133
|
+
return self._build_ack_nak_response_telegram(False)
|
|
134
|
+
|
|
135
|
+
if dimming_level not in range(0, 101):
|
|
136
|
+
return self._build_ack_nak_response_telegram(False)
|
|
137
|
+
|
|
138
|
+
previous_level = self.channel_states[channel_number]
|
|
139
|
+
self.channel_states[channel_number] = dimming_level
|
|
140
|
+
state_changed = (previous_level == 0 and dimming_level > 0) or (
|
|
141
|
+
previous_level > 0 and dimming_level == 0
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
telegrams = self._build_ack_nak_response_telegram(True)
|
|
145
|
+
if state_changed and self.autoreport_status:
|
|
146
|
+
# Report dimming change event
|
|
147
|
+
telegrams += self._build_dimming_event_telegram(
|
|
148
|
+
dimming_level, channel_number
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return telegrams
|
|
152
|
+
|
|
153
|
+
def _build_ack_nak_response_telegram(self, ack_or_nak: bool) -> str:
|
|
154
|
+
"""Build a complete ACK or NAK response telegram with checksum.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
ack_or_nak: true: ACK telegram response, false: NAK telegram response.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The complete telegram with checksum enclosed in angle brackets.
|
|
161
|
+
"""
|
|
162
|
+
data_value = (
|
|
163
|
+
SystemFunction.ACK.value if ack_or_nak else SystemFunction.NAK.value
|
|
164
|
+
)
|
|
165
|
+
data_part = f"R{self.serial_number}" f"F{data_value:02}D"
|
|
166
|
+
return self._build_response_telegram(data_part)
|
|
167
|
+
|
|
168
|
+
def _build_dimming_event_telegram(
|
|
169
|
+
self, dimming_level: int, channel_number: int
|
|
170
|
+
) -> str:
|
|
171
|
+
"""Build a complete dimming event telegram with checksum.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
dimming_level: Dimming level 0-100%.
|
|
175
|
+
channel_number: Channel concerned (0-2).
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The complete event telegram with checksum enclosed in angle brackets.
|
|
179
|
+
"""
|
|
180
|
+
data_value = "M" if dimming_level > 0 else "B"
|
|
181
|
+
data_part = (
|
|
182
|
+
f"E{self.module_type_code.value:02}"
|
|
183
|
+
f"L{self.link_number:02}"
|
|
184
|
+
f"I{channel_number:02}"
|
|
185
|
+
f"{data_value}"
|
|
186
|
+
)
|
|
187
|
+
return self._build_response_telegram(data_part)
|
|
188
|
+
|
|
189
|
+
def _handle_device_specific_data_request(
|
|
190
|
+
self, request: SystemTelegram
|
|
191
|
+
) -> Optional[str]:
|
|
192
|
+
"""Handle XP33-specific data requests with storm mode support."""
|
|
193
|
+
if not request.datapoint_type:
|
|
194
|
+
# Check for D99 storm trigger (not in DataPointType enum)
|
|
195
|
+
if request.data and request.data.startswith("99"):
|
|
196
|
+
return self._trigger_storm_mode()
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
datapoint_type = request.datapoint_type
|
|
200
|
+
|
|
201
|
+
# Storm mode handling
|
|
202
|
+
if datapoint_type == DataPointType.MODULE_ERROR_CODE:
|
|
203
|
+
if self.storm_mode:
|
|
204
|
+
# MODULE_ERROR_CODE query stops storm
|
|
205
|
+
return self._exit_storm_mode()
|
|
206
|
+
else:
|
|
207
|
+
# Normal operation - return error code 00
|
|
208
|
+
return self._build_error_code_response("00")
|
|
209
|
+
|
|
210
|
+
# If in storm mode and not MODULE_ERROR_CODE query, ignore (background thread is sending)
|
|
211
|
+
if self.storm_mode:
|
|
212
|
+
self.logger.debug(
|
|
213
|
+
f"Ignoring query during storm mode for device {self.serial_number}"
|
|
214
|
+
)
|
|
215
|
+
return None # Background thread is sending storm telegrams
|
|
216
|
+
|
|
217
|
+
# Normal data request handling
|
|
218
|
+
handler = {
|
|
219
|
+
DataPointType.MODULE_OUTPUT_STATE: self._handle_read_module_output_state,
|
|
220
|
+
DataPointType.MODULE_STATE: self._handle_read_module_state,
|
|
221
|
+
DataPointType.MODULE_OPERATING_HOURS: self._handle_read_module_operating_hours,
|
|
222
|
+
DataPointType.MODULE_LIGHT_LEVEL: self._handle_read_light_level,
|
|
223
|
+
}.get(datapoint_type)
|
|
224
|
+
if not handler:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
data_value = handler()
|
|
228
|
+
data_part = (
|
|
229
|
+
f"R{self.serial_number}"
|
|
230
|
+
f"F02D{datapoint_type.value}"
|
|
231
|
+
f"{self.module_type_code.value:02}"
|
|
232
|
+
f"{data_value}"
|
|
233
|
+
)
|
|
234
|
+
telegram = self._build_response_telegram(data_part)
|
|
235
|
+
|
|
236
|
+
# Cache response for potential storm replay
|
|
237
|
+
self.last_response = telegram
|
|
238
|
+
|
|
239
|
+
self.logger.debug(
|
|
240
|
+
f"Generated {self.device_type} module type response: {telegram}"
|
|
241
|
+
)
|
|
242
|
+
return telegram
|
|
243
|
+
|
|
244
|
+
def _handle_read_module_output_state(self) -> str:
|
|
245
|
+
"""Handle XP33-specific module output state.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
String representation of the output state for 3 channels.
|
|
249
|
+
"""
|
|
250
|
+
return (
|
|
251
|
+
f"xxxxx"
|
|
252
|
+
f"{1 if self.channel_states[0] > 0 else 0}"
|
|
253
|
+
f"{1 if self.channel_states[1] > 0 else 0}"
|
|
254
|
+
f"{1 if self.channel_states[2] > 0 else 0}"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def _handle_read_module_state(self) -> str:
|
|
258
|
+
"""Handle XP33-specific module state.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
'ON' if any channel is active, 'OFF' otherwise.
|
|
262
|
+
"""
|
|
263
|
+
if any(level > 0 for level in self.channel_states):
|
|
264
|
+
return "ON"
|
|
265
|
+
return "OFF"
|
|
266
|
+
|
|
267
|
+
def _handle_read_module_operating_hours(self) -> str:
|
|
268
|
+
"""Handle XP33-specific module operating hours.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Operating hours for all 3 channels.
|
|
272
|
+
"""
|
|
273
|
+
return "00:000[H],01:000[H],02:000[H]"
|
|
274
|
+
|
|
275
|
+
def _handle_read_light_level(self) -> str:
|
|
276
|
+
"""Handle XP33-specific light level reading.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Light levels for all channels in format "00:000[%],01:000[%],02:000[%]".
|
|
280
|
+
"""
|
|
281
|
+
levels = [
|
|
282
|
+
f"{i:02d}:{level:03d}[%]" for i, level in enumerate(self.channel_states)
|
|
283
|
+
]
|
|
284
|
+
return ",".join(levels)
|
|
285
|
+
|
|
286
|
+
def _trigger_storm_mode(self) -> Optional[str]:
|
|
287
|
+
"""Trigger storm mode via D99 query.
|
|
288
|
+
|
|
289
|
+
Starts a background thread that sends 2 packets per second.
|
|
290
|
+
If storm is already active, this is a no-op.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
None (no response - storm mode activated).
|
|
294
|
+
"""
|
|
295
|
+
# If storm already active, just log and continue
|
|
296
|
+
if self.storm_mode and self.storm_thread and self.storm_thread.is_alive():
|
|
297
|
+
self.logger.debug(
|
|
298
|
+
f"Storm already active for device {self.serial_number}, "
|
|
299
|
+
f"sent {self.storm_packets_sent}/200 packets"
|
|
300
|
+
)
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
if not self.last_response:
|
|
304
|
+
self.logger.warning(
|
|
305
|
+
f"Cannot trigger storm for device {self.serial_number}: "
|
|
306
|
+
f"no cached response"
|
|
307
|
+
)
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
self.storm_mode = True
|
|
311
|
+
self.storm_packets_sent = 0
|
|
312
|
+
self.storm_stop_event.clear()
|
|
313
|
+
|
|
314
|
+
# Start background thread to send storm telegrams
|
|
315
|
+
self.storm_thread = threading.Thread(
|
|
316
|
+
target=self._storm_sender_thread,
|
|
317
|
+
daemon=True,
|
|
318
|
+
name=f"Storm-{self.serial_number}",
|
|
319
|
+
)
|
|
320
|
+
self.storm_thread.start()
|
|
321
|
+
|
|
322
|
+
self.logger.info(
|
|
323
|
+
f"Storm triggered via D99 query for device {self.serial_number}"
|
|
324
|
+
)
|
|
325
|
+
return None # No response when entering storm mode
|
|
326
|
+
|
|
327
|
+
def _exit_storm_mode(self) -> str:
|
|
328
|
+
"""Exit storm mode and return error code FE.
|
|
329
|
+
|
|
330
|
+
Stops the background storm thread and returns error code.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
MODULE_ERROR_CODE response with error code FE (buffer overflow).
|
|
334
|
+
"""
|
|
335
|
+
self.logger.info(
|
|
336
|
+
f"MODULE_ERROR_CODE query received, stopping storm for device {self.serial_number}"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Signal the storm thread to stop
|
|
340
|
+
self.storm_stop_event.set()
|
|
341
|
+
self.storm_mode = False
|
|
342
|
+
|
|
343
|
+
# Wait for thread to finish (with timeout)
|
|
344
|
+
if self.storm_thread and self.storm_thread.is_alive():
|
|
345
|
+
self.storm_thread.join(timeout=1.0)
|
|
346
|
+
|
|
347
|
+
self.logger.info(
|
|
348
|
+
f"Storm stopped after {self.storm_packets_sent} packets for device {self.serial_number}"
|
|
349
|
+
)
|
|
350
|
+
self.logger.info(
|
|
351
|
+
f"Storm stopped, returning to normal operation for device {self.serial_number}"
|
|
352
|
+
)
|
|
353
|
+
return self._build_error_code_response("FE")
|
|
354
|
+
|
|
355
|
+
def _storm_sender_thread(self) -> None:
|
|
356
|
+
"""Background thread that sends storm telegrams continuously.
|
|
357
|
+
|
|
358
|
+
Sends 2 packets per second (500ms delay) until:
|
|
359
|
+
- 200 packets have been sent, or
|
|
360
|
+
- Storm mode is stopped via stop event
|
|
361
|
+
|
|
362
|
+
The storm persists across socket disconnections. If the client disconnects
|
|
363
|
+
and reconnects, the storm will continue on the new connection.
|
|
364
|
+
"""
|
|
365
|
+
if not self.last_response:
|
|
366
|
+
self.logger.error(
|
|
367
|
+
f"Storm thread started but missing cached response for {self.serial_number}"
|
|
368
|
+
)
|
|
369
|
+
self.storm_mode = False
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
self.logger.info(
|
|
373
|
+
f"Storm thread started, sending 200 duplicate telegrams at 2 packets/sec for device {self.serial_number}"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Type narrowing for mypy
|
|
377
|
+
cached_response: str = self.last_response
|
|
378
|
+
max_packets = 200
|
|
379
|
+
packets_per_second = 2
|
|
380
|
+
delay_between_packets = 1.0 / packets_per_second # 0.5 seconds
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
while (
|
|
384
|
+
self.storm_packets_sent < max_packets
|
|
385
|
+
and not self.storm_stop_event.is_set()
|
|
386
|
+
):
|
|
387
|
+
# Wait for a valid socket (client may have disconnected and reconnected)
|
|
388
|
+
self.add_telegram_buffer(cached_response)
|
|
389
|
+
self.storm_packets_sent += 1
|
|
390
|
+
self.logger.debug(
|
|
391
|
+
f"Storm packet {self.storm_packets_sent}/{max_packets} sent for {self.serial_number}"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Wait before sending next packet (0.5 seconds for 2 packets/sec)
|
|
395
|
+
if self.storm_packets_sent < max_packets:
|
|
396
|
+
self.storm_stop_event.wait(timeout=delay_between_packets)
|
|
397
|
+
|
|
398
|
+
# Log completion status
|
|
399
|
+
if self.storm_packets_sent >= max_packets:
|
|
400
|
+
self.logger.info(
|
|
401
|
+
f"Storm completed: sent all {self.storm_packets_sent} packets for {self.serial_number}"
|
|
402
|
+
)
|
|
403
|
+
elif self.storm_stop_event.is_set():
|
|
404
|
+
self.logger.info(
|
|
405
|
+
f"Storm stopped by error code query: sent {self.storm_packets_sent} packets for {self.serial_number}"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Clean up storm mode
|
|
409
|
+
self.storm_mode = False
|
|
410
|
+
|
|
411
|
+
except Exception as e:
|
|
412
|
+
self.logger.error(
|
|
413
|
+
f"Unexpected error in storm thread for {self.serial_number}: {e}"
|
|
414
|
+
)
|
|
415
|
+
self.storm_mode = False
|
|
416
|
+
|
|
417
|
+
def _build_error_code_response(self, error_code: str) -> str:
|
|
418
|
+
"""Build MODULE_ERROR_CODE response telegram.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
error_code: Error code (00 = normal, FE = buffer overflow).
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
The complete MODULE_ERROR_CODE response telegram.
|
|
425
|
+
"""
|
|
426
|
+
data_part = (
|
|
427
|
+
f"R{self.serial_number}"
|
|
428
|
+
f"F02D{DataPointType.MODULE_ERROR_CODE.value}"
|
|
429
|
+
f"{error_code}"
|
|
430
|
+
)
|
|
431
|
+
telegram = self._build_response_telegram(data_part)
|
|
432
|
+
self.logger.debug(
|
|
433
|
+
f"Generated {self.device_type} error code response: {telegram}"
|
|
434
|
+
)
|
|
435
|
+
return telegram
|
|
436
|
+
|
|
437
|
+
def set_channel_dimming(self, channel: int, level: int) -> bool:
|
|
438
|
+
"""Set individual channel dimming level.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
channel: Channel number (1-3).
|
|
442
|
+
level: Dimming level (0-100 percent).
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
True if channel was set successfully, False otherwise.
|
|
446
|
+
"""
|
|
447
|
+
if 1 <= channel <= 3 and 0 <= level <= 100:
|
|
448
|
+
self.channel_states[channel - 1] = level
|
|
449
|
+
self.logger.info(f"XP33 channel {channel} set to {level}%")
|
|
450
|
+
return True
|
|
451
|
+
return False
|
|
452
|
+
|
|
453
|
+
def activate_scene(self, scene: int) -> bool:
|
|
454
|
+
"""Activate a pre-programmed scene.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
scene: Scene number (1-4).
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
True if scene was activated successfully, False otherwise.
|
|
461
|
+
"""
|
|
462
|
+
if scene in self.scenes:
|
|
463
|
+
self.channel_states = self.scenes[scene].copy()
|
|
464
|
+
self.logger.info(f"XP33 scene {scene} activated: {self.channel_states}")
|
|
465
|
+
return True
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
def _get_msactiontable_serializer(self) -> Optional[Xp33MsActionTableSerializer]:
|
|
469
|
+
"""Get the MsActionTable serializer for XP33.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
The XP33 MsActionTable serializer instance.
|
|
473
|
+
"""
|
|
474
|
+
return self.msactiontable_serializer
|
|
475
|
+
|
|
476
|
+
def _get_msactiontable(self) -> Optional[Xp33MsActionTable]:
|
|
477
|
+
"""Get the MsActionTable for XP33.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
The XP33 MsActionTable instance.
|
|
481
|
+
"""
|
|
482
|
+
return self.msactiontable
|
|
483
|
+
|
|
484
|
+
def _get_default_msactiontable(self) -> Xp33MsActionTable:
|
|
485
|
+
"""Generate default MsActionTable configuration.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Default XP33 MsActionTable with all outputs at 0-100% range, no scenes configured.
|
|
489
|
+
"""
|
|
490
|
+
# All outputs at 0-100% range, no scenes configured
|
|
491
|
+
return Xp33MsActionTable()
|
|
492
|
+
|
|
493
|
+
def get_device_info(self) -> Dict:
|
|
494
|
+
"""Get XP33 device information.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
Dictionary containing device information.
|
|
498
|
+
"""
|
|
499
|
+
return {
|
|
500
|
+
"serial_number": self.serial_number,
|
|
501
|
+
"device_type": self.device_type,
|
|
502
|
+
"variant": self.variant,
|
|
503
|
+
"firmware_version": self.firmware_version,
|
|
504
|
+
"ean_code": self.ean_code,
|
|
505
|
+
"max_power": self.max_power,
|
|
506
|
+
"status": self.device_status,
|
|
507
|
+
"link_number": self.link_number,
|
|
508
|
+
"autoreport_status": self.autoreport_status,
|
|
509
|
+
"channel_states": self.channel_states.copy(),
|
|
510
|
+
"available_scenes": list(self.scenes.keys()),
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
def get_technical_specs(self) -> Dict:
|
|
514
|
+
"""Get technical specifications.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
Dictionary containing technical specifications.
|
|
518
|
+
"""
|
|
519
|
+
if self.variant == "XP33LED":
|
|
520
|
+
return {
|
|
521
|
+
"power_per_channel": "100VA",
|
|
522
|
+
"total_power": "300VA",
|
|
523
|
+
"load_types": ["LED lamps", "resistive", "capacitive"],
|
|
524
|
+
"dimming_type": "Leading/Trailing edge configurable",
|
|
525
|
+
"protection": "Short-circuit proof channels",
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
# XP33LR
|
|
529
|
+
return {
|
|
530
|
+
"power_per_channel": "500VA max",
|
|
531
|
+
"total_power": "640VA",
|
|
532
|
+
"load_types": ["Resistive", "inductive"],
|
|
533
|
+
"dimming_type": "Leading edge, logarithmic control",
|
|
534
|
+
"protection": "Thermal protection, neutral break detection",
|
|
535
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Telegram parsing and processing services."""
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Service for blink/unblink telegram operations.
|
|
2
|
+
|
|
3
|
+
This service handles generation and parsing of blink/unblink system telegrams
|
|
4
|
+
used for controlling module LED status.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from xp.models.telegram.reply_telegram import ReplyTelegram
|
|
8
|
+
from xp.models.telegram.system_function import SystemFunction
|
|
9
|
+
from xp.models.telegram.system_telegram import SystemTelegram
|
|
10
|
+
from xp.utils.checksum import calculate_checksum
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BlinkError(Exception):
|
|
14
|
+
"""Raised when blink/unblink operations fail."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TelegramBlinkService:
|
|
20
|
+
"""
|
|
21
|
+
Service for generating and handling blink/unblink system telegrams.
|
|
22
|
+
|
|
23
|
+
Handles telegrams for controlling module LED status using the F05D00 and F06D00 formats:
|
|
24
|
+
- Blink: <S{serial_number}F05D00{checksum}>
|
|
25
|
+
- Unblink: <S{serial_number}F06D00{checksum}>
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
"""Initialize the blink service."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def generate_blink_telegram(serial_number: str, on_or_off: str) -> str:
|
|
34
|
+
"""Generate a telegram to start blinking a module's LED.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
serial_number: The 10-digit module serial number.
|
|
38
|
+
on_or_off: The action to perform ('on' for blink, 'off' for unblink).
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Formatted telegram string (e.g., "<S0012345008F05D00FN>").
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
BlinkError: If parameters are invalid.
|
|
45
|
+
"""
|
|
46
|
+
# Validate serial number
|
|
47
|
+
if not serial_number or len(serial_number) != 10:
|
|
48
|
+
raise BlinkError(f"Serial number must be 10 digits, got: {serial_number}")
|
|
49
|
+
|
|
50
|
+
if not serial_number.isdigit():
|
|
51
|
+
raise BlinkError(f"Serial number must contain only digits: {serial_number}")
|
|
52
|
+
|
|
53
|
+
action_type = SystemFunction.BLINK
|
|
54
|
+
if on_or_off.lower() == "off":
|
|
55
|
+
action_type = SystemFunction.UNBLINK
|
|
56
|
+
|
|
57
|
+
# Build the data part of the telegram (F05D00 - Blink function, Status data point)
|
|
58
|
+
data_part = f"S{serial_number}F{action_type.value}D00"
|
|
59
|
+
|
|
60
|
+
# Calculate checksum
|
|
61
|
+
checksum = calculate_checksum(data_part)
|
|
62
|
+
|
|
63
|
+
# Build complete telegram
|
|
64
|
+
telegram = f"<{data_part}{checksum}>"
|
|
65
|
+
|
|
66
|
+
return telegram
|
|
67
|
+
|
|
68
|
+
def create_blink_telegram_object(self, serial_number: str) -> SystemTelegram:
|
|
69
|
+
"""Create a SystemTelegram object for blinking LED.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
serial_number: The 10-digit module serial number.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
SystemTelegram object representing the blink command.
|
|
76
|
+
"""
|
|
77
|
+
raw_telegram = self.generate_blink_telegram(serial_number, "on")
|
|
78
|
+
|
|
79
|
+
# Extract checksum from the generated telegram
|
|
80
|
+
checksum = raw_telegram[-3:-1] # Get checksum before closing >
|
|
81
|
+
|
|
82
|
+
telegram = SystemTelegram(
|
|
83
|
+
serial_number=serial_number,
|
|
84
|
+
system_function=SystemFunction.BLINK,
|
|
85
|
+
datapoint_type=None,
|
|
86
|
+
checksum=checksum,
|
|
87
|
+
raw_telegram=raw_telegram,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return telegram
|
|
91
|
+
|
|
92
|
+
def create_unblink_telegram_object(self, serial_number: str) -> SystemTelegram:
|
|
93
|
+
"""Create a SystemTelegram object for unblink LED.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
serial_number: The 10-digit module serial number.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
SystemTelegram object representing the unblink command.
|
|
100
|
+
"""
|
|
101
|
+
raw_telegram = self.generate_blink_telegram(serial_number, "off")
|
|
102
|
+
|
|
103
|
+
# Extract checksum from the generated telegram
|
|
104
|
+
checksum = raw_telegram[-3:-1] # Get checksum before closing >
|
|
105
|
+
|
|
106
|
+
telegram = SystemTelegram(
|
|
107
|
+
serial_number=serial_number,
|
|
108
|
+
system_function=SystemFunction.UNBLINK,
|
|
109
|
+
datapoint_type=None,
|
|
110
|
+
checksum=checksum,
|
|
111
|
+
raw_telegram=raw_telegram,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return telegram
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def is_ack_response(reply_telegram: ReplyTelegram) -> bool:
|
|
118
|
+
"""Check if a reply telegram is an ACK response.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
reply_telegram: Reply telegram to check.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if this is an ACK response (F18D), False otherwise.
|
|
125
|
+
"""
|
|
126
|
+
return reply_telegram.system_function == SystemFunction.ACK
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def is_nak_response(reply_telegram: ReplyTelegram) -> bool:
|
|
130
|
+
"""Check if a reply telegram is a NAK response.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
reply_telegram: Reply telegram to check.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
True if this is a NAK response (F19D), False otherwise.
|
|
137
|
+
"""
|
|
138
|
+
return reply_telegram.system_function == SystemFunction.NAK
|