ramses-rf 0.52.4__py3-none-any.whl → 0.53.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.
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
@@ -22,7 +23,7 @@ from ramses_rf.schemas import (
22
23
  SZ_ENABLE_EAVESDROP,
23
24
  SZ_REDUCE_PROCESSING,
24
25
  )
25
- from ramses_tx import is_valid_dev_id
26
+ from ramses_tx import is_valid_dev_id, transport_factory
26
27
  from ramses_tx.logger import CONSOLE_COLS, DEFAULT_DATEFMT, DEFAULT_FMT
27
28
  from ramses_tx.schemas import (
28
29
  SZ_DISABLE_QOS,
@@ -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.
469
568
 
470
- def handle_msg(msg: Message) -> None:
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
+ """
573
+
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,43 @@ 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
 
504
610
  # if serial_port == "/dev/ttyMOCK":
505
611
  # from tests.deprecated.mocked_rf import MockGateway # FIXME: for test/dev
506
612
  # gwy = MockGateway(serial_port, **lib_kwargs)
507
613
  # else:
508
- gwy = Gateway(serial_port, **lib_kwargs) # passes action to gateway
614
+ gwy = Gateway(
615
+ serial_port, transport_constructor=transport_factory, **lib_kwargs
616
+ ) # passes action to gateway
509
617
 
510
- if lib_kwargs[SZ_CONFIG][SZ_REDUCE_PROCESSING] < DONT_CREATE_MESSAGES:
618
+ if int(lib_kwargs[SZ_CONFIG][SZ_REDUCE_PROCESSING]) < DONT_CREATE_MESSAGES:
511
619
  # library will not send MSGs to STDOUT, so we'll send PKTs instead
512
620
  colorama_init(autoreset=True) # WIP: remove strip=True
513
621
  gwy.add_msg_handler(handle_msg)
@@ -532,10 +640,10 @@ async def async_main(command: str, lib_kwargs: dict, **kwargs: Any) -> None:
532
640
 
533
641
  elif command == MONITOR:
534
642
  _ = spawn_scripts(gwy, **kwargs)
535
- await gwy._protocol._wait_connection_lost
643
+ await asyncio.wait_for(gwy._protocol._wait_connection_lost, 1.0) # type: ignore[arg-type]
536
644
 
537
645
  elif command in (LISTEN, PARSE):
538
- await gwy._protocol._wait_connection_lost
646
+ await asyncio.wait_for(gwy._protocol._wait_connection_lost, 1.0) # type: ignore[arg-type]
539
647
 
540
648
  except asyncio.CancelledError:
541
649
  msg = "ended via: CancelledError (e.g. SIGINT)"
@@ -571,6 +679,11 @@ cli.add_command(listen)
571
679
 
572
680
 
573
681
  def main() -> None:
682
+ """Entry point for the CLI.
683
+
684
+ Parses arguments, sets up the event loop (including Windows-specific policies),
685
+ and runs the main asynchronous loop (optionally with profiling).
686
+ """
574
687
  print("\r\nclient.py: Starting ramses_rf...")
575
688
 
576
689
  try:
@@ -588,6 +701,7 @@ def main() -> None:
588
701
  print(" - event_loop_policy set for win32") # do before asyncio.run()
589
702
  asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
590
703
 
704
+ profile = None
591
705
  try:
592
706
  if _PROFILE_LIBRARY:
593
707
  profile = cProfile.Profile()
ramses_cli/debug.py CHANGED
@@ -9,7 +9,7 @@ DEBUG_PORT = 5678
9
9
 
10
10
 
11
11
  def start_debugging(wait_for_client: bool) -> None:
12
- import debugpy # type: ignore[import-untyped]
12
+ import debugpy
13
13
 
14
14
  debugpy.listen(address=(DEBUG_ADDR, DEBUG_PORT))
15
15
  print(f" - Debugging is enabled, listening on: {DEBUG_ADDR}:{DEBUG_PORT}")
ramses_cli/py.typed ADDED
File without changes
@@ -17,7 +17,7 @@ parser.add_argument("-i", "--input-file", type=argparse.FileType("r"), default="
17
17
  args = parser.parse_args()
18
18
 
19
19
 
20
- def convert_json_to_yaml(data: dict) -> str:
20
+ def convert_json_to_yaml(data: dict) -> None:
21
21
  """Convert from json (client.py -C config.json) to yaml (HA configuration.yaml)."""
22
22
  (config, schema, include, exclude) = load_config("/dev/ttyMOCK", None, **data)
23
23
 
@@ -37,7 +37,7 @@ def convert_json_to_yaml(data: dict) -> str:
37
37
  print(yaml.dump({"ramses_cc": result}, sort_keys=False))
38
38
 
39
39
 
40
- def convert_yaml_to_json(data: dict) -> str:
40
+ def convert_yaml_to_json(data: dict) -> None:
41
41
  """Convert from yaml (HA configuration.yaml) to json (client.py -C config.json)."""
42
42
 
43
43
  result = data["ramses_cc"]
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
@@ -30,7 +30,7 @@ from collections import OrderedDict
30
30
  from datetime import datetime as dt, timedelta as td
31
31
  from typing import TYPE_CHECKING, Any, NewType
32
32
 
33
- from ramses_tx import CODES_SCHEMA, Code, Message
33
+ from ramses_tx import CODES_SCHEMA, RQ, Code, Message, Packet
34
34
 
35
35
  if TYPE_CHECKING:
36
36
  DtmStrT = NewType("DtmStrT", str)
@@ -155,7 +155,7 @@ class MessageIndex:
155
155
  - verb " I", "RQ" etc.
156
156
  - src message origin address
157
157
  - dst message destination address
158
- - code packet code aka command class e.g. _0005, _31DA
158
+ - code packet code aka command class e.g. 0005, 31DA
159
159
  - ctx message context, created from payload as index + extra markers (Heat)
160
160
  - hdr packet header e.g. 000C|RP|01:223036|0208 (see: src/ramses_tx/frame.py)
161
161
  - plk the keys stored in the parsed payload, separated by the | char
@@ -187,7 +187,7 @@ class MessageIndex:
187
187
 
188
188
  async def _housekeeping_loop(self) -> None:
189
189
  """Periodically remove stale messages from the index,
190
- unless `self.maintain` is False."""
190
+ unless `self.maintain` is False - as in (most) tests."""
191
191
 
192
192
  async def housekeeping(dt_now: dt, _cutoff: td = td(days=1)) -> None:
193
193
  """
@@ -195,7 +195,8 @@ class MessageIndex:
195
195
  :param dt_now: current timestamp
196
196
  :param _cutoff: the oldest timestamp to retain, default is 24 hours ago
197
197
  """
198
- dtm = dt_now - _cutoff # .isoformat(timespec="microseconds") < needed?
198
+ msgs = None
199
+ dtm = dt_now - _cutoff
199
200
 
200
201
  self._cu.execute("SELECT dtm FROM messages WHERE dtm >= ?", (dtm,))
201
202
  rows = self._cu.fetchall() # fetch dtm of current messages to retain
@@ -212,6 +213,10 @@ class MessageIndex:
212
213
  self._msgs = msgs
213
214
  finally:
214
215
  self._lock.release()
216
+ if msgs:
217
+ _LOGGER.debug(
218
+ "MessageIndex size was: %d, now: %d", len(rows), len(msgs)
219
+ )
215
220
 
216
221
  while True:
217
222
  self._last_housekeeping = dt.now()
@@ -246,13 +251,17 @@ class MessageIndex:
246
251
  else:
247
252
  # _msgs dict requires a timestamp reformat
248
253
  dtm: DtmStrT = msg.dtm.isoformat(timespec="microseconds") # type: ignore[assignment]
254
+ # add msg to self._msgs dict
249
255
  self._msgs[dtm] = msg
250
256
 
251
257
  finally:
252
258
  pass # self._lock.release()
253
259
 
254
260
  if (
255
- dup and msg.src is not msg.dst and not msg.dst.id.startswith("18:") # HGI
261
+ dup
262
+ and (msg.src is not msg.dst)
263
+ and not msg.dst.id.startswith("18:") # HGI
264
+ and msg.verb != RQ # these may come very quickly
256
265
  ): # when src==dst, expect to add duplicate, don't warn
257
266
  _LOGGER.debug(
258
267
  "Overwrote dtm (%s) for %s: %s (contrived log?)",
@@ -260,8 +269,6 @@ class MessageIndex:
260
269
  msg._pkt._hdr,
261
270
  dup[0]._pkt,
262
271
  )
263
- if old is not None:
264
- _LOGGER.debug("Old msg replaced: %s", old)
265
272
 
266
273
  return old
267
274
 
@@ -274,7 +281,8 @@ class MessageIndex:
274
281
  :param verb: two letter verb str to use
275
282
  """
276
283
  # Used by OtbGateway init, via entity_base.py
277
- dtm: DtmStrT = dt.strftime(dt.now(), "%Y-%m-%dT%H:%M:%S") # type: ignore[assignment]
284
+ _now: dt = dt.now()
285
+ dtm: DtmStrT = _now.isoformat(timespec="microseconds") # type: ignore[assignment]
278
286
  hdr = f"{code}|{verb}|{src}|00" # dummy record has no contents
279
287
 
280
288
  dup = self._delete_from(hdr=hdr)
@@ -287,7 +295,7 @@ class MessageIndex:
287
295
  self._cu.execute(
288
296
  sql,
289
297
  (
290
- dtm,
298
+ _now,
291
299
  verb,
292
300
  src,
293
301
  src,
@@ -299,6 +307,14 @@ class MessageIndex:
299
307
  )
300
308
  except sqlite3.Error:
301
309
  self._cx.rollback()
310
+ else:
311
+ # also add dummy 3220 msg to self._msgs dict to allow maintenance loop
312
+ msg: Message = Message._from_pkt(
313
+ Packet(
314
+ _now, f"... {verb} --- {src} --:------ {src} {code} 005 0000000000"
315
+ )
316
+ )
317
+ self._msgs[dtm] = msg
302
318
 
303
319
  if dup: # expected when more than one heat system in schema
304
320
  _LOGGER.debug("Replaced record with same hdr: %s", hdr)
@@ -359,7 +375,7 @@ class MessageIndex:
359
375
  if not bool(msg) ^ bool(kwargs):
360
376
  raise ValueError("Either a Message or kwargs should be provided, not both")
361
377
  if msg:
362
- kwargs["dtm"] = msg.dtm # .isoformat(timespec="microseconds")
378
+ kwargs["dtm"] = msg.dtm
363
379
 
364
380
  msgs = None
365
381
  try: # make this operation atomic, i.e. update self._msgs only on success
@@ -410,7 +426,7 @@ class MessageIndex:
410
426
  raise ValueError("Either a Message or kwargs should be provided, not both")
411
427
 
412
428
  if msg:
413
- kwargs["dtm"] = msg.dtm # .isoformat(timespec="microseconds")
429
+ kwargs["dtm"] = msg.dtm
414
430
 
415
431
  return self._select_from(**kwargs)
416
432
 
@@ -432,10 +448,15 @@ class MessageIndex:
432
448
  :returns: a tuple of qualifying messages
433
449
  """
434
450
 
435
- return tuple(
436
- self._msgs[row[0].isoformat(timespec="microseconds")]
437
- for row in self.qry_dtms(**kwargs)
438
- )
451
+ # CHANGE: Use a list comprehension with a check to avoid KeyError
452
+ res: list[Message] = []
453
+ for row in self.qry_dtms(**kwargs):
454
+ ts: DtmStrT = row[0].isoformat(timespec="microseconds")
455
+ if ts in self._msgs:
456
+ res.append(self._msgs[ts])
457
+ else:
458
+ _LOGGER.debug("MessageIndex timestamp %s not in device messages", ts)
459
+ return tuple(res)
439
460
 
440
461
  def qry_dtms(self, **kwargs: bool | dt | str) -> list[Any]:
441
462
  """
@@ -487,6 +508,7 @@ class MessageIndex:
487
508
  # _msgs stamp format: 2022-09-08T13:40:52.447364
488
509
  if ts in self._msgs:
489
510
  lst.append(self._msgs[ts])
511
+ # _LOGGER.debug("MessageIndex ts %s added to qry.lst", ts) # too frequent
490
512
  else: # happens in tests with artificial msg from heat
491
513
  _LOGGER.info("MessageIndex timestamp %s not in device messages", ts)
492
514
  return tuple(lst)
@@ -550,8 +572,9 @@ class MessageIndex:
550
572
  if ts in self._msgs:
551
573
  # if include_expired or not self._msgs[ts].HAS_EXPIRED: # not working
552
574
  lst.append(self._msgs[ts])
553
- else: # happens in tests with dummy msg from heat init
554
- _LOGGER.info("MessageIndex ts %s not in device messages", ts)
575
+ _LOGGER.debug("MessageIndex ts %s added to all.lst", ts)
576
+ else: # happens in tests and real evohome setups with dummy msg from heat init
577
+ _LOGGER.debug("MessageIndex ts %s not in device messages", ts)
555
578
  return tuple(lst)
556
579
 
557
580
  def clr(self) -> None: