ramses-rf 0.52.5__py3-none-any.whl → 0.53.1__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.
- ramses_cli/__init__.py +1 -1
- ramses_cli/client.py +178 -55
- ramses_cli/discovery.py +12 -28
- ramses_cli/py.typed +0 -0
- ramses_rf/__init__.py +2 -0
- ramses_rf/database.py +6 -3
- ramses_rf/device/heat.py +7 -5
- ramses_rf/entity_base.py +10 -4
- ramses_rf/gateway.py +206 -29
- ramses_rf/schemas.py +1 -0
- ramses_rf/system/zones.py +3 -2
- ramses_rf/version.py +1 -1
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.1.dist-info}/METADATA +1 -1
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.1.dist-info}/RECORD +28 -27
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.1.dist-info}/licenses/LICENSE +1 -1
- ramses_tx/command.py +1 -1
- ramses_tx/const.py +113 -24
- ramses_tx/gateway.py +3 -1
- ramses_tx/helpers.py +1 -1
- ramses_tx/message.py +29 -16
- ramses_tx/parsers.py +2 -2
- ramses_tx/protocol.py +22 -8
- ramses_tx/protocol_fsm.py +28 -10
- ramses_tx/schemas.py +2 -2
- ramses_tx/transport.py +503 -42
- ramses_tx/version.py +1 -1
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.1.dist-info}/WHEEL +0 -0
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.1.dist-info}/entry_points.txt +0 -0
ramses_cli/__init__.py
CHANGED
ramses_cli/client.py
CHANGED
|
@@ -7,7 +7,8 @@ import asyncio
|
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
9
|
import sys
|
|
10
|
-
from
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Final, Literal
|
|
11
12
|
|
|
12
13
|
import click
|
|
13
14
|
from colorama import Fore, Style, init as colorama_init
|
|
@@ -47,6 +48,10 @@ from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused
|
|
|
47
48
|
Code,
|
|
48
49
|
)
|
|
49
50
|
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from _typeshed import SupportsRead
|
|
53
|
+
|
|
54
|
+
|
|
50
55
|
_PROFILE_LIBRARY = False # NOTE: for profiling of library
|
|
51
56
|
|
|
52
57
|
if _PROFILE_LIBRARY:
|
|
@@ -85,28 +90,46 @@ COLORS = {
|
|
|
85
90
|
W_: Style.BRIGHT + Fore.MAGENTA,
|
|
86
91
|
}
|
|
87
92
|
|
|
88
|
-
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
|
93
|
+
CONTEXT_SETTINGS: dict[str, Any] = dict(help_option_names=["-h", "--help"])
|
|
89
94
|
|
|
90
95
|
LIB_KEYS = tuple(SCH_GLOBAL_CONFIG({}).keys()) + (SZ_SERIAL_PORT,)
|
|
91
96
|
LIB_CFG_KEYS = tuple(SCH_GLOBAL_CONFIG({})[SZ_CONFIG].keys()) + (SZ_EVOFW_FLAG,)
|
|
92
97
|
|
|
93
98
|
|
|
94
|
-
def normalise_config(
|
|
95
|
-
|
|
99
|
+
def normalise_config(
|
|
100
|
+
lib_config: dict[str, dict[str, str | bool | None]],
|
|
101
|
+
) -> tuple[str | None, dict[str, Any] | None]:
|
|
102
|
+
"""Convert a HA config dict into the client library's own format.
|
|
103
|
+
|
|
104
|
+
:param lib_config: The configuration dictionary from Home Assistant.
|
|
105
|
+
:return: A tuple containing the serial port (if any) and the normalized configuration dictionary.
|
|
106
|
+
"""
|
|
96
107
|
|
|
97
108
|
serial_port = lib_config.pop(SZ_SERIAL_PORT, None)
|
|
98
109
|
|
|
99
110
|
# fix for: https://github.com/ramses-rf/ramses_rf/issues/96
|
|
100
|
-
packet_log = lib_config.get(
|
|
101
|
-
|
|
111
|
+
packet_log: str | Mapping[str, str | bool | None] | None = lib_config.get(
|
|
112
|
+
SZ_PACKET_LOG
|
|
113
|
+
)
|
|
114
|
+
if packet_log is None:
|
|
115
|
+
packet_log = {}
|
|
116
|
+
elif isinstance(packet_log, str):
|
|
102
117
|
packet_log = {SZ_FILE_NAME: packet_log}
|
|
118
|
+
assert isinstance(packet_log, dict)
|
|
103
119
|
lib_config[SZ_PACKET_LOG] = packet_log
|
|
104
120
|
|
|
105
|
-
return serial_port, lib_config
|
|
121
|
+
return serial_port, lib_config # type: ignore[return-value]
|
|
106
122
|
|
|
107
123
|
|
|
108
|
-
def split_kwargs(
|
|
109
|
-
|
|
124
|
+
def split_kwargs(
|
|
125
|
+
obj: tuple[dict[str, Any], dict[str, Any]], kwargs: dict[str, Any]
|
|
126
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
127
|
+
"""Split kwargs into cli/library kwargs.
|
|
128
|
+
|
|
129
|
+
:param obj: A tuple containing the current CLI and library configuration dictionaries.
|
|
130
|
+
:param kwargs: The keyword arguments to split.
|
|
131
|
+
:return: A tuple containing the updated CLI and library configuration dictionaries.
|
|
132
|
+
"""
|
|
110
133
|
cli_kwargs, lib_kwargs = obj
|
|
111
134
|
|
|
112
135
|
cli_kwargs.update(
|
|
@@ -119,9 +142,19 @@ def split_kwargs(obj: tuple[dict, dict], kwargs: dict) -> tuple[dict, dict]:
|
|
|
119
142
|
|
|
120
143
|
|
|
121
144
|
class DeviceIdParamType(click.ParamType):
|
|
145
|
+
"""A Click parameter type for Device IDs."""
|
|
146
|
+
|
|
122
147
|
name = "device_id"
|
|
123
148
|
|
|
124
|
-
def convert(self, value: str, param, ctx):
|
|
149
|
+
def convert(self, value: str, param: Any, ctx: click.Context | None) -> str:
|
|
150
|
+
"""Convert the value to a Device ID.
|
|
151
|
+
|
|
152
|
+
:param value: The value to convert.
|
|
153
|
+
:param param: The parameter being converted.
|
|
154
|
+
:param ctx: The Click context.
|
|
155
|
+
:return: The converted Device ID.
|
|
156
|
+
:raises click.BadParameter: If the value is not a valid Device ID.
|
|
157
|
+
"""
|
|
125
158
|
if is_valid_dev_id(value):
|
|
126
159
|
return value.upper()
|
|
127
160
|
self.fail(f"{value!r} is not a valid device_id", param, ctx)
|
|
@@ -176,8 +209,20 @@ class DeviceIdParamType(click.ParamType):
|
|
|
176
209
|
help="display crazy things",
|
|
177
210
|
)
|
|
178
211
|
@click.pass_context
|
|
179
|
-
def cli(
|
|
180
|
-
|
|
212
|
+
def cli(
|
|
213
|
+
ctx: click.Context,
|
|
214
|
+
/,
|
|
215
|
+
config_file: SupportsRead[str | bytes] | None = None,
|
|
216
|
+
eavesdrop: None | bool = None,
|
|
217
|
+
**kwargs: Any,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""A CLI for the ramses_rf library.
|
|
220
|
+
|
|
221
|
+
:param ctx: The Click context.
|
|
222
|
+
:param config_file: An optional configuration file to load.
|
|
223
|
+
:param eavesdrop: Whether to enable eavesdropping mode.
|
|
224
|
+
:param kwargs: Additional keyword arguments.
|
|
225
|
+
"""
|
|
181
226
|
|
|
182
227
|
if kwargs[SZ_DBG_MODE] > 0: # Do first
|
|
183
228
|
start_debugging(kwargs[SZ_DBG_MODE] == 1)
|
|
@@ -187,7 +232,7 @@ def cli(ctx, config_file=None, eavesdrop: None | bool = None, **kwargs: Any) ->
|
|
|
187
232
|
if eavesdrop is not None:
|
|
188
233
|
lib_kwargs[SZ_CONFIG][SZ_ENABLE_EAVESDROP] = eavesdrop
|
|
189
234
|
|
|
190
|
-
if config_file: # TODO: validate with voluptuous, use YAML
|
|
235
|
+
if config_file: # TODO: validate file with voluptuous, use YAML
|
|
191
236
|
lib_kwargs = deep_merge(
|
|
192
237
|
lib_kwargs, json.load(config_file)
|
|
193
238
|
) # CLI takes precedence
|
|
@@ -197,6 +242,8 @@ def cli(ctx, config_file=None, eavesdrop: None | bool = None, **kwargs: Any) ->
|
|
|
197
242
|
|
|
198
243
|
# Args/Params for packet log only
|
|
199
244
|
class FileCommand(click.Command): # client.py parse <file>
|
|
245
|
+
"""A Click Command class for file-based operations."""
|
|
246
|
+
|
|
200
247
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
201
248
|
super().__init__(*args, **kwargs)
|
|
202
249
|
self.params.insert( # input_file name/path only
|
|
@@ -216,6 +263,8 @@ class FileCommand(click.Command): # client.py parse <file>
|
|
|
216
263
|
class PortCommand(
|
|
217
264
|
click.Command
|
|
218
265
|
): # client.py <command> <port> --packet-log xxx --evofw3-flag xxx
|
|
266
|
+
"""A Click Command class for serial port operations."""
|
|
267
|
+
|
|
219
268
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
220
269
|
super().__init__(*args, **kwargs)
|
|
221
270
|
self.params.insert(0, click.Argument(("serial-port",)))
|
|
@@ -251,8 +300,15 @@ class PortCommand(
|
|
|
251
300
|
# 1/4: PARSE (a file, +/- eavesdrop)
|
|
252
301
|
@click.command(cls=FileCommand) # parse a packet log file, then stop
|
|
253
302
|
@click.pass_obj
|
|
254
|
-
def parse(
|
|
255
|
-
|
|
303
|
+
def parse(
|
|
304
|
+
obj: Any, /, **kwargs: Any
|
|
305
|
+
) -> tuple[Literal["parse"], dict[str, str], dict[str, str]]:
|
|
306
|
+
"""Command to parse a log file containing messages/packets.
|
|
307
|
+
|
|
308
|
+
:param obj: The context object containing configuration.
|
|
309
|
+
:param kwargs: Additional keyword arguments.
|
|
310
|
+
:return: A tuple containing the command name, library configuration, and CLI configuration.
|
|
311
|
+
"""
|
|
256
312
|
config, lib_config = split_kwargs(obj, kwargs)
|
|
257
313
|
|
|
258
314
|
lib_config[SZ_INPUT_FILE] = config.pop(SZ_INPUT_FILE) # just the file path
|
|
@@ -277,8 +333,16 @@ def parse(obj, **kwargs: Any):
|
|
|
277
333
|
"--poll-devices", type=click.STRING, help="e.g. 'device_id, device_id, ...'"
|
|
278
334
|
)
|
|
279
335
|
@click.pass_obj
|
|
280
|
-
def monitor(
|
|
281
|
-
|
|
336
|
+
def monitor(
|
|
337
|
+
obj: Any, /, discover: None | bool = None, **kwargs: Any
|
|
338
|
+
) -> tuple[Literal["monitor"], dict[str, str], dict[str, str]]:
|
|
339
|
+
"""Monitor (eavesdrop and/or probe) a serial port for messages/packets.
|
|
340
|
+
|
|
341
|
+
:param obj: The context object containing configuration.
|
|
342
|
+
:param discover: Whether to enable discovery. If None, inferred from other arguments.
|
|
343
|
+
:param kwargs: Additional keyword arguments.
|
|
344
|
+
:return: A tuple containing the command name, library configuration, and CLI configuration.
|
|
345
|
+
"""
|
|
282
346
|
config, lib_config = split_kwargs(obj, kwargs)
|
|
283
347
|
|
|
284
348
|
if discover is None:
|
|
@@ -315,10 +379,16 @@ def monitor(obj, discover: None | bool = None, **kwargs: Any):
|
|
|
315
379
|
help="controller_id, filename.json",
|
|
316
380
|
)
|
|
317
381
|
@click.pass_obj
|
|
318
|
-
def execute(
|
|
382
|
+
def execute(
|
|
383
|
+
obj: Any, /, **kwargs: Any
|
|
384
|
+
) -> tuple[Literal["execute"], dict[str | None, str | dict[str, Any]], dict[str, str]]:
|
|
319
385
|
"""Execute any specified scripts, return the results, then quit.
|
|
320
386
|
|
|
321
387
|
Disables discovery, and enforces a strict allow_list.
|
|
388
|
+
|
|
389
|
+
:param obj: A tuple containing the CLI and library configuration dictionaries.
|
|
390
|
+
:param kwargs: Additional arguments passed to the command.
|
|
391
|
+
:return: A tuple containing the command string, library config, and CLI config.
|
|
322
392
|
"""
|
|
323
393
|
config, lib_config = split_kwargs(obj, kwargs)
|
|
324
394
|
|
|
@@ -326,29 +396,37 @@ def execute(obj, **kwargs: Any):
|
|
|
326
396
|
lib_config[SZ_CONFIG][SZ_DISABLE_DISCOVERY] = True
|
|
327
397
|
lib_config[SZ_CONFIG][SZ_DISABLE_QOS] = False
|
|
328
398
|
|
|
399
|
+
known_list: dict[str | None, dict[str, Any]] = {}
|
|
329
400
|
if kwargs[GET_FAULTS]:
|
|
330
401
|
known_list = {kwargs[GET_FAULTS]: {}}
|
|
331
402
|
elif kwargs[GET_SCHED][0]:
|
|
332
403
|
known_list = {kwargs[GET_SCHED][0]: {}}
|
|
333
404
|
elif kwargs[SET_SCHED][0]:
|
|
334
405
|
known_list = {kwargs[SET_SCHED][0]: {}}
|
|
335
|
-
else:
|
|
336
|
-
known_list = {}
|
|
337
406
|
|
|
338
407
|
if known_list:
|
|
339
408
|
print(" - known list is force-configured/enforced")
|
|
340
409
|
lib_config[SZ_KNOWN_LIST] = known_list
|
|
341
410
|
lib_config[SZ_CONFIG][SZ_ENFORCE_KNOWN_LIST] = True
|
|
342
411
|
|
|
343
|
-
return EXECUTE, lib_config, config
|
|
412
|
+
return EXECUTE, lib_config, config # type: ignore[return-value]
|
|
344
413
|
|
|
345
414
|
|
|
346
415
|
#
|
|
347
416
|
# 4/4: LISTEN (to RF, +/- eavesdrop - NO sending/discovery)
|
|
348
417
|
@click.command(cls=PortCommand) # (optionally) execute a command, then listen
|
|
349
418
|
@click.pass_obj
|
|
350
|
-
def listen(
|
|
351
|
-
|
|
419
|
+
def listen(
|
|
420
|
+
obj: Any, /, **kwargs: Any
|
|
421
|
+
) -> tuple[
|
|
422
|
+
Literal["listen"], dict[str, str | dict[str, str | None] | None], dict[str, Any]
|
|
423
|
+
]:
|
|
424
|
+
"""Listen to (eavesdrop only) a serial port for messages/packets.
|
|
425
|
+
|
|
426
|
+
:param obj: The context object containing configuration.
|
|
427
|
+
:param kwargs: Additional keyword arguments.
|
|
428
|
+
:return: A tuple containing the command name, library configuration, and CLI configuration.
|
|
429
|
+
"""
|
|
352
430
|
config, lib_config = split_kwargs(obj, kwargs)
|
|
353
431
|
|
|
354
432
|
print(" - sending is force-disabled")
|
|
@@ -358,20 +436,27 @@ def listen(obj, **kwargs: Any):
|
|
|
358
436
|
|
|
359
437
|
|
|
360
438
|
def print_results(gwy: Gateway, **kwargs: Any) -> None:
|
|
439
|
+
"""Print the results of execution commands (faults, schedules).
|
|
440
|
+
|
|
441
|
+
:param gwy: The gateway instance.
|
|
442
|
+
:param kwargs: The command arguments.
|
|
443
|
+
"""
|
|
361
444
|
if kwargs[GET_FAULTS]:
|
|
362
445
|
fault_log = gwy.system_by_id[kwargs[GET_FAULTS]]._faultlog.faultlog
|
|
363
446
|
|
|
364
|
-
if fault_log
|
|
365
|
-
print("No fault log, or failed to get the fault log.")
|
|
366
|
-
else:
|
|
447
|
+
if fault_log:
|
|
367
448
|
[print(f"{k:02X}", v) for k, v in fault_log.items()]
|
|
449
|
+
else:
|
|
450
|
+
print("No fault log, or failed to get the fault log.")
|
|
368
451
|
|
|
369
452
|
if kwargs[GET_SCHED][0]:
|
|
370
453
|
system_id, zone_idx = kwargs[GET_SCHED]
|
|
371
454
|
if zone_idx == "HW":
|
|
372
|
-
|
|
455
|
+
dhw = gwy.system_by_id[system_id].dhw
|
|
456
|
+
zone: Any = dhw
|
|
373
457
|
else:
|
|
374
458
|
zone = gwy.system_by_id[system_id].zone_by_idx[zone_idx]
|
|
459
|
+
assert zone
|
|
375
460
|
schedule = zone.schedule
|
|
376
461
|
|
|
377
462
|
if schedule is None:
|
|
@@ -387,6 +472,10 @@ def print_results(gwy: Gateway, **kwargs: Any) -> None:
|
|
|
387
472
|
|
|
388
473
|
|
|
389
474
|
def _save_state(gwy: Gateway) -> None:
|
|
475
|
+
"""Save the gateway state to files.
|
|
476
|
+
|
|
477
|
+
:param gwy: The gateway instance.
|
|
478
|
+
"""
|
|
390
479
|
schema, msgs = gwy.get_state()
|
|
391
480
|
|
|
392
481
|
with open("state_msgs.log", "w") as f:
|
|
@@ -397,6 +486,11 @@ def _save_state(gwy: Gateway) -> None:
|
|
|
397
486
|
|
|
398
487
|
|
|
399
488
|
def _print_engine_state(gwy: Gateway, **kwargs: Any) -> None:
|
|
489
|
+
"""Print the current engine state (schema and packets).
|
|
490
|
+
|
|
491
|
+
:param gwy: The gateway instance.
|
|
492
|
+
:param kwargs: Command arguments to determine verbosity.
|
|
493
|
+
"""
|
|
400
494
|
(schema, packets) = gwy.get_state(include_expired=True)
|
|
401
495
|
|
|
402
496
|
if kwargs["print_state"] > 0:
|
|
@@ -406,6 +500,11 @@ def _print_engine_state(gwy: Gateway, **kwargs: Any) -> None:
|
|
|
406
500
|
|
|
407
501
|
|
|
408
502
|
def print_summary(gwy: Gateway, **kwargs: Any) -> None:
|
|
503
|
+
"""Print a summary of the system state, schema, params, and status.
|
|
504
|
+
|
|
505
|
+
:param gwy: The gateway instance.
|
|
506
|
+
:param kwargs: Command arguments to determine what to display.
|
|
507
|
+
"""
|
|
409
508
|
entity = gwy.tcs or gwy
|
|
410
509
|
|
|
411
510
|
if kwargs.get("show_schema"):
|
|
@@ -445,8 +544,8 @@ def print_summary(gwy: Gateway, **kwargs: Any) -> None:
|
|
|
445
544
|
print(f"{msg._pkt}")
|
|
446
545
|
else: # TODO(eb): replace next block by
|
|
447
546
|
# raise NotImplementedError
|
|
448
|
-
for
|
|
449
|
-
if
|
|
547
|
+
for msg_code, verbs in device._msgz.items():
|
|
548
|
+
if msg_code in (Code._0005, Code._000C):
|
|
450
549
|
for verb in verbs.values():
|
|
451
550
|
for pkt in verb.values():
|
|
452
551
|
print(f"{pkt}")
|
|
@@ -455,19 +554,24 @@ def print_summary(gwy: Gateway, **kwargs: Any) -> None:
|
|
|
455
554
|
if gwy.msg_db:
|
|
456
555
|
for msg in gwy.msg_db.get(device=device.id):
|
|
457
556
|
print(f"{msg._pkt}")
|
|
458
|
-
else: # TODO(eb): replace next block by
|
|
557
|
+
else: # TODO(eb): Q1 2026 replace next legacy block by
|
|
459
558
|
# raise NotImplementedError
|
|
460
|
-
for
|
|
461
|
-
for verb in
|
|
559
|
+
for cd in device._msgz.values():
|
|
560
|
+
for verb in cd.values():
|
|
462
561
|
for pkt in verb.values():
|
|
463
562
|
print(f"{pkt}")
|
|
464
563
|
print()
|
|
465
564
|
|
|
466
565
|
|
|
467
|
-
async def async_main(command: str, lib_kwargs: dict, **kwargs: Any) -> None:
|
|
468
|
-
"""
|
|
566
|
+
async def async_main(command: str, lib_kwargs: dict[str, Any], **kwargs: Any) -> None:
|
|
567
|
+
"""Execute the main asynchronous logic for the CLI.
|
|
568
|
+
|
|
569
|
+
:param command: The command to execute (e.g., "execute", "monitor", "listen", "parse").
|
|
570
|
+
:param lib_kwargs: Configuration arguments for the library.
|
|
571
|
+
:param kwargs: Additional CLI arguments.
|
|
572
|
+
"""
|
|
469
573
|
|
|
470
|
-
def handle_msg(
|
|
574
|
+
def handle_msg(_msg: Message) -> None:
|
|
471
575
|
"""Process the message as it arrives (a callback).
|
|
472
576
|
|
|
473
577
|
In this case, the message is merely printed.
|
|
@@ -475,39 +579,53 @@ async def async_main(command: str, lib_kwargs: dict, **kwargs: Any) -> None:
|
|
|
475
579
|
|
|
476
580
|
if kwargs["long_format"]: # HACK for test/dev
|
|
477
581
|
print(
|
|
478
|
-
f"{
|
|
479
|
-
f" # {
|
|
582
|
+
f"{_msg.dtm.isoformat(timespec='microseconds')} ... {_msg!r}"
|
|
583
|
+
f" # {_msg.payload}" # or f' # ("{msg.src!r}", "{msg.dst!r}")'
|
|
480
584
|
)
|
|
481
585
|
return
|
|
482
586
|
|
|
483
|
-
dtm = f"{
|
|
587
|
+
dtm = f"{_msg.dtm:%H:%M:%S.%f}"[:-3]
|
|
484
588
|
con_cols = CONSOLE_COLS
|
|
485
589
|
|
|
486
|
-
if
|
|
487
|
-
print(f"{Style.BRIGHT}{Fore.YELLOW}{dtm} {
|
|
488
|
-
elif
|
|
489
|
-
print(f"{Style.BRIGHT}{COLORS.get(
|
|
490
|
-
elif
|
|
491
|
-
print(f"{Fore.YELLOW}{dtm} {
|
|
492
|
-
elif
|
|
493
|
-
print(f"{Fore.YELLOW}{dtm} {
|
|
590
|
+
if _msg.code == Code._PUZZ:
|
|
591
|
+
print(f"{Style.BRIGHT}{Fore.YELLOW}{dtm} {_msg}"[:con_cols])
|
|
592
|
+
elif _msg.src and _msg.src.type == DEV_TYPE_MAP.HGI:
|
|
593
|
+
print(f"{Style.BRIGHT}{COLORS.get(_msg.verb)}{dtm} {_msg}"[:con_cols])
|
|
594
|
+
elif _msg.code == Code._1F09 and _msg.verb == I_:
|
|
595
|
+
print(f"{Fore.YELLOW}{dtm} {_msg}"[:con_cols])
|
|
596
|
+
elif _msg.code in (Code._000A, Code._2309, Code._30C9) and _msg._has_array:
|
|
597
|
+
print(f"{Fore.YELLOW}{dtm} {_msg}"[:con_cols])
|
|
494
598
|
else:
|
|
495
|
-
print(f"{COLORS.get(
|
|
599
|
+
print(f"{COLORS.get(_msg.verb)}{dtm} {_msg}"[:con_cols])
|
|
496
600
|
|
|
497
|
-
serial_port, lib_kwargs = normalise_config(lib_kwargs)
|
|
601
|
+
serial_port, lib_kwargs = normalise_config(lib_kwargs) # type: ignore[assignment]
|
|
498
602
|
|
|
499
603
|
if kwargs["restore_schema"]:
|
|
500
604
|
print(" - restoring client schema from a HA cache...")
|
|
501
|
-
state = json.load(kwargs["restore_schema"])["data"][
|
|
605
|
+
state: dict[str, Any] = json.load(kwargs["restore_schema"])["data"][
|
|
606
|
+
"client_state"
|
|
607
|
+
]
|
|
502
608
|
lib_kwargs = lib_kwargs | state["schema"]
|
|
503
609
|
|
|
610
|
+
# Explicitly extract input_file if present to ensure it's passed as a named arg
|
|
611
|
+
input_file = lib_kwargs.pop(SZ_INPUT_FILE, None)
|
|
612
|
+
|
|
504
613
|
# if serial_port == "/dev/ttyMOCK":
|
|
505
614
|
# from tests.deprecated.mocked_rf import MockGateway # FIXME: for test/dev
|
|
506
615
|
# gwy = MockGateway(serial_port, **lib_kwargs)
|
|
507
616
|
# else:
|
|
508
|
-
gwy = Gateway(serial_port, **lib_kwargs) # passes action to gateway
|
|
509
617
|
|
|
510
|
-
|
|
618
|
+
# Instantiate Gateway, note: transport_factory is the default, so we don't need to pass it
|
|
619
|
+
gwy = Gateway(
|
|
620
|
+
serial_port,
|
|
621
|
+
input_file=input_file,
|
|
622
|
+
**lib_kwargs,
|
|
623
|
+
) # passes action to gateway
|
|
624
|
+
|
|
625
|
+
if (
|
|
626
|
+
int(lib_kwargs.get(SZ_CONFIG, {}).get(SZ_REDUCE_PROCESSING, 0))
|
|
627
|
+
< DONT_CREATE_MESSAGES
|
|
628
|
+
):
|
|
511
629
|
# library will not send MSGs to STDOUT, so we'll send PKTs instead
|
|
512
630
|
colorama_init(autoreset=True) # WIP: remove strip=True
|
|
513
631
|
gwy.add_msg_handler(handle_msg)
|
|
@@ -532,10 +650,10 @@ async def async_main(command: str, lib_kwargs: dict, **kwargs: Any) -> None:
|
|
|
532
650
|
|
|
533
651
|
elif command == MONITOR:
|
|
534
652
|
_ = spawn_scripts(gwy, **kwargs)
|
|
535
|
-
await gwy._protocol._wait_connection_lost
|
|
653
|
+
await asyncio.wait_for(gwy._protocol._wait_connection_lost, 1.0) # type: ignore[arg-type]
|
|
536
654
|
|
|
537
655
|
elif command in (LISTEN, PARSE):
|
|
538
|
-
await gwy._protocol._wait_connection_lost
|
|
656
|
+
await asyncio.wait_for(gwy._protocol._wait_connection_lost, 1.0) # type: ignore[arg-type]
|
|
539
657
|
|
|
540
658
|
except asyncio.CancelledError:
|
|
541
659
|
msg = "ended via: CancelledError (e.g. SIGINT)"
|
|
@@ -570,7 +688,12 @@ cli.add_command(execute)
|
|
|
570
688
|
cli.add_command(listen)
|
|
571
689
|
|
|
572
690
|
|
|
573
|
-
def main() -> None:
|
|
691
|
+
def main() -> None: # pragma: no cover
|
|
692
|
+
"""Entry point for the CLI.
|
|
693
|
+
|
|
694
|
+
Parses arguments, sets up the event loop (including Windows-specific policies),
|
|
695
|
+
and runs the main asynchronous loop (optionally with profiling).
|
|
696
|
+
"""
|
|
574
697
|
print("\r\nclient.py: Starting ramses_rf...")
|
|
575
698
|
|
|
576
699
|
try:
|
|
@@ -605,5 +728,5 @@ def main() -> None:
|
|
|
605
728
|
print(" - finished ramses_rf.\r\n")
|
|
606
729
|
|
|
607
730
|
|
|
608
|
-
if __name__ == "__main__":
|
|
731
|
+
if __name__ == "__main__": # pragma: no cover
|
|
609
732
|
main()
|
ramses_cli/discovery.py
CHANGED
|
@@ -17,10 +17,6 @@ from ramses_rf.device import Fakeable
|
|
|
17
17
|
from ramses_tx import CODES_SCHEMA, Command, DeviceIdT, Priority
|
|
18
18
|
from ramses_tx.opentherm import OTB_DATA_IDS
|
|
19
19
|
|
|
20
|
-
# Beware, none of this is reliable - it is all subject to random change
|
|
21
|
-
# However, these serve as examples how to use the other modules
|
|
22
|
-
|
|
23
|
-
|
|
24
20
|
from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
25
21
|
I_,
|
|
26
22
|
RP,
|
|
@@ -44,22 +40,19 @@ SCAN_FULL: Final = "scan_full"
|
|
|
44
40
|
SCAN_HARD: Final = "scan_hard"
|
|
45
41
|
SCAN_XXXX: Final = "scan_xxxx"
|
|
46
42
|
|
|
47
|
-
# DEVICE_ID_REGEX = re.compile(DEVICE_ID_REGEX.ANY)
|
|
48
|
-
|
|
49
|
-
|
|
50
43
|
_LOGGER = logging.getLogger(__name__)
|
|
51
44
|
|
|
52
45
|
|
|
53
46
|
def script_decorator(fnc: Callable[..., Any]) -> Callable[..., Any]:
|
|
54
47
|
@functools.wraps(fnc)
|
|
55
|
-
def wrapper(gwy: Gateway, *args: Any, **kwargs: Any) -> None:
|
|
48
|
+
async def wrapper(gwy: Gateway, *args: Any, **kwargs: Any) -> None:
|
|
56
49
|
gwy.send_cmd(
|
|
57
50
|
Command._puzzle(message="Script begins:"),
|
|
58
51
|
priority=Priority.HIGHEST,
|
|
59
52
|
num_repeats=3,
|
|
60
53
|
)
|
|
61
54
|
|
|
62
|
-
fnc(gwy, *args, **kwargs)
|
|
55
|
+
await fnc(gwy, *args, **kwargs)
|
|
63
56
|
|
|
64
57
|
gwy.send_cmd(
|
|
65
58
|
Command._puzzle(message="Script done."),
|
|
@@ -67,8 +60,6 @@ def script_decorator(fnc: Callable[..., Any]) -> Callable[..., Any]:
|
|
|
67
60
|
num_repeats=3,
|
|
68
61
|
)
|
|
69
62
|
|
|
70
|
-
return None
|
|
71
|
-
|
|
72
63
|
return wrapper
|
|
73
64
|
|
|
74
65
|
|
|
@@ -93,7 +84,12 @@ def spawn_scripts(gwy: Gateway, **kwargs: Any) -> list[asyncio.Task[None]]:
|
|
|
93
84
|
_LOGGER.warning(f"Script: {kwargs[EXEC_SCR][0]}() - unknown script")
|
|
94
85
|
else:
|
|
95
86
|
_LOGGER.info(f"Script: {kwargs[EXEC_SCR][0]}().- starts...")
|
|
96
|
-
|
|
87
|
+
# script_poll_device returns a list of tasks, others return a coroutine
|
|
88
|
+
result = script(gwy, kwargs[EXEC_SCR][1])
|
|
89
|
+
if isinstance(result, list):
|
|
90
|
+
tasks.extend(result)
|
|
91
|
+
else:
|
|
92
|
+
tasks.append(asyncio.create_task(result))
|
|
97
93
|
|
|
98
94
|
gwy._tasks.extend(tasks)
|
|
99
95
|
return tasks
|
|
@@ -104,14 +100,6 @@ async def exec_cmd(gwy: Gateway, **kwargs: Any) -> None:
|
|
|
104
100
|
await gwy.async_send_cmd(cmd, priority=Priority.HIGH, wait_for_reply=True)
|
|
105
101
|
|
|
106
102
|
|
|
107
|
-
# @script_decorator
|
|
108
|
-
# async def script_scan_001(gwy: Gateway, dev_id: DeviceIdT):
|
|
109
|
-
# _LOGGER.warning("scan_001() invoked - expect a lot of nonsense")
|
|
110
|
-
# for idx in range(0x10):
|
|
111
|
-
# gwy.send_cmd(Command.from_attrs(W_, dev_id, Code._000E, f"{idx:02X}0050"))
|
|
112
|
-
# gwy.send_cmd(Command.from_attrs(RQ, dev_id, Code._000E, f"{idx:02X}00C8"))
|
|
113
|
-
|
|
114
|
-
|
|
115
103
|
async def get_faults(
|
|
116
104
|
gwy: Gateway, ctl_id: DeviceIdT, start: int = 0, limit: int = 0x3F
|
|
117
105
|
) -> None:
|
|
@@ -199,7 +187,7 @@ def script_poll_device(gwy: Gateway, dev_id: DeviceIdT) -> list[asyncio.Task[Non
|
|
|
199
187
|
async def script_scan_disc(gwy: Gateway, dev_id: DeviceIdT) -> None:
|
|
200
188
|
_LOGGER.warning("scan_disc() invoked...")
|
|
201
189
|
|
|
202
|
-
await gwy.get_device(dev_id).discover()
|
|
190
|
+
await gwy.get_device(dev_id).discover()
|
|
203
191
|
|
|
204
192
|
|
|
205
193
|
@script_decorator
|
|
@@ -340,9 +328,7 @@ async def script_scan_otb_hard(gwy: Gateway, dev_id: DeviceIdT) -> None:
|
|
|
340
328
|
|
|
341
329
|
|
|
342
330
|
@script_decorator
|
|
343
|
-
async def script_scan_otb_map(
|
|
344
|
-
gwy: Gateway, dev_id: DeviceIdT
|
|
345
|
-
) -> None: # Tested only upon a R8820A
|
|
331
|
+
async def script_scan_otb_map(gwy: Gateway, dev_id: DeviceIdT) -> None:
|
|
346
332
|
_LOGGER.warning("script_scan_otb_map invoked - expect a lot of nonsense")
|
|
347
333
|
|
|
348
334
|
RAMSES_TO_OPENTHERM = {
|
|
@@ -364,9 +350,7 @@ async def script_scan_otb_map(
|
|
|
364
350
|
|
|
365
351
|
|
|
366
352
|
@script_decorator
|
|
367
|
-
async def script_scan_otb_ramses(
|
|
368
|
-
gwy: Gateway, dev_id: DeviceIdT
|
|
369
|
-
) -> None: # Tested only upon a R8820A
|
|
353
|
+
async def script_scan_otb_ramses(gwy: Gateway, dev_id: DeviceIdT) -> None:
|
|
370
354
|
_LOGGER.warning("script_scan_otb_ramses invoked - expect a lot of nonsense")
|
|
371
355
|
|
|
372
356
|
_CODES = (
|
|
@@ -394,7 +378,7 @@ async def script_scan_otb_ramses(
|
|
|
394
378
|
Code._3223,
|
|
395
379
|
Code._3EF0, # rel. modulation level / RelativeModulationLevel (also, below)
|
|
396
380
|
Code._3EF1, # rel. modulation level / RelativeModulationLevel
|
|
397
|
-
)
|
|
381
|
+
)
|
|
398
382
|
|
|
399
383
|
for c in _CODES:
|
|
400
384
|
gwy.send_cmd(Command.from_attrs(RQ, dev_id, c, "00"), priority=Priority.LOW)
|
ramses_cli/py.typed
ADDED
|
File without changes
|
ramses_rf/__init__.py
CHANGED
ramses_rf/database.py
CHANGED
|
@@ -272,18 +272,21 @@ class MessageIndex:
|
|
|
272
272
|
|
|
273
273
|
return old
|
|
274
274
|
|
|
275
|
-
def add_record(
|
|
275
|
+
def add_record(
|
|
276
|
+
self, src: str, code: str = "", verb: str = "", payload: str = "00"
|
|
277
|
+
) -> None:
|
|
276
278
|
"""
|
|
277
279
|
Add a single record to the MessageIndex with timestamp `now()` and no Message contents.
|
|
278
280
|
|
|
279
281
|
:param src: device id to use as source address
|
|
280
282
|
:param code: device id to use as destination address (can be identical)
|
|
281
283
|
:param verb: two letter verb str to use
|
|
284
|
+
:param payload: payload str to use
|
|
282
285
|
"""
|
|
283
|
-
# Used by OtbGateway init, via entity_base.py
|
|
286
|
+
# Used by OtbGateway init, via entity_base.py (code=_3220)
|
|
284
287
|
_now: dt = dt.now()
|
|
285
288
|
dtm: DtmStrT = _now.isoformat(timespec="microseconds") # type: ignore[assignment]
|
|
286
|
-
hdr = f"{code}|{verb}|{src}|
|
|
289
|
+
hdr = f"{code}|{verb}|{src}|{payload}"
|
|
287
290
|
|
|
288
291
|
dup = self._delete_from(hdr=hdr)
|
|
289
292
|
|
ramses_rf/device/heat.py
CHANGED
|
@@ -668,11 +668,13 @@ class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
|
|
|
668
668
|
self._child_id = FC # NOTE: domain_id
|
|
669
669
|
|
|
670
670
|
# TODO(eb): cleanup
|
|
671
|
-
if self._gwy.msg_db:
|
|
672
|
-
self._add_record(
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
671
|
+
if not self._gwy.msg_db:
|
|
672
|
+
# self._add_record(
|
|
673
|
+
# id=self.id, code=Code._3220, verb="RP", payload="00C0060101"
|
|
674
|
+
# ) # is parsed but pollutes the client.py
|
|
675
|
+
# adds a "sim" RP opentherm_msg to the SQLite MessageIndex with code _3220
|
|
676
|
+
# causes exc when fetching ALL, when no "real" msg was added to _msgs_. We skip those.
|
|
677
|
+
# else:
|
|
676
678
|
self._msgz[Code._3220] = {RP: {}} # No ctx! (not None)
|
|
677
679
|
|
|
678
680
|
# lf._use_ot = self._gwy.config.use_native_ot
|
ramses_rf/entity_base.py
CHANGED
|
@@ -324,12 +324,16 @@ class _MessageDB(_Entity):
|
|
|
324
324
|
]
|
|
325
325
|
|
|
326
326
|
def _add_record(
|
|
327
|
-
self,
|
|
327
|
+
self,
|
|
328
|
+
id: DeviceIdT,
|
|
329
|
+
code: Code | None = None,
|
|
330
|
+
verb: str = " I",
|
|
331
|
+
payload: str = "00",
|
|
328
332
|
) -> None:
|
|
329
333
|
"""Add a (dummy) record to the central SQLite MessageIndex."""
|
|
330
|
-
# used by heat.py init
|
|
334
|
+
# used by heat.py.OtbGateway init
|
|
331
335
|
if self._gwy.msg_db:
|
|
332
|
-
self._gwy.msg_db.add_record(id, code=str(code), verb=verb)
|
|
336
|
+
self._gwy.msg_db.add_record(id, code=str(code), verb=verb, payload=payload)
|
|
333
337
|
# else:
|
|
334
338
|
# _LOGGER.warning("Missing MessageIndex")
|
|
335
339
|
# raise NotImplementedError
|
|
@@ -1048,7 +1052,9 @@ class _Discovery(_MessageDB):
|
|
|
1048
1052
|
f"No msg found for hdr {hdr}, task code {task[_SZ_COMMAND].code}"
|
|
1049
1053
|
)
|
|
1050
1054
|
else: # TODO(eb) remove next Q1 2026
|
|
1051
|
-
|
|
1055
|
+
# CRITICAL FIX: self.tcs might be None during early discovery
|
|
1056
|
+
if self.tcs:
|
|
1057
|
+
msgs += [self.tcs._msgz[task[_SZ_COMMAND].code][I_][True]]
|
|
1052
1058
|
# raise NotImplementedError
|
|
1053
1059
|
except KeyError:
|
|
1054
1060
|
pass
|