conson-xp 1.19.0__py3-none-any.whl → 1.20.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.19.0
3
+ Version: 1.20.0
4
4
  Summary: XP Protocol Communication Tools
5
5
  Author-Email: ldvchosal <ldvchosal@github.com>
6
6
  License: MIT License
@@ -48,6 +48,7 @@ Requires-Dist: punq>=0.7.0
48
48
  Requires-Dist: twisted>=25.5.0
49
49
  Requires-Dist: bubus>=1.5.6
50
50
  Requires-Dist: psygnal>=0.15.0
51
+ Requires-Dist: textual>=1.0.0
51
52
  Description-Content-Type: text/markdown
52
53
 
53
54
  # 🔌 XP Protocol Communication Tool
@@ -398,6 +399,10 @@ xp telegram parse
398
399
  xp telegram validate
399
400
  xp telegram version
400
401
 
402
+
403
+ xp term
404
+ xp term protocol
405
+
401
406
  <!-- END CLI HELP -->
402
407
  ```
403
408
  </details>
@@ -1,11 +1,11 @@
1
- conson_xp-1.19.0.dist-info/METADATA,sha256=-VtLm8xePli914-pZt50momoSfpi6BARWkhyla_IiYA,9527
2
- conson_xp-1.19.0.dist-info/WHEEL,sha256=9P2ygRxDrTJz3gsagc0Z96ukrxjr-LFBGOgv3AuKlCA,90
3
- conson_xp-1.19.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
- conson_xp-1.19.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
- xp/__init__.py,sha256=xqDwwDvx5lt_aNSuHKANp3WXQCP4x1a9OzyP8CniQnE,181
1
+ conson_xp-1.20.0.dist-info/METADATA,sha256=vAvQzvNII8d7oxEEbo1rSLyswX3lROWxh7cERQ-y3_Y,9584
2
+ conson_xp-1.20.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
+ conson_xp-1.20.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
+ conson_xp-1.20.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
+ xp/__init__.py,sha256=T304nC2FLiJmOCyBS3xq0x0Bb4Nv_XAMMRN4xZtqUec,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=wvo9Z5viwpjvO2432E7YP5HWjLLiW1IFpyXLc5puuGY,4766
8
+ xp/cli/commands/__init__.py,sha256=noh8fdZAWq-ihJEboP8WugbIgq4LJ3jUWMRA7720xWE,4909
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
@@ -22,7 +22,7 @@ xp/cli/commands/conbus/conbus_modulenumber_commands.py,sha256=L7-6y3rDllOjQ9g6Bk
22
22
  xp/cli/commands/conbus/conbus_msactiontable_commands.py,sha256=fb9MQ4O04H0Dinpt7vSF5GtfntTZHelQ5TuUmSBbCTg,2899
23
23
  xp/cli/commands/conbus/conbus_output_commands.py,sha256=zdRVbHzVhMbZpG2x5WXtujc3wKTsoQUV4IgkVIbJbCc,5019
24
24
  xp/cli/commands/conbus/conbus_raw_commands.py,sha256=8BKUarwvHgz-sxML7n99YVsb8B1HJNExjQpRsuY_tQw,1829
25
- xp/cli/commands/conbus/conbus_receive_commands.py,sha256=WdH7fYcbjweIGxD2uxrTRD8lJzViMSVdsaHTvSDJNCQ,1757
25
+ xp/cli/commands/conbus/conbus_receive_commands.py,sha256=2lZP0a3dte3Q_Vp28xYkqLAoxnvArS9SsQdeedOHcQw,1788
26
26
  xp/cli/commands/conbus/conbus_scan_commands.py,sha256=JfXucOwOadvLEKT_fW9fwvqWKHaEODOojLjnO8JV_00,1730
27
27
  xp/cli/commands/file_commands.py,sha256=GV102X7FRZDUNKLlzvSsIGcoXAaofOzmjCp3HUpE9lw,5532
28
28
  xp/cli/commands/homekit/__init__.py,sha256=qqwY8ulxTx1S74Mzpb6EKjBLT6fWTNdf9PQ3HKuERKY,50
@@ -40,7 +40,10 @@ xp/cli/commands/telegram/telegram_discover_commands.py,sha256=0UArJinw1eWFbee5EG
40
40
  xp/cli/commands/telegram/telegram_linknumber_commands.py,sha256=7j0-E5Moqqga4NrKDch82C6glaFDFMQn5_3hMwie7BQ,2511
41
41
  xp/cli/commands/telegram/telegram_parse_commands.py,sha256=_OYOso1hS4f_ox96qlkYL2SuFnmimpAvqqdYlLzX9yo,2232
42
42
  xp/cli/commands/telegram/telegram_version_commands.py,sha256=WQyx1-B9yJ8V9WrFyBpOvULJ-jq12GoZZDDoRbM7eyw,1553
43
- xp/cli/main.py,sha256=3TY4wZoKMK8kQBgOn0WshTsag4J4ofoGoGPgg12wueM,2810
43
+ xp/cli/commands/term/__init__.py,sha256=1NNST_8YJfj5LCujQISwQflK6LyEn7mDmZpMpvI9d-o,116
44
+ xp/cli/commands/term/term.py,sha256=gjvsv2OE-F_KNWQrWi04fXQ5cGo0l8P-Ortbb5KTA-A,309
45
+ xp/cli/commands/term/term_commands.py,sha256=3qwiGlEEgMIz5AG7fq5U_9SZSWSDfrKwYggHbiv5kRk,738
46
+ xp/cli/main.py,sha256=ap5jU0DrSnrCKDKqGXcz9N-sngZodyyN-5ReWE8Fh1s,1817
44
47
  xp/cli/utils/__init__.py,sha256=gTGIj60Uai0iE7sr9_TtEpl04fD7krtTzbbigXUsUVU,46
45
48
  xp/cli/utils/click_tree.py,sha256=ilmM2IMa_c-TqUMsv2alrZXuS0BNhvVlrBlSfyN8lzM,1670
46
49
  xp/cli/utils/datapoint_type_choice.py,sha256=HcydhlqxZ7YyorEeTjFGkypF2JnYNPvOzkl1rhZ93Fc,1666
@@ -63,7 +66,7 @@ xp/models/conbus/__init__.py,sha256=VIusMWQdBtlwDgj7oSj06wQkklihTp4oWFShvP_JUgA,
63
66
  xp/models/conbus/conbus.py,sha256=mZQzKPfrdttT-qUnYUSyrEYyc_eHs8z301E5ejeiyvk,2689
64
67
  xp/models/conbus/conbus_autoreport.py,sha256=lKotDfxRBb7h2Z1d4qI3KhhLJhFDwKqLbSdG5Makm8Y,2289
65
68
  xp/models/conbus/conbus_blink.py,sha256=XEAPtA-O76ulX6Zh1oYzsWF6L4css6xJBuUTwNcDQKc,2911
66
- xp/models/conbus/conbus_client_config.py,sha256=fWPmHM-OVUzSASKq667JzP7e9_Qp9ZUyYcTaijWkVlY,1484
69
+ xp/models/conbus/conbus_client_config.py,sha256=MliIffoP0Ku1FxpNznMJPBrpdz9OVKZcs9IJ4g1Z3dE,1485
67
70
  xp/models/conbus/conbus_connection_status.py,sha256=iGbmtBaAMwV6UD7XG3H3tnB0fl2MR8rJhpjrLH2KjsE,1097
68
71
  xp/models/conbus/conbus_custom.py,sha256=8H2sPR6_LIlksuOvL7-8bPkzAJLR0rpYiiwfYYFVjEo,1965
69
72
  xp/models/conbus/conbus_datapoint.py,sha256=4ncR-vB2lRzRBAA30rYn8eguyTxsZoOKrrXtjGmPpWg,3396
@@ -72,6 +75,7 @@ xp/models/conbus/conbus_event_list.py,sha256=M8aHRHVB5VDIjqMzjO86xlERt7AMdfjIjt1
72
75
  xp/models/conbus/conbus_event_raw.py,sha256=i5gc7z-0yeunWOZ4rw3AiBt4MANezmhBQKjOOQk3oDc,1567
73
76
  xp/models/conbus/conbus_lightlevel.py,sha256=GQGhzrCBEJROosNHInXIzBy6MD2AskEIMoFEGgZ60-0,1695
74
77
  xp/models/conbus/conbus_linknumber.py,sha256=uFzKzfB06oIzZEKCb5X2JEI80JjMPFuYglsT1W1k8j4,1815
78
+ xp/models/conbus/conbus_logger_config.py,sha256=cFWjWn8tc_hPPI2kQAib_Akddar8O-3zkoj6wLBsdUo,3328
75
79
  xp/models/conbus/conbus_output.py,sha256=q7QKsD_CWT7YOk-V3otKWD1VM7qThrSLIUOunntMrMc,1953
76
80
  xp/models/conbus/conbus_raw.py,sha256=xqvYao6IE1SXum7JBgZpSuWXm9x_QZquS9N_3_r0Hjs,1460
77
81
  xp/models/conbus/conbus_receive.py,sha256=-1u1qK-texfKCNZV-GYf_9KyLtJdIrx7HuZsKzu26Ow,1322
@@ -125,7 +129,7 @@ xp/services/conbus/conbus_event_list_service.py,sha256=0xyXXNU44epN5bFkU6oiZMyhx
125
129
  xp/services/conbus/conbus_event_raw_service.py,sha256=FZFu-LNLInrTKTpiGLyootozvyIF5Si5FMrxNk2ALD0,7000
126
130
  xp/services/conbus/conbus_output_service.py,sha256=mHFOAPx2zo0TStZ3pokp6v94AQjIamcwZDeg5YH_-eo,7240
127
131
  xp/services/conbus/conbus_raw_service.py,sha256=4yZLLTIAOxpgByUTWZXw1ihGa6Xtl98ckj9T7VfprDI,4335
128
- xp/services/conbus/conbus_receive_service.py,sha256=frXrS0OyKKvYYQTWdma21Kd0BKw5aSuHn3ZXTTqOaj0,3953
132
+ xp/services/conbus/conbus_receive_service.py,sha256=38lAZ0tc2AjBfcqI7qje-ES_QHiHZ3Ayybrp1ZC8ceM,5412
129
133
  xp/services/conbus/conbus_scan_service.py,sha256=tHJ5qaxcNXxAZb2D2F1v6IrzydfxjJOYllM6Txt1eBE,5176
130
134
  xp/services/conbus/write_config_service.py,sha256=6feNdixI_Nli4MRLe15nea-7gTEXMUwZIvTqv_1OqHI,7157
131
135
  xp/services/homekit/__init__.py,sha256=xAMKmln_AmEFdOOJGKWYi96seRlKDQpKx3-hm7XbdIo,36
@@ -145,7 +149,7 @@ xp/services/homekit/homekit_service.py,sha256=0lW-hg40ETco3gDBEYkR_sX-UIYsLSKCD4
145
149
  xp/services/log_file_service.py,sha256=fvPcZQj8XOKUA-4ep5R8n0PelvwvRlTLlVxvIWM5KR4,10506
146
150
  xp/services/module_type_service.py,sha256=xWhr1EAZMykL5uNWHWdpa5T8yNruGKH43XRTOS8GwZg,7477
147
151
  xp/services/protocol/__init__.py,sha256=qRufBmqRKGzpuzZ5bxBbmwf510TT00Ke8s5HcWGnqRY,818
148
- xp/services/protocol/conbus_event_protocol.py,sha256=6ihDsWj5k08Hb3OpYd3xBZCS-yPa16FfWtFSxJknIwo,12852
152
+ xp/services/protocol/conbus_event_protocol.py,sha256=48KCTkJLDHV1ijVXHf0TraY663Nk3_dEV3lkZpvduDo,13671
149
153
  xp/services/protocol/conbus_protocol.py,sha256=JO7yLkD_ohPT0ETjnAIx4CGpZyobf4LdbuozM_43btE,10276
150
154
  xp/services/protocol/protocol_factory.py,sha256=PmjN9AtW9sxNo3voqUiNgQA-pTvX1RW4XXFlHKfFr5Q,2470
151
155
  xp/services/protocol/telegram_protocol.py,sha256=Ki5DrXsKxiaqLcdP9WWUuhUI7cPu2DfwyZkh-Gv9Lb8,9496
@@ -169,10 +173,16 @@ xp/services/telegram/telegram_link_number_service.py,sha256=1_c-_QCRPTHYn3BmMElr
169
173
  xp/services/telegram/telegram_output_service.py,sha256=UaUv_14fR8o5K2PxQBXrCzx-Hohnk-gzbev_oLw_Clc,10799
170
174
  xp/services/telegram/telegram_service.py,sha256=XrP1CPi0ckxoKBaNwLA6lo-TogWxXgmXDOsU4Xl8BlY,13237
171
175
  xp/services/telegram/telegram_version_service.py,sha256=M5HdOTsLdcwo122FP-jW6R740ktLrtKf2TiMDVz23h8,10528
176
+ xp/tui/__init__.py,sha256=Xg2DhBeI3xQJLfc7_BPWI1por-rUXemyer5OtOt9Cus,51
177
+ xp/tui/app.py,sha256=NBCdFCgftckey5TgxDFn-SeOLSFggapshIwKDcLM5QY,2227
178
+ xp/tui/protocol.tcss,sha256=njMgFgz4oD4Qjw3dyoX1SfCuvlfGg6QrrkZ2COFZ0yM,768
179
+ xp/tui/widgets/__init__.py,sha256=Ewiza9u6k5K50zZRIMD7jjOHY1IvGhoX1ViwlqhdGms,27
180
+ xp/tui/widgets/protocol_log.py,sha256=0twJHiOTNcOOw3eQtYkjb1x37adecypPPPT1fuWb2q4,11541
172
181
  xp/utils/__init__.py,sha256=_avMF_UOkfR3tNeDIPqQ5odmbq5raKkaq1rZ9Cn1CJs,332
173
182
  xp/utils/checksum.py,sha256=HDpiQxmdIedbCbZ4o_Box0i_Zig417BtCV_46ZyhiTk,1711
174
- xp/utils/dependencies.py,sha256=PYe-RvmfGBRXWnLKX62nXGMDFN7PQW3deoGCkIVEG4s,21274
183
+ xp/utils/dependencies.py,sha256=ECS6p0eXzocM5INLwJeckHXn_Dim18uOjXTJ29qQvkQ,22001
175
184
  xp/utils/event_helper.py,sha256=W-A_xmoXlpWZBbJH6qdaN50o3-XrwFsDgvAGMJDiAgo,1001
185
+ xp/utils/logging.py,sha256=5ol2JrnFtjs9QtBYW4KeKYzcFzCxbCn8BsIq3aIF4H4,3395
176
186
  xp/utils/serialization.py,sha256=RWHHk86feaB4ZP7rjE4qOWK0900yg2joUBDkP76gfOY,4618
177
187
  xp/utils/time_utils.py,sha256=dEyViDlAG9GWU-J3D_YVa-sGma6yiyyMTgN4h2x3PY4,3781
178
- conson_xp-1.19.0.dist-info/RECORD,,
188
+ conson_xp-1.20.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: pdm-backend (2.4.5)
2
+ Generator: pdm-backend (2.4.6)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
xp/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  conson-xp package.
4
4
  """
5
5
 
6
- __version__ = "1.19.0"
6
+ __version__ = "1.20.0"
7
7
  __manufacturer__ = "salchichon"
8
8
  __model__ = "xp.cli"
9
9
  __serial__ = "2025.09.23.000"
@@ -86,6 +86,8 @@ from xp.cli.commands.telegram.telegram_parse_commands import (
86
86
  validate_telegram,
87
87
  )
88
88
  from xp.cli.commands.telegram.telegram_version_commands import generate_version_request
89
+ from xp.cli.commands.term.term import term
90
+ from xp.cli.commands.term.term_commands import protocol_monitor
89
91
 
90
92
  __all__ = [
91
93
  # Main command groups (conbus excluded to avoid module shadowing)
@@ -109,7 +111,9 @@ __all__ = [
109
111
  "checksum",
110
112
  "homekit",
111
113
  "homekit_start",
114
+ "term",
112
115
  # Individual command functions
116
+ "protocol_monitor",
113
117
  "conbus_download_msactiontable",
114
118
  "conbus_download_actiontable",
115
119
  "send_blink_on_telegram",
@@ -56,4 +56,5 @@ def receive_telegrams(ctx: Context, timeout: float) -> None:
56
56
  ctx.obj.get("container").get_container().resolve(ConbusReceiveService)
57
57
  )
58
58
  with service:
59
- service.start(progress, on_finish, timeout)
59
+ service.init(progress, on_finish, timeout)
60
+ service.start_reactor()
@@ -0,0 +1,5 @@
1
+ """Term CLI commands package for TUI interfaces."""
2
+
3
+ from xp.cli.commands.term.term import term
4
+
5
+ __all__ = ["term"]
@@ -0,0 +1,12 @@
1
+ """Term CLI group definition for TUI commands."""
2
+
3
+ import click
4
+ from click_help_colors import HelpColorsGroup
5
+
6
+
7
+ @click.group(
8
+ cls=HelpColorsGroup, help_headers_color="yellow", help_options_color="green"
9
+ )
10
+ def term() -> None:
11
+ """Terminal UI commands for interactive monitoring and control."""
12
+ pass
@@ -0,0 +1,31 @@
1
+ """Term protocol CLI command for TUI monitoring."""
2
+
3
+ import click
4
+ from click import Context
5
+
6
+ from xp.cli.commands.term.term import term
7
+
8
+
9
+ @term.command("protocol")
10
+ @click.pass_context
11
+ def protocol_monitor(ctx: Context) -> None:
12
+ r"""Start TUI for real-time protocol monitoring.
13
+
14
+ Displays live RX/TX telegram stream from Conbus server
15
+ in an interactive terminal interface.
16
+
17
+ Args:
18
+ ctx: Click context object.
19
+
20
+ Examples:
21
+ \b
22
+ xp term protocol
23
+ """
24
+ from xp.tui.app import ProtocolMonitorApp
25
+
26
+ # Resolve ServiceContainer from context
27
+ container = ctx.obj.get("container").get_container()
28
+
29
+ # Initialize and run Textual app
30
+ app = ProtocolMonitorApp(container=container)
31
+ app.run()
xp/cli/main.py CHANGED
@@ -1,7 +1,5 @@
1
1
  """XP CLI tool entry point with modular command structure."""
2
2
 
3
- import logging
4
-
5
3
  import click
6
4
  from click_help_colors import HelpColorsGroup
7
5
 
@@ -16,8 +14,10 @@ from xp.cli.commands.server.server_commands import server
16
14
 
17
15
  # Import command groups from modular structure
18
16
  from xp.cli.commands.telegram.telegram_parse_commands import telegram
17
+ from xp.cli.commands.term.term import term
19
18
  from xp.cli.utils.click_tree import add_tree_command
20
19
  from xp.utils.dependencies import ServiceContainer
20
+ from xp.utils.logging import LoggerService
21
21
 
22
22
 
23
23
  @click.group(
@@ -31,44 +31,15 @@ def cli(ctx: click.Context) -> None:
31
31
  Args:
32
32
  ctx: Click context object for passing state between commands.
33
33
  """
34
- # Configure logging with thread information
35
- log_format = "%(asctime)s - [%(threadName)s-%(thread)d] - %(levelname)s - %(name)s - %(message)s"
36
- date_format = "%H:%M:%S"
37
-
38
- # Force format on root logger and all handlers
39
- formatter = logging.Formatter(log_format, datefmt=date_format)
40
- root_logger = logging.getLogger()
41
- root_logger.setLevel(logging.DEBUG)
42
-
43
- # Update all existing handlers or create new one
44
- if root_logger.handlers:
45
- for handler in root_logger.handlers:
46
- handler.setFormatter(formatter)
47
- else:
48
- handler = logging.StreamHandler()
49
- handler.setFormatter(formatter)
50
- root_logger.addHandler(handler)
51
-
52
- # Suppress pyhap.hap_protocol logs
53
-
54
- # bubus
55
- logging.getLogger("bubus").setLevel(logging.WARNING)
56
-
57
- # xp
58
- logging.getLogger("xp").setLevel(logging.DEBUG)
59
- logging.getLogger("xp.services.homekit").setLevel(logging.DEBUG)
60
-
61
- # pyhap
62
- logging.getLogger("pyhap").setLevel(logging.WARNING)
63
- logging.getLogger("pyhap.hap_handler").setLevel(logging.WARNING)
64
- logging.getLogger("pyhap.hap_protocol").setLevel(logging.WARNING)
65
- # logging.getLogger('pyhap.accessory_driver').setLevel(logging.WARNING)
34
+ container = ServiceContainer()
35
+ logger_config = container.get_container().resolve(LoggerService)
36
+ logger_config.setup()
66
37
 
67
38
  # Initialize the service container and store it in the context
68
39
  ctx.ensure_object(dict)
69
40
  # Only create a new container if one wasn't provided (e.g., for testing)
70
41
  if "container" not in ctx.obj:
71
- ctx.obj["container"] = ServiceContainer()
42
+ ctx.obj["container"] = container
72
43
 
73
44
 
74
45
  # Register all command groups
@@ -79,6 +50,7 @@ cli.add_command(module)
79
50
  cli.add_command(file)
80
51
  cli.add_command(server)
81
52
  cli.add_command(reverse_proxy)
53
+ cli.add_command(term)
82
54
 
83
55
  # Add the tree command
84
56
  add_tree_command(cli)
@@ -49,6 +49,7 @@ class ConbusClientConfig(BaseModel):
49
49
  except FileNotFoundError:
50
50
  logger.error(f"File {file_path} does not exist, loading default")
51
51
  return cls()
52
+
52
53
  except yaml.YAMLError:
53
54
  logger.error(f"File {file_path} is not valid")
54
55
  # Return default config if YAML parsing fails
@@ -0,0 +1,107 @@
1
+ """Logger configuration models for XP application."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Dict, Union
6
+
7
+ import yaml
8
+ from pydantic import BaseModel, Field, field_validator
9
+
10
+
11
+ class LoggingConfig(BaseModel):
12
+ """Logging configuration.
13
+
14
+ Attributes:
15
+ path: log folder.
16
+ default_level: DEBUG, WARNING, INFO, ERROR, CRITICAL.
17
+ levels: Per-module log level overrides.
18
+ max_bytes: Maximum size in bytes before rotating (default: 1MB).
19
+ backup_count: Number of backup files to keep (default: 365).
20
+ log_format: Log message format string.
21
+ date_format: Date format string for timestamps.
22
+ """
23
+
24
+ path: str = "log"
25
+ default_level: str = "DEBUG"
26
+ levels: Dict[str, int] = {
27
+ "xp": logging.DEBUG,
28
+ "xp.services.homekit": logging.WARNING,
29
+ "xp.services.server": logging.WARNING,
30
+ }
31
+ max_bytes: int = 1024 * 1024 # 1MB
32
+ backup_count: int = 365
33
+ log_format: str = (
34
+ "%(asctime)s - [%(threadName)s-%(thread)d] - %(levelname)s - %(name)s - %(message)s"
35
+ )
36
+ date_format: str = "%H:%M:%S"
37
+
38
+ @field_validator("levels", mode="before")
39
+ @classmethod
40
+ def convert_level_names(cls, v: Dict[str, Union[str, int]]) -> Dict[str, int]:
41
+ """Convert string level names to numeric values.
42
+
43
+ Args:
44
+ v: Dictionary with string or int log levels.
45
+
46
+ Returns:
47
+ Dictionary with numeric log levels.
48
+
49
+ Raises:
50
+ ValueError: If an invalid log level name is provided.
51
+ """
52
+ level_map = {
53
+ "DEBUG": logging.DEBUG,
54
+ "INFO": logging.INFO,
55
+ "WARNING": logging.WARNING,
56
+ "ERROR": logging.ERROR,
57
+ "CRITICAL": logging.CRITICAL,
58
+ }
59
+
60
+ result = {}
61
+ for module, level in v.items():
62
+ if isinstance(level, str):
63
+ level_upper = level.upper()
64
+ if level_upper not in level_map:
65
+ raise ValueError(
66
+ f"Invalid log level '{level}' for module '{module}'. "
67
+ f"Must be one of: {', '.join(level_map.keys())}"
68
+ )
69
+ result[module] = level_map[level_upper]
70
+ else:
71
+ result[module] = level
72
+ return result
73
+
74
+
75
+ class ConbusLoggerConfig(BaseModel):
76
+ """Logging configuration.
77
+
78
+ Attributes:
79
+ log: LoggingConfig instance for logging settings.
80
+ """
81
+
82
+ log: LoggingConfig = Field(default_factory=LoggingConfig)
83
+
84
+ @classmethod
85
+ def from_yaml(cls, file_path: str) -> "ConbusLoggerConfig":
86
+ """Load configuration from YAML file.
87
+
88
+ Args:
89
+ file_path: Path to the YAML configuration file.
90
+
91
+ Returns:
92
+ ConbusClientConfig instance loaded from file or default config.
93
+ """
94
+ logger = logging.getLogger(__name__)
95
+ try:
96
+ with Path(file_path).open("r") as file:
97
+ data = yaml.safe_load(file)
98
+ return cls(**data)
99
+
100
+ except FileNotFoundError:
101
+ logger.error(f"File {file_path} does not exist, loading default")
102
+ return cls()
103
+
104
+ except yaml.YAMLError:
105
+ logger.error(f"File {file_path} is not valid")
106
+ # Return default config if YAML parsing fails
107
+ return cls()
@@ -1,51 +1,55 @@
1
1
  """Conbus Receive Service for receiving telegrams from Conbus servers.
2
2
 
3
- This service uses ConbusProtocol to provide receive-only functionality,
3
+ This service uses ConbusEventProtocol to provide receive-only functionality,
4
4
  allowing clients to receive waiting event telegrams using empty telegram sends.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import logging
8
- from typing import Callable, Optional
9
+ from typing import Any, Callable, Optional
9
10
 
10
- from twisted.internet.posixbase import PosixReactorBase
11
-
12
- from xp.models import ConbusClientConfig
13
11
  from xp.models.conbus.conbus_receive import ConbusReceiveResponse
14
12
  from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
15
- from xp.services.protocol import ConbusProtocol
13
+ from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
16
14
 
17
15
 
18
- class ConbusReceiveService(ConbusProtocol):
16
+ class ConbusReceiveService:
19
17
  """
20
18
  Service for receiving telegrams from Conbus servers.
21
19
 
22
- Uses ConbusProtocol to provide receive-only functionality
20
+ Uses ConbusEventProtocol to provide receive-only functionality
23
21
  for collecting waiting event telegrams from the server.
22
+
23
+ Attributes:
24
+ conbus_protocol: Protocol instance for Conbus communication.
24
25
  """
25
26
 
26
- def __init__(
27
- self,
28
- cli_config: ConbusClientConfig,
29
- reactor: PosixReactorBase,
30
- ) -> None:
27
+ conbus_protocol: ConbusEventProtocol
28
+
29
+ def __init__(self, conbus_protocol: ConbusEventProtocol) -> None:
31
30
  """Initialize the Conbus receive service.
32
31
 
33
32
  Args:
34
- cli_config: Conbus client configuration.
35
- reactor: Twisted reactor instance.
33
+ conbus_protocol: ConbusEventProtocol instance.
36
34
  """
37
- super().__init__(cli_config, reactor)
38
35
  self.progress_callback: Optional[Callable[[str], None]] = None
39
36
  self.finish_callback: Optional[Callable[[ConbusReceiveResponse], None]] = None
40
37
  self.receive_response: ConbusReceiveResponse = ConbusReceiveResponse(
41
38
  success=True
42
39
  )
43
40
 
41
+ self.conbus_protocol: ConbusEventProtocol = conbus_protocol
42
+ self.conbus_protocol.on_connection_made.connect(self.connection_made)
43
+ self.conbus_protocol.on_telegram_sent.connect(self.telegram_sent)
44
+ self.conbus_protocol.on_telegram_received.connect(self.telegram_received)
45
+ self.conbus_protocol.on_timeout.connect(self.timeout)
46
+ self.conbus_protocol.on_failed.connect(self.failed)
47
+
44
48
  # Set up logging
45
49
  self.logger = logging.getLogger(__name__)
46
50
 
47
- def connection_established(self) -> None:
48
- """Handle connection established event."""
51
+ def connection_made(self) -> None:
52
+ """Handle connection made event."""
49
53
  self.logger.debug("Connection established, waiting for telegrams.")
50
54
 
51
55
  def telegram_sent(self, telegram_sent: str) -> None:
@@ -70,17 +74,13 @@ class ConbusReceiveService(ConbusProtocol):
70
74
  self.receive_response.received_telegrams = []
71
75
  self.receive_response.received_telegrams.append(telegram_received.frame)
72
76
 
73
- def timeout(self) -> bool:
74
- """Handle timeout event to stop receiving.
75
-
76
- Returns:
77
- False to stop the reactor.
78
- """
79
- self.logger.info("Receive stopped after: %ss", self.timeout_seconds)
77
+ def timeout(self) -> None:
78
+ """Handle timeout event to stop receiving."""
79
+ timeout = self.conbus_protocol.timeout_seconds
80
+ self.logger.info("Receive stopped after: %ss", timeout)
80
81
  self.receive_response.success = True
81
82
  if self.finish_callback:
82
83
  self.finish_callback(self.receive_response)
83
- return False
84
84
 
85
85
  def failed(self, message: str) -> None:
86
86
  """Handle failed connection event.
@@ -94,22 +94,50 @@ class ConbusReceiveService(ConbusProtocol):
94
94
  if self.finish_callback:
95
95
  self.finish_callback(self.receive_response)
96
96
 
97
- def start(
97
+ def init(
98
98
  self,
99
99
  progress_callback: Callable[[str], None],
100
100
  finish_callback: Callable[[ConbusReceiveResponse], None],
101
101
  timeout_seconds: Optional[float] = None,
102
+ event_loop: Optional[asyncio.AbstractEventLoop] = None,
102
103
  ) -> None:
103
- """Run reactor in dedicated thread with its own event loop.
104
+ """Setup callbacks and timeout for receiving telegrams.
104
105
 
105
106
  Args:
106
107
  progress_callback: Callback for each received telegram.
107
108
  finish_callback: Callback when receiving completes.
108
109
  timeout_seconds: Optional timeout in seconds.
110
+ event_loop: Optional event loop to use for async operations.
109
111
  """
110
112
  self.logger.info("Starting receive")
111
113
  if timeout_seconds:
112
- self.timeout_seconds = timeout_seconds
114
+ self.conbus_protocol.timeout_seconds = timeout_seconds
113
115
  self.progress_callback = progress_callback
114
116
  self.finish_callback = finish_callback
115
- self.start_reactor()
117
+
118
+ if event_loop:
119
+ self.conbus_protocol.set_event_loop(event_loop)
120
+
121
+ def start_reactor(self) -> None:
122
+ """Start the reactor."""
123
+ self.conbus_protocol.start_reactor()
124
+
125
+ def __enter__(self) -> "ConbusReceiveService":
126
+ """Enter context manager.
127
+
128
+ Returns:
129
+ Self for context manager protocol.
130
+ """
131
+ # Reset state for singleton reuse
132
+ self.receive_response = ConbusReceiveResponse(success=True)
133
+ return self
134
+
135
+ def __exit__(
136
+ self, _exc_type: Optional[type], _exc_val: Optional[Exception], _exc_tb: Any
137
+ ) -> None:
138
+ """Exit context manager and disconnect signals."""
139
+ self.conbus_protocol.on_connection_made.disconnect(self.connection_made)
140
+ self.conbus_protocol.on_telegram_sent.disconnect(self.telegram_sent)
141
+ self.conbus_protocol.on_telegram_received.disconnect(self.telegram_received)
142
+ self.conbus_protocol.on_timeout.disconnect(self.timeout)
143
+ self.conbus_protocol.on_failed.disconnect(self.failed)
@@ -3,6 +3,7 @@
3
3
  This module implements the Twisted protocol for Conbus communication.
4
4
  """
5
5
 
6
+ import asyncio
6
7
  import logging
7
8
  from queue import SimpleQueue
8
9
  from random import randint
@@ -302,14 +303,21 @@ class ConbusEventProtocol(protocol.Protocol, protocol.ClientFactory):
302
303
  self.logger.info("Stopping reactor")
303
304
  self._reactor.stop()
304
305
 
305
- def start_reactor(self) -> None:
306
- """Start the reactor if it's running."""
307
- # Connect to TCP server
306
+ def connect(self) -> None:
307
+ """Connect to TCP server."""
308
308
  self.logger.info(
309
309
  f"Connecting to TCP server {self.cli_config.ip}:{self.cli_config.port}"
310
310
  )
311
311
  self._reactor.connectTCP(self.cli_config.ip, self.cli_config.port, self)
312
312
 
313
+ def disconnect(self) -> None:
314
+ """Disconnect from TCP server."""
315
+ self.logger.info("Disconnecting TCP server")
316
+ self._reactor.disconnectAll()
317
+
318
+ def start_reactor(self) -> None:
319
+ """Start the reactor if it's running."""
320
+ self.connect()
313
321
  # Run the reactor (which now uses asyncio underneath)
314
322
  self.logger.info("Starting reactor event loop.")
315
323
  self._reactor.run()
@@ -340,6 +348,23 @@ class ConbusEventProtocol(protocol.Protocol, protocol.ClientFactory):
340
348
  later = randint(10, 80) / 100
341
349
  self.call_later(later, self.process_telegram_queue)
342
350
 
351
+ def set_event_loop(self, event_loop: asyncio.AbstractEventLoop) -> None:
352
+ """Change the event loop.
353
+
354
+ Args:
355
+ event_loop: the event loop instance.
356
+ """
357
+ reactor = self._reactor
358
+ if hasattr(reactor, "_asyncioEventloop"):
359
+ reactor._asyncioEventloop = event_loop
360
+
361
+ # Set reactor to running state
362
+ if not reactor.running:
363
+ reactor.running = True
364
+ if hasattr(reactor, "startRunning"):
365
+ reactor.startRunning()
366
+ self.logger.info("Set reactor to running state")
367
+
343
368
  def __enter__(self) -> "ConbusEventProtocol":
344
369
  """Enter context manager.
345
370
 
xp/tui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """TUI (Terminal User Interface) module for XP."""
xp/tui/app.py ADDED
@@ -0,0 +1,72 @@
1
+ """Protocol Monitor TUI Application."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Optional
5
+
6
+ from textual.app import App, ComposeResult
7
+ from textual.widgets import Footer, Header
8
+
9
+ from xp.tui.widgets.protocol_log import ProtocolLogWidget
10
+
11
+
12
+ class ProtocolMonitorApp(App[None]):
13
+ """Textual app for real-time protocol monitoring.
14
+
15
+ Displays live RX/TX telegram stream from Conbus server in an interactive
16
+ terminal interface with keyboard shortcuts for control.
17
+
18
+ Attributes:
19
+ container: ServiceContainer for dependency injection.
20
+ CSS_PATH: Path to CSS stylesheet file.
21
+ BINDINGS: Keyboard bindings for app actions.
22
+ TITLE: Application title displayed in header.
23
+ """
24
+
25
+ CSS_PATH = Path(__file__).parent / "protocol.tcss"
26
+ TITLE = "Protocol Monitor"
27
+
28
+ BINDINGS = [
29
+ ("q", "quit", "Quit"),
30
+ ("c", "connect", "Connect"),
31
+ ("d", "disconnect", "Disconnect"),
32
+ ("1", "discover", "Discover"),
33
+ ]
34
+
35
+ def __init__(self, container: Any) -> None:
36
+ """Initialize the Protocol Monitor app.
37
+
38
+ Args:
39
+ container: ServiceContainer for resolving services.
40
+ """
41
+ super().__init__()
42
+ self.container = container
43
+ self.protocol_widget: Optional[ProtocolLogWidget] = None
44
+
45
+ def compose(self) -> ComposeResult:
46
+ """Compose the app layout with widgets.
47
+
48
+ Yields:
49
+ Header, ProtocolLogWidget, and Footer widgets.
50
+ """
51
+ yield Header()
52
+ self.protocol_widget = ProtocolLogWidget(container=self.container)
53
+ yield self.protocol_widget
54
+ yield Footer()
55
+
56
+ def action_discover(self) -> None:
57
+ """Send discover telegram on 'D' key press.
58
+
59
+ Sends predefined discover telegram <S0000000000F01D00FA> to the bus.
60
+ """
61
+ if self.protocol_widget:
62
+ self.protocol_widget.send_discover()
63
+
64
+ def action_connect(self) -> None:
65
+ """Connect protocol on 'c' key press."""
66
+ if self.protocol_widget:
67
+ self.protocol_widget.connect()
68
+
69
+ def action_disconnect(self) -> None:
70
+ """Disconnect protocol on 'd' key press."""
71
+ if self.protocol_widget:
72
+ self.protocol_widget.disconnect()
xp/tui/protocol.tcss ADDED
@@ -0,0 +1,50 @@
1
+ /* Protocol Monitor TUI Styling */
2
+
3
+ /* App-level styling */
4
+ Screen {
5
+ background: $background;
6
+ }
7
+
8
+ /* Protocol Log Widget */
9
+ ProtocolLogWidget {
10
+ border: solid $primary;
11
+ height: 1fr;
12
+ background: $surface;
13
+ }
14
+
15
+ ProtocolLogWidget > .connection-status {
16
+ color: $text;
17
+ text-align: center;
18
+ padding: 1;
19
+ }
20
+
21
+ ProtocolLogWidget > .connection-status.connecting {
22
+ color: $warning;
23
+ }
24
+
25
+ ProtocolLogWidget > .connection-status.connected {
26
+ color: $success;
27
+ }
28
+
29
+ ProtocolLogWidget > .connection-status.failed {
30
+ color: $error;
31
+ }
32
+
33
+ /* Message display styling */
34
+ .message-tx {
35
+ color: $success;
36
+ }
37
+
38
+ .message-rx {
39
+ color: $success;
40
+ }
41
+
42
+ .message-frame {
43
+ color: $text-muted;
44
+ }
45
+
46
+ /* Footer styling */
47
+ Footer {
48
+ background: $panel;
49
+ color: $text;
50
+ }
@@ -0,0 +1 @@
1
+ """TUI widgets package."""
@@ -0,0 +1,312 @@
1
+ """Protocol Log Widget for displaying telegram stream."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from enum import Enum
6
+ from typing import Any, Optional
7
+
8
+ from textual.reactive import reactive
9
+ from textual.widget import Widget
10
+ from textual.widgets import RichLog
11
+
12
+ from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
13
+ from xp.services.conbus.conbus_receive_service import ConbusReceiveService
14
+ from xp.services.protocol import ConbusEventProtocol
15
+
16
+
17
+ class ConnectionState(str, Enum):
18
+ """Connection state enumeration.
19
+
20
+ Attributes:
21
+ DISCONNECTED: Not connected to server.
22
+ CONNECTING: Connection in progress.
23
+ CONNECTED: Successfully connected.
24
+ FAILED: Connection failed.
25
+ """
26
+
27
+ DISCONNECTED = "DISCONNECTED"
28
+ CONNECTING = "CONNECTING"
29
+ CONNECTED = "CONNECTED"
30
+ FAILED = "FAILED"
31
+
32
+
33
+ class ProtocolLogWidget(Widget):
34
+ """Widget for displaying protocol telegram stream.
35
+
36
+ Connects to Conbus server via ConbusReceiveService and displays
37
+ live RX/TX telegram stream with color-coded direction markers.
38
+
39
+ Attributes:
40
+ container: ServiceContainer for dependency injection.
41
+ connection_state: Current connection state (reactive).
42
+ protocol: Reference to ConbusEventProtocol (prevents duplicate connections).
43
+ service: ConbusReceiveService instance.
44
+ logger: Logger instance for this widget.
45
+ log_widget: RichLog widget for displaying messages.
46
+ """
47
+
48
+ connection_state = reactive(ConnectionState.DISCONNECTED)
49
+
50
+ def __init__(self, container: Any) -> None:
51
+ """Initialize the Protocol Log widget.
52
+
53
+ Args:
54
+ container: ServiceContainer for resolving services.
55
+ """
56
+ super().__init__()
57
+ self.container = container
58
+ self.protocol: Optional[ConbusEventProtocol] = None
59
+ self.service: Optional[ConbusReceiveService] = None
60
+ self.logger = logging.getLogger(__name__)
61
+ self.log_widget: Optional[RichLog] = None
62
+
63
+ def compose(self) -> Any:
64
+ """Compose the widget layout.
65
+
66
+ Yields:
67
+ RichLog widget for message display.
68
+ """
69
+ self.log_widget = RichLog(highlight=True, markup=True)
70
+ yield self.log_widget
71
+
72
+ async def on_mount(self) -> None:
73
+ """Initialize connection when widget mounts.
74
+
75
+ Delays connection by 0.5s to let UI render first.
76
+ Resolves ConbusReceiveService and connects signals.
77
+ """
78
+ # Resolve service from container (singleton)
79
+ self.service = self.container.resolve(ConbusReceiveService)
80
+ self.protocol = self.service.conbus_protocol
81
+
82
+ # Connect psygnal signals
83
+ self.protocol.on_connection_made.connect(self._on_connection_made)
84
+ self.protocol.on_telegram_received.connect(self._on_telegram_received)
85
+ self.protocol.on_telegram_sent.connect(self._on_telegram_sent)
86
+ self.protocol.on_timeout.connect(self._on_timeout)
87
+ self.protocol.on_failed.connect(self._on_failed)
88
+
89
+ # Delay connection to let UI render
90
+ await asyncio.sleep(0.5)
91
+ await self._start_connection_async()
92
+
93
+ async def _start_connection_async(self) -> None:
94
+ """Start TCP connection to Conbus server (async).
95
+
96
+ Guards against duplicate connections and sets up protocol signals.
97
+ Integrates Twisted reactor with Textual's asyncio loop cleanly.
98
+ """
99
+ # Guard against duplicate connections (race condition)
100
+ if self.service is None:
101
+ self.logger.error("Service not initialized")
102
+ return
103
+
104
+ if self.protocol is None:
105
+ self.logger.error("Protocol not initialized")
106
+ return
107
+
108
+ try:
109
+ # Set state to connecting
110
+ self.connection_state = ConnectionState.CONNECTING
111
+ if self.log_widget:
112
+ self.log_widget.write("[yellow]Connecting to Conbus server...[/yellow]")
113
+
114
+ # Store protocol reference
115
+ self.logger.info(f"Protocol object: {self.protocol}")
116
+ self.logger.info(f"Reactor object: {self.protocol._reactor}")
117
+ self.logger.info(f"Reactor running: {self.protocol._reactor.running}")
118
+
119
+ # Setup service callbacks
120
+ def progress_callback(telegram: str) -> None:
121
+ """Handle progress updates for telegram reception.
122
+
123
+ Args:
124
+ telegram: Received telegram string.
125
+ """
126
+ pass
127
+
128
+ def finish_callback(response: Any) -> None:
129
+ """Handle completion of telegram reception.
130
+
131
+ Args:
132
+ response: Response object from telegram reception.
133
+ """
134
+ pass
135
+
136
+ # Get the currently running asyncio event loop (Textual's loop)
137
+ event_loop = asyncio.get_running_loop()
138
+ self.logger.info(f"Current running loop: {event_loop}")
139
+ self.logger.info(f"Loop is running: {event_loop.is_running()}")
140
+
141
+ self.service.init(
142
+ progress_callback=progress_callback,
143
+ finish_callback=finish_callback,
144
+ timeout_seconds=None, # Continuous monitoring
145
+ event_loop=event_loop,
146
+ )
147
+
148
+ reactor = self.service.conbus_protocol._reactor
149
+ # Schedule the connection on the running asyncio loop
150
+ # This ensures connectTCP is called in the context of the running loop
151
+
152
+ def do_connect() -> None:
153
+ """Execute TCP connection in event loop context."""
154
+ self.logger.info("Executing connectTCP in event loop callback")
155
+ if self.protocol is not None:
156
+ reactor.connectTCP(
157
+ self.protocol.cli_config.ip,
158
+ self.protocol.cli_config.port,
159
+ self.protocol,
160
+ )
161
+
162
+ event_loop.call_soon(do_connect)
163
+ self.logger.info("Scheduled connectTCP on running loop")
164
+
165
+ if self.log_widget:
166
+ self.log_widget.write(
167
+ f"[dim]→ {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}[/dim]"
168
+ )
169
+
170
+ # Wait for connection to establish
171
+ await asyncio.sleep(1.0)
172
+ self.logger.info(f"After 1s - transport: {self.protocol.transport}")
173
+
174
+ except Exception as e:
175
+ self.logger.error(f"Connection failed: {e}")
176
+ self.connection_state = ConnectionState.FAILED
177
+ if self.log_widget:
178
+ self.log_widget.write(f"[red]Connection error: {e}[/red]")
179
+ # Exit app after brief delay
180
+ self.set_timer(2.0, self.app.exit)
181
+
182
+ def _start_connection(self) -> None:
183
+ """Start connection (sync wrapper for async method)."""
184
+ # Use run_worker to run async method from sync context
185
+ self.run_worker(self._start_connection_async(), exclusive=True)
186
+
187
+ def _on_connection_made(self) -> None:
188
+ """Handle connection established signal.
189
+
190
+ Sets state to CONNECTED and displays success message.
191
+ """
192
+ self.connection_state = ConnectionState.CONNECTED
193
+ if self.log_widget:
194
+ self.log_widget.write("[green]Connected to Conbus server[/green]")
195
+ self.log_widget.write("[dim]---[/dim]")
196
+
197
+ def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
198
+ """Handle telegram received signal.
199
+
200
+ Args:
201
+ event: Telegram received event with frame data.
202
+ """
203
+ if self.log_widget:
204
+ # Display [RX] in green, frame in gray
205
+ self.log_widget.write(f"[green]\\[RX][/green] [dim]{event.frame}[/dim]")
206
+
207
+ def _on_telegram_sent(self, telegram: str) -> None:
208
+ """Handle telegram sent signal.
209
+
210
+ Args:
211
+ telegram: Sent telegram string.
212
+ """
213
+ if self.log_widget:
214
+ # Display [TX] in green, frame in gray
215
+ self.log_widget.write(f"[green]\\[TX][/green] [dim]{telegram}[/dim]")
216
+
217
+ def _on_timeout(self) -> None:
218
+ """Handle timeout signal.
219
+
220
+ Logs timeout but continues monitoring (no action needed).
221
+ """
222
+ self.logger.debug("Timeout occurred (continuous monitoring)")
223
+
224
+ def _on_failed(self, error: str) -> None:
225
+ """Handle connection failed signal.
226
+
227
+ Args:
228
+ error: Error message describing the failure.
229
+ """
230
+ self.connection_state = ConnectionState.FAILED
231
+ self.logger.error(f"Connection failed: {error}")
232
+
233
+ if self.log_widget:
234
+ self.log_widget.write(f"[red]Connection failed: {error}[/red]")
235
+
236
+ # Exit app after brief delay to show error
237
+ self.set_timer(2.0, self.app.exit)
238
+
239
+ def connect(self) -> None:
240
+ """Connect to Conbus server."""
241
+ self._start_connection()
242
+
243
+ def disconnect(self) -> None:
244
+ """Disconnect from Conbus server."""
245
+ if self.protocol:
246
+ self.protocol.disconnect()
247
+
248
+ def send_discover(self) -> None:
249
+ """Send discover telegram.
250
+
251
+ Sends predefined discover telegram <S0000000000F01D00FA> to the bus.
252
+ Called when user presses 'd' key.
253
+ """
254
+ if self.protocol is None:
255
+ self.logger.warning("Cannot send discover: not connected")
256
+ if self.log_widget:
257
+ self.log_widget.write(
258
+ "[yellow]Not connected, cannot send discover[/yellow]"
259
+ )
260
+ return
261
+
262
+ try:
263
+ # Send discover telegram
264
+ # Note: The telegram includes framing <>, but protocol may add it
265
+ # Check if protocol expects with or without brackets
266
+ from xp.models.telegram.system_function import SystemFunction
267
+ from xp.models.telegram.telegram_type import TelegramType
268
+
269
+ # Send discover: S 0000000000 F01 D00
270
+ self.protocol.send_telegram(
271
+ telegram_type=TelegramType.SYSTEM,
272
+ serial_number="0000000000",
273
+ system_function=SystemFunction.DISCOVERY,
274
+ data_value="00",
275
+ )
276
+
277
+ if self.log_widget:
278
+ self.log_widget.write("[yellow]Discover telegram sent[/yellow]")
279
+
280
+ except Exception as e:
281
+ self.logger.error(f"Failed to send discover: {e}")
282
+ if self.log_widget:
283
+ self.log_widget.write(f"[red]Failed to send discover: {e}[/red]")
284
+
285
+ def on_unmount(self) -> None:
286
+ """Clean up when widget unmounts.
287
+
288
+ Disconnects signals and closes transport connection.
289
+ """
290
+ if self.protocol is not None:
291
+ try:
292
+ # Disconnect all signals
293
+ self.protocol.on_connection_made.disconnect(self._on_connection_made)
294
+ self.protocol.on_telegram_received.disconnect(
295
+ self._on_telegram_received
296
+ )
297
+ self.protocol.on_telegram_sent.disconnect(self._on_telegram_sent)
298
+ self.protocol.on_timeout.disconnect(self._on_timeout)
299
+ self.protocol.on_failed.disconnect(self._on_failed)
300
+
301
+ # Close transport if connected
302
+ if self.protocol.transport:
303
+ self.protocol.disconnect()
304
+
305
+ # Reset protocol reference
306
+ self.protocol = None
307
+
308
+ # Set state to disconnected
309
+ self.connection_state = ConnectionState.DISCONNECTED
310
+
311
+ except Exception as e:
312
+ self.logger.error(f"Error during cleanup: {e}")
xp/utils/dependencies.py CHANGED
@@ -7,6 +7,7 @@ from twisted.internet.interfaces import IConnector
7
7
  from twisted.internet.posixbase import PosixReactorBase
8
8
 
9
9
  from xp.models import ConbusClientConfig
10
+ from xp.models.conbus.conbus_logger_config import ConbusLoggerConfig
10
11
  from xp.models.homekit.homekit_config import HomekitConfig
11
12
  from xp.models.homekit.homekit_conson_config import ConsonModuleListConfig
12
13
  from xp.services.actiontable.actiontable_serializer import ActionTableSerializer
@@ -72,6 +73,7 @@ from xp.services.telegram.telegram_discover_service import TelegramDiscoverServi
72
73
  from xp.services.telegram.telegram_link_number_service import LinkNumberService
73
74
  from xp.services.telegram.telegram_output_service import TelegramOutputService
74
75
  from xp.services.telegram.telegram_service import TelegramService
76
+ from xp.utils.logging import LoggerService
75
77
 
76
78
  asyncioreactor.install()
77
79
  from twisted.internet import reactor # noqa: E402
@@ -87,7 +89,8 @@ class ServiceContainer:
87
89
 
88
90
  def __init__(
89
91
  self,
90
- config_path: str = "cli.yml",
92
+ client_config_path: str = "cli.yml",
93
+ logger_config_path: str = "logger.yml",
91
94
  homekit_config_path: str = "homekit.yml",
92
95
  conson_config_path: str = "conson.yml",
93
96
  server_port: int = 10001,
@@ -97,14 +100,16 @@ class ServiceContainer:
97
100
  Initialize the service container.
98
101
 
99
102
  Args:
100
- config_path: Path to the Conbus CLI configuration file
103
+ client_config_path: Path to the Conbus CLI configuration file
104
+ logger_config_path: Path to the Conbus Loggerr configuration file
101
105
  homekit_config_path: Path to the HomeKit configuration file
102
106
  conson_config_path: Path to the Conson configuration file
103
107
  server_port: Port for the server service
104
108
  reverse_proxy_port: Port for the reverse proxy service
105
109
  """
106
110
  self.container = punq.Container()
107
- self._config_path = config_path
111
+ self._client_config_path = client_config_path
112
+ self._logger_config_path = logger_config_path
108
113
  self._homekit_config_path = homekit_config_path
109
114
  self._conson_config_path = conson_config_path
110
115
  self._server_port = server_port
@@ -117,7 +122,13 @@ class ServiceContainer:
117
122
  # ConbusClientConfig
118
123
  self.container.register(
119
124
  ConbusClientConfig,
120
- factory=lambda: ConbusClientConfig.from_yaml(self._config_path),
125
+ factory=lambda: ConbusClientConfig.from_yaml(self._client_config_path),
126
+ scope=punq.Scope.singleton,
127
+ )
128
+
129
+ self.container.register(
130
+ ConbusLoggerConfig,
131
+ factory=lambda: ConbusLoggerConfig.from_yaml(self._logger_config_path),
121
132
  scope=punq.Scope.singleton,
122
133
  )
123
134
 
@@ -332,8 +343,7 @@ class ServiceContainer:
332
343
  self.container.register(
333
344
  ConbusReceiveService,
334
345
  factory=lambda: ConbusReceiveService(
335
- cli_config=self.container.resolve(ConbusClientConfig),
336
- reactor=self.container.resolve(PosixReactorBase),
346
+ conbus_protocol=self.container.resolve(ConbusEventProtocol)
337
347
  ),
338
348
  scope=punq.Scope.singleton,
339
349
  )
@@ -387,6 +397,15 @@ class ServiceContainer:
387
397
  scope=punq.Scope.singleton,
388
398
  )
389
399
 
400
+ # Logging
401
+ self.container.register(
402
+ LoggerService,
403
+ factory=lambda: LoggerService(
404
+ logger_config=self.container.resolve(ConbusLoggerConfig),
405
+ ),
406
+ scope=punq.Scope.singleton,
407
+ )
408
+
390
409
  # Module type services layer
391
410
  self.container.register(ModuleTypeService, scope=punq.Scope.singleton)
392
411
 
xp/utils/logging.py ADDED
@@ -0,0 +1,91 @@
1
+ """Logging service for XP application."""
2
+
3
+ import logging
4
+ from logging.handlers import RotatingFileHandler
5
+ from pathlib import Path
6
+
7
+ from xp.models.conbus.conbus_logger_config import ConbusLoggerConfig
8
+
9
+
10
+ class LoggerService:
11
+ """Service for managing logging configuration and setup."""
12
+
13
+ def __init__(self, logger_config: ConbusLoggerConfig):
14
+ """Initialize LoggerService with configuration.
15
+
16
+ Args:
17
+ logger_config: Logger configuration object.
18
+ """
19
+ self.logging_config = logger_config.log
20
+ self.logger = logging.getLogger(__name__)
21
+
22
+ def setup(self) -> None:
23
+ """Setup console and file logging with configured levels."""
24
+ # Setup file logging for term app
25
+ self.setup_console_logging(
26
+ self.logging_config.log_format, self.logging_config.date_format
27
+ )
28
+ self.setup_file_logging(
29
+ self.logging_config.log_format, self.logging_config.date_format
30
+ )
31
+
32
+ for module in self.logging_config.levels.keys():
33
+ logging.getLogger(module).setLevel(self.logging_config.levels[module])
34
+
35
+ def setup_console_logging(self, log_format: str, date_format: str) -> None:
36
+ """Setup console logging with specified format.
37
+
38
+ Args:
39
+ log_format: Log message format string.
40
+ date_format: Date format string for log timestamps.
41
+ """
42
+ # Force format on root logger and all handlers
43
+ formatter = logging.Formatter(log_format, datefmt=date_format)
44
+ root_logger = logging.getLogger()
45
+
46
+ # Set log level from CLI argument
47
+ numeric_level = getattr(logging, self.logging_config.default_level.upper())
48
+ root_logger.setLevel(numeric_level)
49
+
50
+ # Update all existing handlers or create new one
51
+ if root_logger.handlers:
52
+ for handler in root_logger.handlers:
53
+ handler.setFormatter(formatter)
54
+ else:
55
+ handler = logging.StreamHandler()
56
+ handler.setFormatter(formatter)
57
+ root_logger.addHandler(handler)
58
+
59
+ def setup_file_logging(self, log_format: str, date_format: str) -> None:
60
+ """Setup file logging with rotation for term application.
61
+
62
+ Args:
63
+ log_format: Log message format string.
64
+ date_format: Date format string for log timestamps.
65
+ """
66
+ log_path = Path(self.logging_config.path)
67
+ log_level = self.logging_config.default_level
68
+
69
+ try:
70
+ # Create log directory if it doesn't exist
71
+ log_path.parent.mkdir(parents=True, exist_ok=True)
72
+
73
+ # Create rotating file handler
74
+ file_handler = RotatingFileHandler(
75
+ log_path,
76
+ maxBytes=self.logging_config.max_bytes,
77
+ backupCount=self.logging_config.backup_count,
78
+ )
79
+
80
+ # Configure formatter to match console format
81
+ formatter = logging.Formatter(log_format, datefmt=date_format)
82
+ file_handler.setFormatter(formatter)
83
+ file_handler.setLevel(log_level)
84
+
85
+ # Attach to root logger
86
+ root_logger = logging.getLogger()
87
+ root_logger.addHandler(file_handler)
88
+
89
+ except (OSError, PermissionError) as e:
90
+ self.logger.warning(f"Failed to setup file logging at {log_path}: {e}")
91
+ self.logger.warning("Continuing without file logging")