conson-xp 1.15.0__py3-none-any.whl → 1.17.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.15.0
3
+ Version: 1.17.0
4
4
  Summary: XP Protocol Communication Tools
5
5
  Author-Email: ldvchosal <ldvchosal@github.com>
6
6
  License: MIT License
@@ -47,6 +47,7 @@ Requires-Dist: HAP-python[QRCode]>=5.0.0
47
47
  Requires-Dist: punq>=0.7.0
48
48
  Requires-Dist: twisted>=25.5.0
49
49
  Requires-Dist: bubus>=1.5.6
50
+ Requires-Dist: psygnal>=0.15.0
50
51
  Description-Content-Type: text/markdown
51
52
 
52
53
  # 🔌 XP Protocol Communication Tool
@@ -304,6 +305,10 @@ xp conbus datapoint query
304
305
 
305
306
  xp conbus discover
306
307
 
308
+ xp conbus event
309
+ xp conbus event raw
310
+
311
+
307
312
  xp conbus lightlevel
308
313
  xp conbus lightlevel get
309
314
  xp conbus lightlevel off
@@ -1,11 +1,11 @@
1
- conson_xp-1.15.0.dist-info/METADATA,sha256=1uDwWEJsmWvUk8RjzbLoPj0WrlRujgplsRRb5NHEJdU,9437
2
- conson_xp-1.15.0.dist-info/WHEEL,sha256=9P2ygRxDrTJz3gsagc0Z96ukrxjr-LFBGOgv3AuKlCA,90
3
- conson_xp-1.15.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
- conson_xp-1.15.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
- xp/__init__.py,sha256=cQLZ0V5wACMFH4b8H0g8gWRKytLc5RdIi_JA8HVho6Q,181
1
+ conson_xp-1.17.0.dist-info/METADATA,sha256=Ib3pQUP44vhlduSotZu5juw2A86rHXgnO1e3fHrhuLg,9506
2
+ conson_xp-1.17.0.dist-info/WHEEL,sha256=9P2ygRxDrTJz3gsagc0Z96ukrxjr-LFBGOgv3AuKlCA,90
3
+ conson_xp-1.17.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
+ conson_xp-1.17.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
+ xp/__init__.py,sha256=3Amcz5pSjBDLeWf7aPWXM1DtmHeDt9T4cbNlNM6t964,181
6
6
  xp/cli/__init__.py,sha256=QjnKB1KaI2aIyKlzrnvCwfbBuUj8HNgwNMvNJVQofbI,81
7
7
  xp/cli/__main__.py,sha256=l2iKwMdat5rTGd3JWs-uGksnYYDDffp_Npz05QdKEeU,117
8
- xp/cli/commands/__init__.py,sha256=EGDWTEH_pCKIMWelnjhK4_PxBsvIH24rO9fz1nF1pKA,4638
8
+ xp/cli/commands/__init__.py,sha256=wvo9Z5viwpjvO2432E7YP5HWjLLiW1IFpyXLc5puuGY,4766
9
9
  xp/cli/commands/conbus/__init__.py,sha256=gE3K5OEoXkkZX8UOc2v3nreQQzwkOQi7n0VZ-Z2juXA,495
10
10
  xp/cli/commands/conbus/conbus.py,sha256=eqdY8ArapvD08Z4p7Xk7eh4z0dESHuMSw7PKtwTJRYU,3021
11
11
  xp/cli/commands/conbus/conbus_actiontable_commands.py,sha256=cdjLV9cnm7teEOlu5Jf1MS_aL7lNy8KiDIyjCQa5Nzw,7138
@@ -14,7 +14,8 @@ xp/cli/commands/conbus/conbus_blink_commands.py,sha256=UK-Ey4K0FvaPQ96U0gyMid236
14
14
  xp/cli/commands/conbus/conbus_config_commands.py,sha256=BugIbgNX6_s4MySGvI6tWZkwguciajHUX2Xz8XBux7k,716
15
15
  xp/cli/commands/conbus/conbus_custom_commands.py,sha256=lICT93ijMdhVRm8KjNMLo7kQ2BLlnOZvMPbR3SxSmZ4,1692
16
16
  xp/cli/commands/conbus/conbus_datapoint_commands.py,sha256=r36OuTjREtbGKL-bskAGa0-WLw7x06td6woZn3GYJNA,3630
17
- xp/cli/commands/conbus/conbus_discover_commands.py,sha256=Jt-OjARc-QXCr453bhKG0zXRqvOMVKZr328zNxSDulY,1373
17
+ xp/cli/commands/conbus/conbus_discover_commands.py,sha256=-y3TDgOnw1_cjvxvgyfQ1GQE2_WmYq-l8Md7DsdTXmo,1719
18
+ xp/cli/commands/conbus/conbus_event_commands.py,sha256=8IjQfX9vXlTRprb1oGkMRHRDPmxb02ZnmVbv3ltCqGk,3369
18
19
  xp/cli/commands/conbus/conbus_lightlevel_commands.py,sha256=FpCwogdxa7yFUjlrxM7e8Q2Ut32tKAHabngQQChvtJI,6763
19
20
  xp/cli/commands/conbus/conbus_linknumber_commands.py,sha256=KitaGDM5HpwVUz8rLpO8VZUypUTcAg3Bzl0DVm6gnSk,3391
20
21
  xp/cli/commands/conbus/conbus_modulenumber_commands.py,sha256=L7-6y3rDllOjQ9g6Bk_RiTKIhAOHVPLdxWif9exkngs,3463
@@ -39,7 +40,7 @@ xp/cli/commands/telegram/telegram_discover_commands.py,sha256=0UArJinw1eWFbee5EG
39
40
  xp/cli/commands/telegram/telegram_linknumber_commands.py,sha256=7j0-E5Moqqga4NrKDch82C6glaFDFMQn5_3hMwie7BQ,2511
40
41
  xp/cli/commands/telegram/telegram_parse_commands.py,sha256=_OYOso1hS4f_ox96qlkYL2SuFnmimpAvqqdYlLzX9yo,2232
41
42
  xp/cli/commands/telegram/telegram_version_commands.py,sha256=WQyx1-B9yJ8V9WrFyBpOvULJ-jq12GoZZDDoRbM7eyw,1553
42
- xp/cli/main.py,sha256=52nMDP2Pq-W1PP4_BU5ybyTuO6QrStWOeLr44Wl_gB8,2812
43
+ xp/cli/main.py,sha256=3TY4wZoKMK8kQBgOn0WshTsag4J4ofoGoGPgg12wueM,2810
43
44
  xp/cli/utils/__init__.py,sha256=gTGIj60Uai0iE7sr9_TtEpl04fD7krtTzbbigXUsUVU,46
44
45
  xp/cli/utils/click_tree.py,sha256=ilmM2IMa_c-TqUMsv2alrZXuS0BNhvVlrBlSfyN8lzM,1670
45
46
  xp/cli/utils/datapoint_type_choice.py,sha256=HcydhlqxZ7YyorEeTjFGkypF2JnYNPvOzkl1rhZ93Fc,1666
@@ -51,7 +52,7 @@ xp/cli/utils/system_function_choice.py,sha256=0J02EMgAQcsrE-9rEkv6YHelBoBkZ73T8V
51
52
  xp/cli/utils/xp_module_type.py,sha256=qSFJBRceqPi_cUFPxAWtLUNq37-KwUEjo9ekYOj7kLQ,1471
52
53
  xp/connection/__init__.py,sha256=ClJsVWALYZgAGYZK_Jznd3YKLrHDu17kBfwugjuPfu0,209
53
54
  xp/connection/exceptions.py,sha256=7CcRUzkyay5zA6Z9-5dIDRzua806v5N7pCcJazP_1dE,365
54
- xp/models/__init__.py,sha256=wCyJNKBd8J2ziOm0g00eUZH4OeTaLO5vHuoQGd_AJbg,1111
55
+ xp/models/__init__.py,sha256=UaUiuvWevneh9gPzKNaVsuy6rxM7YlZg4mi8VlEJpfg,1210
55
56
  xp/models/actiontable/__init__.py,sha256=6kVq1rTOlpc24sZxGGVWkY48tqR42YWHLQHqakWqlPc,43
