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 +168 -54
- ramses_cli/debug.py +1 -1
- ramses_cli/py.typed +0 -0
- ramses_cli/utils/convert.py +2 -2
- ramses_rf/__init__.py +2 -0
- ramses_rf/database.py +40 -17
- ramses_rf/device/base.py +14 -3
- ramses_rf/device/heat.py +1 -1
- ramses_rf/device/hvac.py +24 -21
- ramses_rf/entity_base.py +9 -7
- ramses_rf/gateway.py +214 -27
- ramses_rf/schemas.py +2 -1
- ramses_rf/system/zones.py +22 -2
- ramses_rf/version.py +1 -1
- {ramses_rf-0.52.4.dist-info → ramses_rf-0.53.0.dist-info}/METADATA +1 -1
- ramses_rf-0.53.0.dist-info/RECORD +56 -0
- {ramses_rf-0.52.4.dist-info → ramses_rf-0.53.0.dist-info}/WHEEL +1 -1
- {ramses_rf-0.52.4.dist-info → ramses_rf-0.53.0.dist-info}/licenses/LICENSE +1 -1
- ramses_tx/address.py +21 -6
- ramses_tx/command.py +19 -3
- ramses_tx/const.py +110 -23
- ramses_tx/helpers.py +30 -10
- ramses_tx/message.py +11 -5
- ramses_tx/packet.py +13 -5
- ramses_tx/parsers.py +1039 -16
- ramses_tx/protocol.py +112 -23
- ramses_tx/protocol_fsm.py +28 -10
- ramses_tx/schemas.py +2 -2
- ramses_tx/transport.py +529 -47
- ramses_tx/version.py +1 -1
- ramses_rf-0.52.4.dist-info/RECORD +0 -55
- {ramses_rf-0.52.4.dist-info → ramses_rf-0.53.0.dist-info}/entry_points.txt +0 -0
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
|
|
@@ -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(
|
|
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.
|
|
469
568
|
|
|
470
|
-
|
|
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"{
|
|
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
|
|
|
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(
|
|
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
|
|
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
|
ramses_cli/utils/convert.py
CHANGED
|
@@ -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) ->
|
|
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) ->
|
|
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
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
554
|
-
|
|
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:
|