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 CHANGED
@@ -12,7 +12,7 @@ _DBG_FORCE_CLI_DEBUGGING: Final[bool] = (
12
12
  )
13
13
 
14
14
 
15
- if _DBG_FORCE_CLI_DEBUGGING:
15
+ if _DBG_FORCE_CLI_DEBUGGING: # pragma: no cover
16
16
  from .debug import start_debugging
17
17
 
18
18
  start_debugging(True)
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 typing import Any, Final
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(lib_config: dict) -> tuple[str, dict]:
95
- """Convert a HA config dict into the client library's own format."""
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(SZ_PACKET_LOG)
101
- if isinstance(packet_log, str):
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(obj: tuple[dict, dict], kwargs: dict) -> tuple[dict, dict]:
109
- """Split kwargs into cli/library kwargs."""
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(ctx, config_file=None, eavesdrop: None | bool = None, **kwargs: Any) -> None:
180
- """A CLI for the ramses_rf library."""
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(obj, **kwargs: Any):
255
- """Command to parse a log file containing messages/packets."""
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(obj, discover: None | bool = None, **kwargs: Any):
281
- """Monitor (eavesdrop and/or probe) a serial port for messages/packets."""
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(obj, **kwargs: Any):
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(obj, **kwargs: Any):
351
- """Listen to (eavesdrop only) a serial port for messages/packets."""
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 is None:
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
- zone = gwy.system_by_id[system_id].dhw
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 code, verbs in device._msgz.items():
449
- if code in (Code._0005, Code._000C):
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 code in device._msgz.values():
461
- for verb in code.values():
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
- """Do certain things."""
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(msg: Message) -> None:
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"{msg.dtm.isoformat(timespec='microseconds')} ... {msg!r}"
479
- f" # {msg.payload}" # or f' # ("{msg.src!r}", "{msg.dst!r}")'
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"{msg.dtm:%H:%M:%S.%f}"[:-3]
587
+ dtm = f"{_msg.dtm:%H:%M:%S.%f}"[:-3]
484
588
  con_cols = CONSOLE_COLS
485
589
 
486
- if msg.code == Code._PUZZ:
487
- print(f"{Style.BRIGHT}{Fore.YELLOW}{dtm} {msg}"[:con_cols])
488
- elif msg.src and msg.src.type == DEV_TYPE_MAP.HGI:
489
- print(f"{Style.BRIGHT}{COLORS.get(msg.verb)}{dtm} {msg}"[:con_cols])
490
- elif msg.code == Code._1F09 and msg.verb == I_:
491
- print(f"{Fore.YELLOW}{dtm} {msg}"[:con_cols])
492
- elif msg.code in (Code._000A, Code._2309, Code._30C9) and msg._has_array:
493
- print(f"{Fore.YELLOW}{dtm} {msg}"[:con_cols])
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(msg.verb)}{dtm} {msg}"[:con_cols])
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"]["client_state"]
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
- if lib_kwargs[SZ_CONFIG][SZ_REDUCE_PROCESSING] < DONT_CREATE_MESSAGES:
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
- tasks += [asyncio.create_task(script(gwy, kwargs[EXEC_SCR][1]))]
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() # discover_flag=Discover.DEFAULT)
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
- ) # excl. 3150, 3220
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
@@ -56,6 +56,8 @@ __all__ = [
56
56
  "VerbT",
57
57
  #
58
58
  "exceptions",
59
+ #
60
+ "GracefulExit",
59
61
  ]
60
62
 
61
63
  _LOGGER = logging.getLogger(__name__)
ramses_rf/database.py CHANGED
@@ -272,18 +272,21 @@ class MessageIndex:
272
272
 
273
273
  return old
274
274
 
275
- def add_record(self, src: str, code: str = "", verb: str = "") -> None:
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}|00" # dummy record has no contents
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(id=self.id, code=Code._3220, verb="RP")
673
- # adds a "sim" RP opentherm_msg to the SQLite MessageIndex with code _3220
674
- # causes exc when fetching ALL, when no "real" msg was added to _msgs_. We skip those.
675
- else:
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, id: DeviceIdT, code: Code | None = None, verb: str = " I"
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
- msgs += [self.tcs._msgz[task[_SZ_COMMAND].code][I_][True]]
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