56
57
  xp/models/actiontable/actiontable.py,sha256=bIeluZhMsvukkSwy2neaewavU8YR6Pso3PIvJ8ENlGg,1251
57
58
  xp/models/actiontable/msactiontable_xp20.py,sha256=C_lYYIQagEFap0S5S40_S7AhLO2UZG2EmXjjeem7uw8,1967
@@ -66,6 +67,7 @@ xp/models/conbus/conbus_connection_status.py,sha256=iGbmtBaAMwV6UD7XG3H3tnB0fl2M
66
67
  xp/models/conbus/conbus_custom.py,sha256=8H2sPR6_LIlksuOvL7-8bPkzAJLR0rpYiiwfYYFVjEo,1965
67
68
  xp/models/conbus/conbus_datapoint.py,sha256=4ncR-vB2lRzRBAA30rYn8eguyTxsZoOKrrXtjGmPpWg,3396
68
69
  xp/models/conbus/conbus_discover.py,sha256=nxxUEKfEsH1kd0BF8ovMs7zLujRhrq1oL9ZJtysPr5o,2238
70
+ xp/models/conbus/conbus_event_raw.py,sha256=i5gc7z-0yeunWOZ4rw3AiBt4MANezmhBQKjOOQk3oDc,1567
69
71
  xp/models/conbus/conbus_lightlevel.py,sha256=GQGhzrCBEJROosNHInXIzBy6MD2AskEIMoFEGgZ60-0,1695
70
72
  xp/models/conbus/conbus_linknumber.py,sha256=uFzKzfB06oIzZEKCb5X2JEI80JjMPFuYglsT1W1k8j4,1815
71
73
  xp/models/conbus/conbus_output.py,sha256=q7QKsD_CWT7YOk-V3otKWD1VM7qThrSLIUOunntMrMc,1953
@@ -78,7 +80,7 @@ xp/models/homekit/homekit_config.py,sha256=Y_k92PsKHFBnn3r1_RSZHJP5uLH27Gw8G7Bj5
78
80
  xp/models/homekit/homekit_conson_config.py,sha256=NML644Ij7abstMbud-TUPcxraGY4vQwKrkJOwftv2pM,2603
79
81
  xp/models/log_entry.py,sha256=kPcYuAirCXugfL3YkOK9cQVlkNWxG_8a4AVuhsykHL0,4355
80
82
  xp/models/protocol/__init__.py,sha256=TJ_CJKchA-xgQiv5vCo_ndBBZjrcaTmjT74bR0T-5Cw,38
81
- xp/models/protocol/conbus_protocol.py,sha256=igRgnbfjhnmNZG6kqQw4un433EpKTMXR13MJW151_7U,9041
83
+ xp/models/protocol/conbus_protocol.py,sha256=3uWYE_t_-mp_2wPEgbDHbZoeQSEv48IdRcQpQyemEY0,9141
82
84
  xp/models/response.py,sha256=h6_B1k_v6IrWhgNWBohEGQGRCp5TcVhgQ3RJS8gTkhY,1230
83
85
  xp/models/telegram/__init__.py,sha256=-_exhjlRLbBNuPxHC4tLA2SAgf8M0yHJMeyEoQIk9PI,53
84
86
  xp/models/telegram/action_type.py,sha256=vkw_chTgmsadksGXvZ9D_qYGpjOwCw-OgbEi8Bml17g,783
@@ -115,8 +117,9 @@ xp/services/conbus/conbus_blink_all_service.py,sha256=OaEg4b8AEiEruHSkZ5jDtaoI81
115
117
  xp/services/conbus/conbus_blink_service.py,sha256=x9uM-sLnIEV8wSNsvJgo08E042g-Hh2ZF3rXkz-k_9s,5824
116
118
  xp/services/conbus/conbus_custom_service.py,sha256=4aneYdPObiZOGxPFYg5Wr70cl_xFxlQIdJBPQSa0enI,5826
117
119
  xp/services/conbus/conbus_datapoint_queryall_service.py,sha256=p9R02cVimhdJILHQ6BoeZj8Hog4oRpqBnMo3t4R8ecY,6816
118
- xp/services/conbus/conbus_datapoint_service.py,sha256=NsqRQfNsZ4_Pbe7kcMQpUqfhVPH7H148JDWH49ExQ1E,6392
119
- xp/services/conbus/conbus_discover_service.py,sha256=lH6I8YcN7Beo_f-M8XkNZ_5UuNB-x2R9U5xJNTK-QXE,10110
120
+ xp/services/conbus/conbus_datapoint_service.py,sha256=SYhHj9RmTmaJ750tyZ1IW2kl7tgDQ1xm_EM1zUjk1aQ,6421
121
+ xp/services/conbus/conbus_discover_service.py,sha256=sSCSDNWWGtx5QOShwJfcbG54WCYH-BxWvgE10ghibN4,12326
122
+ xp/services/conbus/conbus_event_raw_service.py,sha256=zNY7GxT4R6ROsT1dDhoOoJkGtGbv2_AIBgOlLxZJl1A,7068
120
123
  xp/services/conbus/conbus_output_service.py,sha256=mHFOAPx2zo0TStZ3pokp6v94AQjIamcwZDeg5YH_-eo,7240
121
124
  xp/services/conbus/conbus_raw_service.py,sha256=4yZLLTIAOxpgByUTWZXw1ihGa6Xtl98ckj9T7VfprDI,4335
122
125
  xp/services/conbus/conbus_receive_service.py,sha256=frXrS0OyKKvYYQTWdma21Kd0BKw5aSuHn3ZXTTqOaj0,3953
@@ -133,12 +136,13 @@ xp/services/homekit/homekit_hap_service.py,sha256=YrFe10XPBf6EC2SRnWmcCbjdVkrHjx
133
136
  xp/services/homekit/homekit_lightbulb.py,sha256=7HGMIPwEcmvSs89ENcDhdb8g0R9WMq7115gYuwcskAs,3661
134
137
  xp/services/homekit/homekit_lightbulb_service.py,sha256=G_ummBFiBurhQ2ZVwJ9l_aZ2MQgl5Uq-oi3KjIrdb-Y,2752
135
138
  xp/services/homekit/homekit_module_service.py,sha256=7lanEinxAfTdn28ZHV-O5YyTqq_3v8FIyP2FI0jsEQM,1526
136
- xp/services/homekit/homekit_outlet.py,sha256=_Glfytfmvydz9TEn69N6bw3Ux0Y-EMw2eJPgeborkoA,5268
139
+ xp/services/homekit/homekit_outlet.py,sha256=TtrOwVF3BkEvDcTOkNJIWT64zhtPLFkDgtyzW6u_4yQ,5209
137
140
  xp/services/homekit/homekit_outlet_service.py,sha256=y7DbWbbvihWwF1Gyl0l9Hup1JHin6PTlDEHdoIqTfEQ,3798
138
141
  xp/services/homekit/homekit_service.py,sha256=0lW-hg40ETco3gDBEYkR_sX-UIYsLSKCD4NWruLXUM8,14031
139
142
  xp/services/log_file_service.py,sha256=fvPcZQj8XOKUA-4ep5R8n0PelvwvRlTLlVxvIWM5KR4,10506
140
143
  xp/services/module_type_service.py,sha256=xWhr1EAZMykL5uNWHWdpa5T8yNruGKH43XRTOS8GwZg,7477
141
- xp/services/protocol/__init__.py,sha256=WuYn2iEcvsOIXnn5HCrU9kD3PjuMX1sIh0ljKISDoJw,720
144
+ xp/services/protocol/__init__.py,sha256=qRufBmqRKGzpuzZ5bxBbmwf510TT00Ke8s5HcWGnqRY,818
145
+ xp/services/protocol/conbus_event_protocol.py,sha256=btWLGM-onWXVIvL5atD7HgQKNcx6F8dNqTZf2CSquiE,12272
142
146
  xp/services/protocol/conbus_protocol.py,sha256=JO7yLkD_ohPT0ETjnAIx4CGpZyobf4LdbuozM_43btE,10276
143
147
  xp/services/protocol/protocol_factory.py,sha256=PmjN9AtW9sxNo3voqUiNgQA-pTvX1RW4XXFlHKfFr5Q,2470
144
148
  xp/services/protocol/telegram_protocol.py,sha256=Ki5DrXsKxiaqLcdP9WWUuhUI7cPu2DfwyZkh-Gv9Lb8,9496
@@ -164,8 +168,8 @@ xp/services/telegram/telegram_service.py,sha256=XrP1CPi0ckxoKBaNwLA6lo-TogWxXgmX
164
168
  xp/services/telegram/telegram_version_service.py,sha256=M5HdOTsLdcwo122FP-jW6R740ktLrtKf2TiMDVz23h8,10528
165
169
  xp/utils/__init__.py,sha256=_avMF_UOkfR3tNeDIPqQ5odmbq5raKkaq1rZ9Cn1CJs,332
166
170
  xp/utils/checksum.py,sha256=HDpiQxmdIedbCbZ4o_Box0i_Zig417BtCV_46ZyhiTk,1711
167
- xp/utils/dependencies.py,sha256=ibTkpQ7nak2ioqlPtb7yCqBJ9tl3NHSxDdODtUItbCg,20281
171
+ xp/utils/dependencies.py,sha256=QsZfPDMdlrQK01YQ4PRQ8Q59ZF9w22h1evWX3J4xCjE,20930
168
172
  xp/utils/event_helper.py,sha256=W-A_xmoXlpWZBbJH6qdaN50o3-XrwFsDgvAGMJDiAgo,1001
169
173
  xp/utils/serialization.py,sha256=RWHHk86feaB4ZP7rjE4qOWK0900yg2joUBDkP76gfOY,4618
170
174
  xp/utils/time_utils.py,sha256=dEyViDlAG9GWU-J3D_YVa-sGma6yiyyMTgN4h2x3PY4,3781
171
- conson_xp-1.15.0.dist-info/RECORD,,
175
+ conson_xp-1.17.0.dist-info/RECORD,,
xp/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  conson-xp package.
4
4
  """
5
5
 
6
- __version__ = "1.15.0"
6
+ __version__ = "1.17.0"
7
7
  __manufacturer__ = "salchichon"
8
8
  __model__ = "xp.cli"
9
9
  __serial__ = "2025.09.23.000"
@@ -37,6 +37,7 @@ from xp.cli.commands.conbus.conbus_datapoint_commands import (
37
37
  query_datapoint,
38
38
  )
39
39
  from xp.cli.commands.conbus.conbus_discover_commands import send_discover_telegram
40
+ from xp.cli.commands.conbus.conbus_event_commands import conbus_event, send_event_raw
40
41
  from xp.cli.commands.conbus.conbus_lightlevel_commands import (
41
42
  xp_lightlevel_get,
42
43
  xp_lightlevel_off,
@@ -97,6 +98,7 @@ __all__ = [
97
98
  "conbus_lightlevel",
98
99
  "conbus_msactiontable",
99
100
  "conbus_actiontable",
101
+ "conbus_event",
100
102
  "file",
101
103
  "module",
102
104
  "reverse_proxy",
@@ -118,6 +120,7 @@ __all__ = [
118
120
  "show_config",
119
121
  "send_custom_telegram",
120
122
  "send_discover_telegram",
123
+ "send_event_raw",
121
124
  "xp_output_on",
122
125
  "xp_output_off",
123
126
  "xp_output_status",
@@ -9,6 +9,7 @@ from xp.cli.utils.decorators import (
9
9
  connection_command,
10
10
  )
11
11
  from xp.models import ConbusDiscoverResponse
12
+ from xp.models.conbus.conbus_discover import DiscoveredDevice
12
13
  from xp.services.conbus.conbus_discover_service import (
13
14
  ConbusDiscoverService,
14
15
  )
@@ -36,6 +37,14 @@ def send_discover_telegram(ctx: click.Context) -> None:
36
37
  """
37
38
  click.echo(json.dumps(discovered_devices.to_dict(), indent=2))
38
39
 
40
+ def on_device_discovered(discovered_device: DiscoveredDevice) -> None:
41
+ """Handle discovery of sa single module.
42
+
43
+ Args:
44
+ discovered_device: Discover device.
45
+ """
46
+ click.echo(json.dumps(discovered_device, indent=2))
47
+
39
48
  def progress(_serial_number: str) -> None:
40
49
  """Handle progress updates during device discovery.
41
50
 
@@ -48,5 +57,5 @@ def send_discover_telegram(ctx: click.Context) -> None:
48
57
  service: ConbusDiscoverService = (
49
58
  ctx.obj.get("container").get_container().resolve(ConbusDiscoverService)
50
59
  )
51
- with service:
52
- service.start(progress, on_finish, 0.5)
60
+ service.run(progress, on_device_discovered, on_finish, 5)
61
+ service.start_reactor()
@@ -0,0 +1,115 @@
1
+ """Conbus event operations CLI commands."""
2
+
3
+ import json
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 import ConbusEventRawResponse
10
+ from xp.models.telegram.module_type_code import ModuleTypeCode
11
+ from xp.services.conbus.conbus_event_raw_service import ConbusEventRawService
12
+
13
+
14
+ @click.group(name="event")
15
+ def conbus_event() -> None:
16
+ """Send event telegrams to Conbus modules."""
17
+ pass
18
+
19
+
20
+ @conbus_event.command("raw")
21
+ @click.argument("module_type", type=str)
22
+ @click.argument("link_number", type=int)
23
+ @click.argument("input_number", type=int)
24
+ @click.argument("time_ms", type=int, default=1000)
25
+ @click.pass_context
26
+ @connection_command()
27
+ def send_event_raw(
28
+ ctx: click.Context,
29
+ module_type: str,
30
+ link_number: int,
31
+ input_number: int,
32
+ time_ms: int,
33
+ ) -> None:
34
+ r"""Send raw event telegrams to simulate button presses.
35
+
36
+ Args:
37
+ ctx: Click context object.
38
+ module_type: Module type code (e.g., CP20, XP33).
39
+ link_number: Link number (0-99).
40
+ input_number: Input number (0-9).
41
+ time_ms: Delay between MAKE/BREAK events in milliseconds (default: 1000).
42
+
43
+ Examples:
44
+ \b
45
+ xp conbus event raw CP20 00 00
46
+ xp conbus event raw XP33 00 00 500
47
+ """
48
+ # Validate parameters
49
+ if link_number < 0 or link_number > 99:
50
+ click.echo(
51
+ json.dumps({"error": "Link number must be between 0 and 99"}, indent=2)
52
+ )
53
+ return
54
+
55
+ if input_number < 0 or input_number > 9:
56
+ click.echo(
57
+ json.dumps({"error": "Input number must be between 0 and 9"}, indent=2)
58
+ )
59
+ return
60
+
61
+ if time_ms <= 0:
62
+ click.echo(json.dumps({"error": "Time must be greater than 0"}, indent=2))
63
+ return
64
+
65
+ # Resolve module type to numeric code
66
+ module_type_code: int = 0
67
+ try:
68
+ # Try to get the enum value by name
69
+ module_type_enum = ModuleTypeCode[module_type.upper()]
70
+ module_type_code = module_type_enum.value
71
+ except KeyError:
72
+ # Module type not found
73
+ click.echo(
74
+ json.dumps(
75
+ {
76
+ "error": f"Unknown module type: {module_type}. Use module types like CP20, XP33, XP24, etc."
77
+ },
78
+ indent=2,
79
+ )
80
+ )
81
+ return
82
+
83
+ def on_finish(response: ConbusEventRawResponse) -> None:
84
+ """Handle successful completion of event raw operation.
85
+
86
+ Args:
87
+ response: Event raw response with sent and received telegrams.
88
+ """
89
+ click.echo(json.dumps(response.to_dict(), indent=2))
90
+
91
+ def on_progress(telegram: str) -> None:
92
+ """Handle progress updates during event operation.
93
+
94
+ Args:
95
+ telegram: Received telegram.
96
+ """
97
+ click.echo(json.dumps({"telegram": telegram}))
98
+
99
+ service: ConbusEventRawService = (
100
+ ctx.obj.get("container").get_container().resolve(ConbusEventRawService)
101
+ )
102
+ service.run(
103
+ module_type_code=module_type_code,
104
+ link_number=link_number,
105
+ input_number=input_number,
106
+ time_ms=time_ms,
107
+ progress_callback=on_progress,
108
+ finish_callback=on_finish,
109
+ timeout_seconds=5,
110
+ )
111
+ service.start_reactor()
112
+
113
+
114
+ # Register the event command group with conbus
115
+ conbus.add_command(conbus_event)
xp/cli/main.py CHANGED
@@ -56,7 +56,7 @@ def cli(ctx: click.Context) -> None:
56
56
 
57
57
  # xp
58
58
  logging.getLogger("xp").setLevel(logging.DEBUG)
59
- logging.getLogger("xp.services.homekit").setLevel(logging.WARNING)
59
+ logging.getLogger("xp.services.homekit").setLevel(logging.DEBUG)
60
60
 
61
61
  # pyhap
62
62
  logging.getLogger("pyhap").setLevel(logging.WARNING)
xp/models/__init__.py CHANGED
@@ -5,6 +5,7 @@ from xp.models.conbus.conbus_client_config import ConbusClientConfig
5
5
  from xp.models.conbus.conbus_connection_status import ConbusConnectionStatus
6
6
  from xp.models.conbus.conbus_datapoint import ConbusDatapointResponse
7
7
  from xp.models.conbus.conbus_discover import ConbusDiscoverResponse
8
+ from xp.models.conbus.conbus_event_raw import ConbusEventRawResponse
8
9
  from xp.models.log_entry import LogEntry
9
10
  from xp.models.telegram.event_telegram import EventTelegram
10
11
  from xp.models.telegram.event_type import EventType
@@ -30,5 +31,6 @@ __all__ = [
30
31
  "ConbusResponse",
31
32
  "ConbusDatapointResponse",
32
33
  "ConbusDiscoverResponse",
34
+ "ConbusEventRawResponse",
33
35
  "ConbusConnectionStatus",
34
36
  ]
@@ -0,0 +1,47 @@
1
+ """Conbus event raw response model."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ @dataclass
9
+ class ConbusEventRawResponse:
10
+ """Represents a response from Conbus event raw operation.
11
+
12
+ Attributes:
13
+ success: Whether the operation was successful.
14
+ sent_telegrams: List of event telegrams sent (MAKE and BREAK).
15
+ received_telegrams: List of all telegrams received.
16
+ error: Error message if operation failed.
17
+ timestamp: Timestamp of the response.
18
+ """
19
+
20
+ success: bool
21
+ sent_telegrams: Optional[list[str]] = None
22
+ received_telegrams: Optional[list[str]] = None
23
+ error: Optional[str] = None
24
+ timestamp: Optional[datetime] = None
25
+
26
+ def __post_init__(self) -> None:
27
+ """Initialize timestamp and telegram lists if not provided."""
28
+ if self.timestamp is None:
29
+ self.timestamp = datetime.now()
30
+ if self.sent_telegrams is None:
31
+ self.sent_telegrams = []
32
+ if self.received_telegrams is None:
33
+ self.received_telegrams = []
34
+
35
+ def to_dict(self) -> Dict[str, Any]:
36
+ """Convert to dictionary for JSON serialization.
37
+
38
+ Returns:
39
+ Dictionary representation of the response.
40
+ """
41
+ return {
42
+ "success": self.success,
43
+ "sent_telegrams": self.sent_telegrams,
44
+ "received_telegrams": self.received_telegrams,
45
+ "error": self.error,
46
+ "timestamp": self.timestamp.isoformat() if self.timestamp else None,
47
+ }
@@ -12,6 +12,7 @@ from xp.models.homekit.homekit_conson_config import ConsonModuleConfig
12
12
  from xp.models.telegram.datapoint_type import DataPointType
13
13
 
14
14
  if TYPE_CHECKING:
15
+ from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
15
16
  from xp.services.protocol.conbus_protocol import ConbusProtocol
16
17
  from xp.services.protocol.telegram_protocol import TelegramProtocol
17
18
 
@@ -249,7 +250,7 @@ class TelegramEvent(BaseEvent):
249
250
  checksum_valid: Checksum valid true or false.
250
251
  """
251
252
 
252
- protocol: Union[TelegramProtocol, ConbusProtocol] = Field(
253
+ protocol: Union[TelegramProtocol, ConbusProtocol, ConbusEventProtocol] = Field(
253
254
  description="TelegramProtocol instance"
254
255
  )
255
256
  frame: str = Field(description="Frame <S0123450001F02D12FK>")
@@ -130,6 +130,7 @@ class ConbusDatapointService(ConbusProtocol):
130
130
  self.service_response.data_value = datapoint_telegram.data_value
131
131
  if self.datapoint_finished_callback:
132
132
  self.datapoint_finished_callback(self.service_response)
133
+ self._stop_reactor()
133
134
 
134
135
  def failed(self, message: str) -> None:
135
136
  """Handle failed connection event.
@@ -7,49 +7,57 @@ discover telegrams to find modules on the network.
7
7
  import logging
8
8
  from typing import Callable, Optional
9
9
 
10
- from twisted.internet.posixbase import PosixReactorBase
11
-
12
- from xp.models import ConbusClientConfig, ConbusDiscoverResponse
10
+ from xp.models import ConbusDiscoverResponse
13
11
  from xp.models.conbus.conbus_discover import DiscoveredDevice
14
12
  from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
15
13
  from xp.models.telegram.datapoint_type import DataPointType
16
14
  from xp.models.telegram.module_type_code import MODULE_TYPE_REGISTRY
17
15
  from xp.models.telegram.system_function import SystemFunction
18
16
  from xp.models.telegram.telegram_type import TelegramType
19
- from xp.services.protocol.conbus_protocol import ConbusProtocol
17
+ from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
20
18
 
21
19
 
22
- class ConbusDiscoverService(ConbusProtocol):
20
+ class ConbusDiscoverService:
23
21
  """
24
22
  Service for discovering modules on Conbus servers.
25
23
 
26
24
  Uses ConbusProtocol to provide discovery functionality for finding
27
25
  modules connected to the Conbus network.
26
+
27
+ Attributes:
28
+ conbus_protocol: Protocol instance for Conbus communication.
28
29
  """
29
30
 
30
- def __init__(
31
- self,
32
- cli_config: ConbusClientConfig,
33
- reactor: PosixReactorBase,
34
- ) -> None:
31
+ conbus_protocol: ConbusEventProtocol
32
+
33
+ def __init__(self, conbus_protocol: ConbusEventProtocol) -> None:
35
34
  """Initialize the Conbus discover service.
36
35
 
37
36
  Args:
38
- cli_config: Conbus client configuration.
39
- reactor: Twisted reactor instance.
37
+ conbus_protocol: ConbusProtocol.
40
38
  """
41
- super().__init__(cli_config, reactor)
42
39
  self.progress_callback: Optional[Callable[[str], None]] = None
40
+ self.device_discover_callback: Optional[Callable[[DiscoveredDevice], None]] = (
41
+ None
42
+ )
43
43
  self.finish_callback: Optional[Callable[[ConbusDiscoverResponse], None]] = None
44
44
 
45
+ self.conbus_protocol: ConbusEventProtocol = conbus_protocol
46
+ self.conbus_protocol.on_connection_made.connect(self.connection_made)
47
+ self.conbus_protocol.on_telegram_sent.connect(self.telegram_sent)
48
+ self.conbus_protocol.on_telegram_received.connect(self.telegram_received)
49
+ self.conbus_protocol.on_timeout.connect(self.timeout)
50
+ self.conbus_protocol.on_failed.connect(self.failed)
51
+
45
52
  self.discovered_device_result = ConbusDiscoverResponse(success=False)
46
53
  # Set up logging
47
54
  self.logger = logging.getLogger(__name__)
48
55
 
49
- def connection_established(self) -> None:
56
+ def connection_made(self) -> None:
50
57
  """Handle connection established event."""
51
- self.logger.debug("Connection established, sending discover telegram")
52
- self.send_telegram(
58
+ self.logger.debug("Connection established")
59
+ self.logger.debug("Sending discover telegram")
60
+ self.conbus_protocol.send_telegram(
53
61
  telegram_type=TelegramType.SYSTEM,
54
62
  serial_number="0000000000",
55
63
  system_function=SystemFunction.DISCOVERY,
@@ -83,7 +91,7 @@ class ConbusDiscoverService(ConbusProtocol):
83
91
  and telegram_received.payload[11:16] == "F01D"
84
92
  and len(telegram_received.payload) == 15
85
93
  ):
86
- self.discovered_device(telegram_received.serial_number)
94
+ self.handle_discovered_device(telegram_received.serial_number)
87
95
 
88
96
  # Check for module type response (F02D07)
89
97
  elif (
@@ -109,7 +117,7 @@ class ConbusDiscoverService(ConbusProtocol):
109
117
  else:
110
118
  self.logger.debug("Not a discover or module type response")
111
119
 
112
- def discovered_device(self, serial_number: str) -> None:
120
+ def handle_discovered_device(self, serial_number: str) -> None:
113
121
  """Handle discovered device event.
114
122
 
115
123
  Args:
@@ -128,22 +136,17 @@ class ConbusDiscoverService(ConbusProtocol):
128
136
  }
129
137
  self.discovered_device_result.discovered_devices.append(device)
130
138
 
139
+ if self.device_discover_callback:
140
+ self.device_discover_callback(device)
141
+
131
142
  # Send READ_DATAPOINT telegram to query module type
132
143
  self.logger.debug(f"Sending module type query for {serial_number}")
133
- self.send_telegram(
144
+ self.conbus_protocol.send_telegram(
134
145
  telegram_type=TelegramType.SYSTEM,
135
146
  serial_number=serial_number,
136
147
  system_function=SystemFunction.READ_DATAPOINT,
137
148
  data_value=DataPointType.MODULE_TYPE.value,
138
149
  )
139
-
140
- self.send_telegram(
141
- telegram_type=TelegramType.SYSTEM,
142
- serial_number=serial_number,
143
- system_function=SystemFunction.READ_DATAPOINT,
144
- data_value=DataPointType.MODULE_TYPE_CODE.value,
145
- )
146
-
147
150
  if self.progress_callback:
148
151
  self.progress_callback(serial_number)
149
152
 
@@ -190,11 +193,27 @@ class ConbusDiscoverService(ConbusProtocol):
190
193
  if device["serial_number"] == serial_number:
191
194
  device["module_type_code"] = code
192
195
  device["module_type_name"] = module_type_name
196
+
197
+ if self.device_discover_callback:
198
+ self.device_discover_callback(device)
199
+
193
200
  self.logger.debug(
194
201
  f"Updated device {serial_number} with module_type {module_type_name}"
195
202
  )
196
203
  break
197
204
 
205
+ if self.discovered_device_result.discovered_devices:
206
+ for device in self.discovered_device_result.discovered_devices:
207
+ if not (
208
+ device["serial_number"]
209
+ and device["module_type"]
210
+ and device["module_type_code"]
211
+ and device["module_type_name"]
212
+ ):
213
+ return
214
+
215
+ self.succeed()
216
+
198
217
  def handle_module_type_response(self, serial_number: str, module_type: str) -> None:
199
218
  """Handle module type response and update discovered device.
200
219
 
@@ -212,19 +231,28 @@ class ConbusDiscoverService(ConbusProtocol):
212
231
  self.logger.debug(
213
232
  f"Updated device {serial_number} with module_type {module_type}"
214
233
  )
234
+ if self.device_discover_callback:
235
+ self.device_discover_callback(device)
236
+
215
237
  break
216
238
 
217
- def timeout(self) -> bool:
218
- """Handle timeout event to stop discovery.
239
+ self.conbus_protocol.send_telegram(
240
+ telegram_type=TelegramType.SYSTEM,
241
+ serial_number=serial_number,
242
+ system_function=SystemFunction.READ_DATAPOINT,
243
+ data_value=DataPointType.MODULE_TYPE_CODE.value,
244
+ )
219
245
 
220
- Returns:
221
- False to stop the reactor.
222
- """
223
- self.logger.info("Discovery stopped after: %ss", self.timeout_seconds)
224
- self.discovered_device_result.success = True
246
+ def timeout(self) -> None:
247
+ """Handle timeout event to stop discovery."""
248
+ timeout = self.conbus_protocol.timeout_seconds
249
+ self.logger.info("Discovery stopped after: %ss", timeout)
250
+ self.discovered_device_result.success = False
251
+ self.discovered_device_result.error = "Discovered device timeout"
225
252
  if self.finish_callback:
226
253
  self.finish_callback(self.discovered_device_result)
227
- return False
254
+
255
+ self.stop_reactor()
228
256
 
229
257
  def failed(self, message: str) -> None:
230
258
  """Handle failed connection event.
@@ -238,9 +266,32 @@ class ConbusDiscoverService(ConbusProtocol):
238
266
  if self.finish_callback:
239
267
  self.finish_callback(self.discovered_device_result)
240
268
 
241
- def start(
269
+ self.stop_reactor()
270
+
271
+ def succeed(self) -> None:
272
+ """Handle discovered device success event."""
273
+ self.logger.debug("Succeed")
274
+ self.discovered_device_result.success = True
275
+ self.discovered_device_result.error = None
276
+ if self.finish_callback:
277
+ self.finish_callback(self.discovered_device_result)
278
+
279
+ self.stop_reactor()
280
+
281
+ def stop_reactor(self) -> None:
282
+ """Stop reactor."""
283
+ self.logger.info("Stopping reactor")
284
+ self.conbus_protocol.stop_reactor()
285
+
286
+ def start_reactor(self) -> None:
287
+ """Start reactor."""
288
+ self.logger.info("Starting reactor")
289
+ self.conbus_protocol.start_reactor()
290
+
291
+ def run(
242
292
  self,
243
293
  progress_callback: Callable[[str], None],
294
+ device_discover_callback: Callable[[DiscoveredDevice], None],
244
295
  finish_callback: Callable[[ConbusDiscoverResponse], None],
245
296
  timeout_seconds: Optional[float] = None,
246
297
  ) -> None:
@@ -248,12 +299,14 @@ class ConbusDiscoverService(ConbusProtocol):
248
299
 
249
300
  Args:
250
301
  progress_callback: Callback for each discovered device.
302
+ device_discover_callback: Callback for each discovered device.
251
303
  finish_callback: Callback when discovery completes.
252
304
  timeout_seconds: Optional timeout in seconds.
253
305
  """
254
306
  self.logger.info("Starting discovery")
307
+
255
308
  if timeout_seconds:
256
- self.timeout_seconds = timeout_seconds
309
+ self.conbus_protocol.timeout_seconds = timeout_seconds
257
310
  self.progress_callback = progress_callback
311
+ self.device_discover_callback = device_discover_callback
258
312
  self.finish_callback = finish_callback
259
- self.start_reactor()
@@ -0,0 +1,185 @@
1
+ """Conbus Event Raw Service for sending raw event telegrams.
2
+
3
+ This service implements a TCP client that connects to Conbus servers and sends
4
+ raw event telegrams to simulate button presses on Conbus modules.
5
+ """
6
+
7
+ import logging
8
+ from typing import Callable, Optional
9
+
10
+ from twisted.internet.base import DelayedCall
11
+
12
+ from xp.models import ConbusEventRawResponse
13
+ from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
14
+ from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
15
+
16
+
17
+ class ConbusEventRawService:
18
+ """Service for sending raw event telegrams to Conbus servers.
19
+
20
+ Uses ConbusEventProtocol to send MAKE/BREAK event sequences to
21
+ simulate button presses on Conbus modules.
22
+
23
+ Attributes:
24
+ conbus_protocol: Protocol instance for Conbus communication.
25
+ """
26
+
27
+ conbus_protocol: ConbusEventProtocol
28
+
29
+ def __init__(self, conbus_protocol: ConbusEventProtocol) -> None:
30
+ """Initialize the Conbus event raw service.
31
+
32
+ Args:
33
+ conbus_protocol: ConbusEventProtocol instance.
34
+ """
35
+ self.progress_callback: Optional[Callable[[str], None]] = None
36
+ self.finish_callback: Optional[Callable[[ConbusEventRawResponse], None]] = None
37
+
38
+ self.conbus_protocol: ConbusEventProtocol = conbus_protocol
39
+ self.conbus_protocol.on_connection_made.connect(self.connection_made)
40
+ self.conbus_protocol.on_telegram_sent.connect(self.telegram_sent)
41
+ self.conbus_protocol.on_telegram_received.connect(self.telegram_received)
42
+ self.conbus_protocol.on_timeout.connect(self.timeout)
43
+ self.conbus_protocol.on_failed.connect(self.failed)
44
+
45
+ self.event_result = ConbusEventRawResponse(success=False)
46
+ self.logger = logging.getLogger(__name__)
47
+
48
+ # Event parameters
49
+ self.module_type_code: int = 0
50
+ self.link_number: int = 0
51
+ self.input_number: int = 0
52
+ self.time_ms: int = 1000
53
+ self.break_event_call: Optional[DelayedCall] = None
54
+
55
+ def connection_made(self) -> None:
56
+ """Handle connection established event."""
57
+ self.logger.debug("Connection established")
58
+ self.logger.debug("Sending MAKE event telegram")
59
+ self._send_make_event()
60
+
61
+ def _send_make_event(self) -> None:
62
+ """Send MAKE event telegram."""
63
+ payload = f"E{self.module_type_code:02d}L{self.link_number:02d}I{self.input_number:02d}M"
64
+ self.logger.debug(f"Sending MAKE event: {payload}")
65
+ self.conbus_protocol.telegram_queue.put_nowait(payload.encode())
66
+ self.conbus_protocol._reactor.callLater(
67
+ 0.0, self.conbus_protocol.start_queue_manager
68
+ )
69
+
70
+ # Schedule BREAK event after delay
71
+ delay_seconds = self.time_ms / 1000.0
72
+ self.break_event_call = self.conbus_protocol._reactor.callLater(
73
+ delay_seconds, self._send_break_event
74
+ )
75
+
76
+ def _send_break_event(self) -> None:
77
+ """Send BREAK event telegram."""
78
+ payload = f"E{self.module_type_code:02d}L{self.link_number:02d}I{self.input_number:02d}B"
79
+ self.logger.debug(f"Sending BREAK event: {payload}")
80
+ self.conbus_protocol.telegram_queue.put_nowait(payload.encode())
81
+ self.conbus_protocol._reactor.callLater(
82
+ 0.0, self.conbus_protocol.start_queue_manager
83
+ )
84
+
85
+ def telegram_sent(self, telegram_sent: str) -> None:
86
+ """Handle telegram sent event.
87
+
88
+ Args:
89
+ telegram_sent: The telegram that was sent.
90
+ """
91
+ self.logger.debug(f"Telegram sent: {telegram_sent}")
92
+ if self.event_result.sent_telegrams is None:
93
+ self.event_result.sent_telegrams = []
94
+ self.event_result.sent_telegrams.append(telegram_sent)
95
+
96
+ def telegram_received(self, telegram_received: TelegramReceivedEvent) -> None:
97
+ """Handle telegram received event.
98
+
99
+ Args:
100
+ telegram_received: The telegram received event.
101
+ """
102
+ self.logger.debug(f"Telegram received: {telegram_received.frame}")
103
+ if self.event_result.received_telegrams is None:
104
+ self.event_result.received_telegrams = []
105
+ self.event_result.received_telegrams.append(telegram_received.frame)
106
+
107
+ # Display progress - show ALL received telegrams
108
+ if self.progress_callback:
109
+ self.progress_callback(telegram_received.frame)
110
+
111
+ def timeout(self) -> None:
112
+ """Handle timeout event.
113
+
114
+ Timeout is the normal/expected way to finish this service.
115
+ """
116
+ timeout_seconds = self.conbus_protocol.timeout_seconds
117
+ self.logger.info("Event raw finished after timeout: %ss", timeout_seconds)
118
+ self.event_result.success = True
119
+ self.event_result.error = None
120
+ if self.finish_callback:
121
+ self.finish_callback(self.event_result)
122
+
123
+ self.stop_reactor()
124
+
125
+ def failed(self, message: str) -> None:
126
+ """Handle failed connection event.
127
+
128
+ Args:
129
+ message: Failure message.
130
+ """
131
+ self.logger.debug(f"Failed: {message}")
132
+ self.event_result.success = False
133
+ self.event_result.error = message
134
+ if self.finish_callback:
135
+ self.finish_callback(self.event_result)
136
+
137
+ self.stop_reactor()
138
+
139
+ def stop_reactor(self) -> None:
140
+ """Stop reactor."""
141
+ self.logger.info("Stopping reactor")
142
+ # Cancel break event call if it's still pending
143
+ if self.break_event_call and self.break_event_call.active():
144
+ self.break_event_call.cancel()
145
+ self.conbus_protocol.stop_reactor()
146
+
147
+ def start_reactor(self) -> None:
148
+ """Start reactor."""
149
+ self.logger.info("Starting reactor")
150
+ self.conbus_protocol.start_reactor()
151
+
152
+ def run(
153
+ self,
154
+ module_type_code: int,
155
+ link_number: int,
156
+ input_number: int,
157
+ time_ms: int,
158
+ progress_callback: Optional[Callable[[str], None]],
159
+ finish_callback: Callable[[ConbusEventRawResponse], None],
160
+ timeout_seconds: int = 5,
161
+ ) -> None:
162
+ """Run reactor in dedicated thread with its own event loop.
163
+
164
+ Args:
165
+ module_type_code: Module type code (numeric, e.g., 2 for CP20, 33 for XP33).
166
+ link_number: Link number (0-99).
167
+ input_number: Input number (0-9).
168
+ time_ms: Delay in milliseconds between MAKE and BREAK events.
169
+ progress_callback: Callback for progress updates (received telegrams).
170
+ finish_callback: Callback when operation completes.
171
+ timeout_seconds: Timeout in seconds (default: 5).
172
+ """
173
+ self.logger.info(
174
+ f"Starting event raw: module={module_type_code}, "
175
+ f"link={link_number}, input={input_number}, time={time_ms}ms"
176
+ )
177
+
178
+ self.module_type_code = module_type_code
179
+ self.link_number = link_number
180
+ self.input_number = input_number
181
+ self.time_ms = time_ms
182
+
183
+ self.conbus_protocol.timeout_seconds = timeout_seconds
184
+ self.progress_callback = progress_callback
185
+ self.finish_callback = finish_callback
@@ -134,19 +134,18 @@ class Outlet(Accessory):
134
134
  value: True to turn on, False to turn off.
135
135
  """
136
136
  # Emit set event
137
- self.logger.debug(f"set_on {value}")
138
-
139
- if value != self.is_on:
140
- self.is_on = value
141
- self.event_bus.dispatch(
142
- OutletSetOnEvent(
143
- serial_number=self.accessory.serial_number,
144
- output_number=self.accessory.output_number,
145
- module=self.module,
146
- accessory=self.accessory,
147
- value=value,
148
- )
137
+ self.logger.debug(f"set_on {value} {self.is_on}")
138
+
139
+ self.is_on = value
140
+ self.event_bus.dispatch(
141
+ OutletSetOnEvent(
142
+ serial_number=self.accessory.serial_number,
143
+ output_number=self.accessory.output_number,
144
+ module=self.module,
145
+ accessory=self.accessory,
146
+ value=value,
149
147
  )
148
+ )
150
149
 
151
150
  def get_on(self) -> bool:
152
151
  """Get the on/off state of the outlet.
@@ -7,10 +7,11 @@ from xp.models.protocol.conbus_protocol import (
7
7
  ModuleDiscoveredEvent,
8
8
  TelegramReceivedEvent,
9
9
  )
10
+ from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
10
11
  from xp.services.protocol.conbus_protocol import ConbusProtocol
11
12
  from xp.services.protocol.telegram_protocol import TelegramProtocol
12
13
 
13
- __all__ = ["TelegramProtocol", "ConbusProtocol"]
14
+ __all__ = ["TelegramProtocol", "ConbusProtocol", "ConbusEventProtocol"]
14
15
 
15
16
  # Rebuild models after TelegramProtocol and ConbusProtocol are imported to resolve forward references
16
17
  ConnectionMadeEvent.model_rebuild()
@@ -0,0 +1,342 @@
1
+ """Conbus Event Protocol for XP telegram communication.
2
+
3
+ This module implements the Twisted protocol for Conbus communication.
4
+ """
5
+
6
+ import logging
7
+ from queue import SimpleQueue
8
+ from random import randint
9
+ from threading import Lock
10
+ from typing import Any, Optional
11
+
12
+ from psygnal import Signal
13
+ from twisted.internet import protocol
14
+ from twisted.internet.base import DelayedCall
15
+ from twisted.internet.interfaces import IAddress, IConnector
16
+ from twisted.internet.posixbase import PosixReactorBase
17
+ from twisted.python.failure import Failure
18
+
19
+ from xp.models import ConbusClientConfig
20
+ from xp.models.protocol.conbus_protocol import (
21
+ TelegramReceivedEvent,
22
+ )
23
+ from xp.models.telegram.system_function import SystemFunction
24
+ from xp.models.telegram.telegram_type import TelegramType
25
+ from xp.utils import calculate_checksum
26
+
27
+
28
+ class ConbusEventProtocol(protocol.Protocol, protocol.ClientFactory):
29
+ """Twisted protocol for XP telegram communication.
30
+
31
+ Attributes:
32
+ buffer: Buffer for incoming telegram data.
33
+ logger: Logger instance for this protocol.
34
+ cli_config: Conbus configuration settings.
35
+ timeout_seconds: Timeout duration in seconds.
36
+ timeout_call: Delayed call handle for timeout management.
37
+ telegram_queue: FIFO queue for outgoing telegrams.
38
+ queue_manager_running: Flag indicating if queue manager is active.
39
+ queue_manager_lock: Lock for thread-safe queue manager access.
40
+ on_connection_made: Signal emitted when connection is established.
41
+ on_connection_lost: Signal emitted when connection is lost.
42
+ on_connection_failed: Signal emitted when connection fails.
43
+ on_client_connection_failed: Signal emitted when client connection fails.
44
+ on_client_connection_lost: Signal emitted when client connection is lost.
45
+ on_send_frame: Signal emitted when a frame is sent.
46
+ on_telegram_sent: Signal emitted when a telegram is sent.
47
+ on_data_received: Signal emitted when data is received.
48
+ on_telegram_received: Signal emitted when a telegram is received.
49
+ on_timeout: Signal emitted when timeout occurs.
50
+ on_failed: Signal emitted when operation fails.
51
+ on_start_reactor: Signal emitted when reactor starts.
52
+ on_stop_reactor: Signal emitted when reactor stops.
53
+ """
54
+
55
+ buffer: bytes
56
+
57
+ telegram_queue: SimpleQueue[bytes] = SimpleQueue() # FIFO
58
+ queue_manager_running: bool = False
59
+ queue_manager_lock: Lock = Lock()
60
+
61
+ on_connection_made: Signal = Signal()
62
+ on_connection_lost: Signal = Signal()
63
+ on_connection_failed: Signal = Signal(Failure)
64
+ on_client_connection_failed: Signal = Signal(Failure)
65
+ on_client_connection_lost: Signal = Signal(Failure)
66
+ on_send_frame: Signal = Signal(bytes)
67
+ on_telegram_sent: Signal = Signal(bytes)
68
+ on_data_received: Signal = Signal(bytes)
69
+ on_telegram_received: Signal = Signal(TelegramReceivedEvent)
70
+ on_timeout: Signal = Signal()
71
+ on_failed: Signal = Signal(str)
72
+ on_start_reactor: Signal = Signal()
73
+ on_stop_reactor: Signal = Signal()
74
+
75
+ def __init__(
76
+ self,
77
+ cli_config: ConbusClientConfig,
78
+ reactor: PosixReactorBase,
79
+ ) -> None:
80
+ """Initialize ConbusProtocol.
81
+
82
+ Args:
83
+ cli_config: Configuration for Conbus client connection.
84
+ reactor: Twisted reactor for event handling.
85
+ """
86
+ self.buffer = b""
87
+ self.logger = logging.getLogger(__name__)
88
+ self.cli_config = cli_config.conbus
89
+ self._reactor = reactor
90
+ self.timeout_seconds = self.cli_config.timeout
91
+ self.timeout_call: Optional[DelayedCall] = None
92
+
93
+ def connectionMade(self) -> None:
94
+ """Handle connection established event.
95
+
96
+ Called when TCP connection is successfully established.
97
+ Starts inactivity timeout monitoring.
98
+ """
99
+ self.logger.debug("connectionMade")
100
+ self.on_connection_made.emit()
101
+
102
+ # Start inactivity timeout
103
+ self._reset_timeout()
104
+
105
+ def dataReceived(self, data: bytes) -> None:
106
+ """Handle received data from TCP connection.
107
+
108
+ Parses incoming telegram frames and dispatches events.
109
+
110
+ Args:
111
+ data: Raw bytes received from connection.
112
+ """
113
+ self.logger.debug("dataReceived")
114
+ self.on_data_received.emit(data)
115
+ self.buffer += data
116
+
117
+ while True:
118
+ start = self.buffer.find(b"<")
119
+ if start == -1:
120
+ break
121
+
122
+ end = self.buffer.find(b">", start)
123
+ if end == -1:
124
+ break
125
+
126
+ # <S0123450001F02D12FK>
127
+ # <R0123450001F02D12FK>
128
+ # <E12L01I08MAK>
129
+ frame = self.buffer[start : end + 1] # <S0123450001F02D12FK>
130
+ self.buffer = self.buffer[end + 1 :]
131
+ telegram = frame[1:-1] # S0123450001F02D12FK
132
+ telegram_type = telegram[0:1].decode() # S
133
+ payload = telegram[:-2] # S0123450001F02D12
134
+ checksum = telegram[-2:].decode() # FK
135
+ serial_number = (
136
+ telegram[1:11] if telegram_type in ("S", "R") else b""
137
+ ) # 0123450001
138
+ calculated_checksum = calculate_checksum(payload.decode(encoding="latin-1"))
139
+
140
+ checksum_valid = checksum == calculated_checksum
141
+ if not checksum_valid:
142
+ self.logger.debug(
143
+ f"Invalid checksum: {checksum}, calculated: {calculated_checksum}"
144
+ )
145
+
146
+ self.logger.debug(
147
+ f"frameReceived payload: {payload.decode('latin-1')}, checksum: {checksum}"
148
+ )
149
+
150
+ # Reset timeout on activity
151
+ self._reset_timeout()
152
+
153
+ telegram_received = TelegramReceivedEvent(
154
+ protocol=self,
155
+ frame=frame.decode("latin-1"),
156
+ telegram=telegram.decode("latin-1"),
157
+ payload=payload.decode("latin-1"),
158
+ telegram_type=telegram_type,
159
+ serial_number=serial_number,
160
+ checksum=checksum,
161
+ checksum_valid=checksum_valid,
162
+ )
163
+ self.on_telegram_received.emit(telegram_received)
164
+
165
+ def sendFrame(self, data: bytes) -> None:
166
+ """Send telegram frame.
167
+
168
+ Args:
169
+ data: Raw telegram payload (without checksum/framing).
170
+
171
+ Raises:
172
+ IOError: If transport is not open.
173
+ """
174
+ self.on_send_frame.emit(data)
175
+
176
+ # Calculate full frame (add checksum and brackets)
177
+ checksum = calculate_checksum(data.decode())
178
+ frame_data = data.decode() + checksum
179
+ frame = b"<" + frame_data.encode() + b">"
180
+
181
+ if not self.transport:
182
+ self.logger.info("Invalid transport")
183
+ raise IOError("Transport is not open")
184
+
185
+ self.logger.debug(f"Sending frame: {frame.decode()}")
186
+ self.transport.write(frame) # type: ignore
187
+ self.on_telegram_sent.emit(frame.decode())
188
+ self._reset_timeout()
189
+
190
+ def send_telegram(
191
+ self,
192
+ telegram_type: TelegramType,
193
+ serial_number: str,
194
+ system_function: SystemFunction,
195
+ data_value: str,
196
+ ) -> None:
197
+ """Send telegram with specified parameters.
198
+
199
+ Args:
200
+ telegram_type: Type of telegram to send.
201
+ serial_number: Device serial number.
202
+ system_function: System function code.
203
+ data_value: Data value to send.
204
+ """
205
+ payload = (
206
+ f"{telegram_type.value}"
207
+ f"{serial_number}"
208
+ f"F{system_function.value}"
209
+ f"D{data_value}"
210
+ )
211
+ self.telegram_queue.put_nowait(payload.encode())
212
+ self._reactor.callLater(0.0, self.start_queue_manager)
213
+
214
+ def buildProtocol(self, addr: IAddress) -> protocol.Protocol:
215
+ """Build protocol instance for connection.
216
+
217
+ Args:
218
+ addr: Address of the connection.
219
+
220
+ Returns:
221
+ Protocol instance for this connection.
222
+ """
223
+ self.logger.debug(f"buildProtocol: {addr}")
224
+ return self
225
+
226
+ def clientConnectionFailed(self, connector: IConnector, reason: Failure) -> None:
227
+ """Handle client connection failure.
228
+
229
+ Args:
230
+ connector: Connection connector instance.
231
+ reason: Failure reason details.
232
+ """
233
+ self.logger.debug(f"clientConnectionFailed: {reason}")
234
+ self.on_client_connection_failed.emit(reason)
235
+ self.connection_failed(reason)
236
+ self._cancel_timeout()
237
+
238
+ def clientConnectionLost(self, connector: IConnector, reason: Failure) -> None:
239
+ """Handle client connection lost event.
240
+
241
+ Args:
242
+ connector: Connection connector instance.
243
+ reason: Reason for connection loss.
244
+ """
245
+ self.logger.debug(f"clientConnectionLost: {reason}")
246
+ self.on_connection_lost.emit(reason)
247
+ self._cancel_timeout()
248
+
249
+ def timeout(self) -> None:
250
+ """Handle timeout event."""
251
+ self.logger.info("Timeout after: %ss", self.timeout_seconds)
252
+ self.on_timeout.emit()
253
+
254
+ def connection_failed(self, reason: Failure) -> None:
255
+ """Handle connection failure.
256
+
257
+ Args:
258
+ reason: Failure reason details.
259
+ """
260
+ self.logger.debug(f"Client connection failed: {reason}")
261
+ self.on_connection_failed.emit(reason)
262
+ self.on_failed.emit(reason.getErrorMessage())
263
+
264
+ def _reset_timeout(self) -> None:
265
+ """Reset the inactivity timeout."""
266
+ self._cancel_timeout()
267
+ self.timeout_call = self._reactor.callLater(
268
+ self.timeout_seconds, self._on_timeout
269
+ )
270
+
271
+ def _cancel_timeout(self) -> None:
272
+ """Cancel the inactivity timeout."""
273
+ if self.timeout_call and self.timeout_call.active():
274
+ self.timeout_call.cancel()
275
+
276
+ def _on_timeout(self) -> None:
277
+ """Handle inactivity timeout expiration."""
278
+ self.timeout()
279
+ self.logger.debug(f"Conbus timeout after {self.timeout_seconds} seconds")
280
+
281
+ def stop_reactor(self) -> None:
282
+ """Stop the reactor if it's running."""
283
+ if self._reactor.running:
284
+ self.logger.info("Stopping reactor")
285
+ self._reactor.stop()
286
+
287
+ def start_reactor(self) -> None:
288
+ """Start the reactor if it's running."""
289
+ # Connect to TCP server
290
+ self.logger.info(
291
+ f"Connecting to TCP server {self.cli_config.ip}:{self.cli_config.port}"
292
+ )
293
+ self._reactor.connectTCP(self.cli_config.ip, self.cli_config.port, self)
294
+
295
+ # Run the reactor (which now uses asyncio underneath)
296
+ self.logger.info("Starting reactor event loop.")
297
+ self._reactor.run()
298
+
299
+ def start_queue_manager(self) -> None:
300
+ """Start the queue manager if it's not running."""
301
+ with self.queue_manager_lock:
302
+ if self.queue_manager_running:
303
+ return
304
+ self.logger.debug("Queue manager: starting")
305
+ self.queue_manager_running = True
306
+ self.process_telegram_queue()
307
+
308
+ def process_telegram_queue(self) -> None:
309
+ """Start the queue manager if it's not running."""
310
+ self.logger.debug(
311
+ f"Queue manager: processing (remaining: {self.telegram_queue.qsize()})"
312
+ )
313
+ if self.telegram_queue.empty():
314
+ with self.queue_manager_lock:
315
+ self.logger.debug("Queue manager: stopping")
316
+ self.queue_manager_running = False
317
+ return
318
+
319
+ self.logger.debug("Queue manager: event loop")
320
+ telegram = self.telegram_queue.get_nowait()
321
+ self.sendFrame(telegram)
322
+ later = randint(10, 80) / 100
323
+ self._reactor.callLater(later, self.process_telegram_queue)
324
+
325
+ def __enter__(self) -> "ConbusEventProtocol":
326
+ """Enter context manager.
327
+
328
+ Returns:
329
+ Self for context management.
330
+ """
331
+ self.logger.debug("Entering the event loop.")
332
+ return self
333
+
334
+ def __exit__(
335
+ self,
336
+ _exc_type: Optional[type],
337
+ _exc_val: Optional[BaseException],
338
+ _exc_tb: Optional[Any],
339
+ ) -> None:
340
+ """Context manager exit - ensure connection is closed."""
341
+ self.logger.debug("Exiting the event loop.")
342
+ self.stop_reactor()
xp/utils/dependencies.py CHANGED
@@ -43,6 +43,7 @@ from xp.services.conbus.conbus_datapoint_service import (
43
43
  ConbusDatapointService,
44
44
  )
45
45
  from xp.services.conbus.conbus_discover_service import ConbusDiscoverService
46
+ from xp.services.conbus.conbus_event_raw_service import ConbusEventRawService
46
47
  from xp.services.conbus.conbus_output_service import ConbusOutputService
47
48
  from xp.services.conbus.conbus_raw_service import ConbusRawService
48
49
  from xp.services.conbus.conbus_receive_service import ConbusReceiveService
@@ -58,6 +59,7 @@ from xp.services.homekit.homekit_outlet_service import HomeKitOutletService
58
59
  from xp.services.homekit.homekit_service import HomeKitService
59
60
  from xp.services.log_file_service import LogFileService
60
61
  from xp.services.module_type_service import ModuleTypeService
62
+ from xp.services.protocol import ConbusEventProtocol
61
63
  from xp.services.protocol.protocol_factory import TelegramFactory
62
64
  from xp.services.protocol.telegram_protocol import TelegramProtocol
63
65
  from xp.services.reverse_proxy_service import ReverseProxyService
@@ -163,14 +165,30 @@ class ServiceContainer:
163
165
  )
164
166
 
165
167
  self.container.register(
166
- ConbusDiscoverService,
167
- factory=lambda: ConbusDiscoverService(
168
+ ConbusEventProtocol,
169
+ factory=lambda: ConbusEventProtocol(
168
170
  cli_config=self.container.resolve(ConbusClientConfig),
169
171
  reactor=self.container.resolve(PosixReactorBase),
170
172
  ),
171
173
  scope=punq.Scope.singleton,
172
174
  )
173
175
 
176
+ self.container.register(
177
+ ConbusDiscoverService,
178
+ factory=lambda: ConbusDiscoverService(
179
+ conbus_protocol=self.container.resolve(ConbusEventProtocol)
180
+ ),
181
+ scope=punq.Scope.singleton,
182
+ )
183
+
184
+ self.container.register(
185
+ ConbusEventRawService,
186
+ factory=lambda: ConbusEventRawService(
187
+ conbus_protocol=self.container.resolve(ConbusEventProtocol)
188
+ ),
189
+ scope=punq.Scope.singleton,
190
+ )
191
+
174
192
  self.container.register(
175
193
  ConbusBlinkService,
176
194
  factory=lambda: ConbusBlinkService(