roborock-cli 0.1.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.
- roborock_cli/__init__.py +3 -0
- roborock_cli/__main__.py +76 -0
- roborock_cli/_vendor/VERSION +6 -0
- roborock_cli/_vendor/__init__.py +0 -0
- roborock_cli/_vendor/roborock/__init__.py +27 -0
- roborock_cli/_vendor/roborock/broadcast_protocol.py +114 -0
- roborock_cli/_vendor/roborock/callbacks.py +130 -0
- roborock_cli/_vendor/roborock/cli.py +1338 -0
- roborock_cli/_vendor/roborock/const.py +84 -0
- roborock_cli/_vendor/roborock/data/__init__.py +9 -0
- roborock_cli/_vendor/roborock/data/b01_q10/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_code_mappings.py +213 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_containers.py +102 -0
- roborock_cli/_vendor/roborock/data/b01_q7/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_code_mappings.py +303 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_containers.py +302 -0
- roborock_cli/_vendor/roborock/data/code_mappings.py +198 -0
- roborock_cli/_vendor/roborock/data/containers.py +530 -0
- roborock_cli/_vendor/roborock/data/dyad/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_code_mappings.py +102 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_containers.py +28 -0
- roborock_cli/_vendor/roborock/data/v1/__init__.py +3 -0
- roborock_cli/_vendor/roborock/data/v1/v1_clean_modes.py +192 -0
- roborock_cli/_vendor/roborock/data/v1/v1_code_mappings.py +644 -0
- roborock_cli/_vendor/roborock/data/v1/v1_containers.py +800 -0
- roborock_cli/_vendor/roborock/data/zeo/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_code_mappings.py +138 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_containers.py +0 -0
- roborock_cli/_vendor/roborock/device_features.py +668 -0
- roborock_cli/_vendor/roborock/devices/README.md +41 -0
- roborock_cli/_vendor/roborock/devices/__init__.py +11 -0
- roborock_cli/_vendor/roborock/devices/cache.py +143 -0
- roborock_cli/_vendor/roborock/devices/device.py +240 -0
- roborock_cli/_vendor/roborock/devices/device_manager.py +269 -0
- roborock_cli/_vendor/roborock/devices/file_cache.py +79 -0
- roborock_cli/_vendor/roborock/devices/rpc/__init__.py +14 -0
- roborock_cli/_vendor/roborock/devices/rpc/a01_channel.py +94 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q10_channel.py +57 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q7_channel.py +101 -0
- roborock_cli/_vendor/roborock/devices/rpc/v1_channel.py +457 -0
- roborock_cli/_vendor/roborock/devices/traits/__init__.py +28 -0
- roborock_cli/_vendor/roborock/devices/traits/a01/__init__.py +191 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/__init__.py +12 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/__init__.py +76 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/command.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/common.py +115 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/status.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/vacuum.py +81 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/__init__.py +136 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/clean_summary.py +75 -0
- roborock_cli/_vendor/roborock/devices/traits/traits_mixin.py +64 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/__init__.py +344 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/child_lock.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/clean_summary.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/command.py +38 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/common.py +172 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/consumeable.py +48 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/device_features.py +74 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/do_not_disturb.py +41 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/dust_collection_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/flow_led_status.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/home.py +285 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/led_status.py +43 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/map_content.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/maps.py +80 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/network_info.py +55 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/rooms.py +105 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/routines.py +26 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/smart_wash_params.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/status.py +101 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/valley_electricity_timer.py +44 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/volume.py +27 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/wash_towel_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/transport/__init__.py +8 -0
- roborock_cli/_vendor/roborock/devices/transport/channel.py +32 -0
- roborock_cli/_vendor/roborock/devices/transport/local_channel.py +295 -0
- roborock_cli/_vendor/roborock/devices/transport/mqtt_channel.py +118 -0
- roborock_cli/_vendor/roborock/diagnostics.py +166 -0
- roborock_cli/_vendor/roborock/exceptions.py +95 -0
- roborock_cli/_vendor/roborock/map/__init__.py +7 -0
- roborock_cli/_vendor/roborock/map/map_parser.py +123 -0
- roborock_cli/_vendor/roborock/mqtt/__init__.py +10 -0
- roborock_cli/_vendor/roborock/mqtt/health_manager.py +60 -0
- roborock_cli/_vendor/roborock/mqtt/roborock_session.py +463 -0
- roborock_cli/_vendor/roborock/mqtt/session.py +108 -0
- roborock_cli/_vendor/roborock/protocol.py +558 -0
- roborock_cli/_vendor/roborock/protocols/__init__.py +3 -0
- roborock_cli/_vendor/roborock/protocols/a01_protocol.py +74 -0
- roborock_cli/_vendor/roborock/protocols/b01_q10_protocol.py +87 -0
- roborock_cli/_vendor/roborock/protocols/b01_q7_protocol.py +81 -0
- roborock_cli/_vendor/roborock/protocols/v1_protocol.py +271 -0
- roborock_cli/_vendor/roborock/py.typed +0 -0
- roborock_cli/_vendor/roborock/roborock_message.py +246 -0
- roborock_cli/_vendor/roborock/roborock_typing.py +382 -0
- roborock_cli/_vendor/roborock/util.py +54 -0
- roborock_cli/_vendor/roborock/web_api.py +761 -0
- roborock_cli/cli.py +715 -0
- roborock_cli/connection.py +202 -0
- roborock_cli/helpers.py +71 -0
- roborock_cli/server.py +759 -0
- roborock_cli/setup_auth.py +92 -0
- roborock_cli-0.1.1.dist-info/METADATA +172 -0
- roborock_cli-0.1.1.dist-info/RECORD +106 -0
- roborock_cli-0.1.1.dist-info/WHEEL +4 -0
- roborock_cli-0.1.1.dist-info/entry_points.txt +2 -0
- roborock_cli-0.1.1.dist-info/licenses/LICENSE +674 -0
roborock_cli/__init__.py
ADDED
roborock_cli/__main__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI 入口
|
|
3
|
+
|
|
4
|
+
主入口模式:
|
|
5
|
+
roborock-cli → 显示帮助(CLI 为主)
|
|
6
|
+
roborock-cli auth → 交互式认证
|
|
7
|
+
roborock-cli devices → 列出设备
|
|
8
|
+
roborock-cli status → 获取状态
|
|
9
|
+
...
|
|
10
|
+
roborock-cli mcp → 启动 MCP stdio server(降级为子命令)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main():
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="roborock-cli",
|
|
22
|
+
description="Roborock 扫地机控制工具",
|
|
23
|
+
epilog="使用 'roborock-cli <command> --help' 查看子命令详情",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("--version", "-V", action="version", version=f"%(prog)s {__version__}")
|
|
26
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
27
|
+
|
|
28
|
+
# CLI 子命令(主入口)
|
|
29
|
+
from .cli import add_cli_subparsers
|
|
30
|
+
add_cli_subparsers(subparsers)
|
|
31
|
+
|
|
32
|
+
# MCP 子命令(降级)
|
|
33
|
+
mcp_parser = subparsers.add_parser("mcp", help="启动 MCP stdio server")
|
|
34
|
+
mcp_parser.set_defaults(func=cmd_mcp)
|
|
35
|
+
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
|
|
38
|
+
if args.command is None:
|
|
39
|
+
# 无参数时显示帮助
|
|
40
|
+
parser.print_help()
|
|
41
|
+
sys.exit(0)
|
|
42
|
+
elif hasattr(args, "func"):
|
|
43
|
+
# 执行子命令
|
|
44
|
+
from .cli import CLIError, format_error
|
|
45
|
+
try:
|
|
46
|
+
args.func(args)
|
|
47
|
+
except CLIError as e:
|
|
48
|
+
print(format_error(e))
|
|
49
|
+
sys.exit(e.exit_code)
|
|
50
|
+
except KeyboardInterrupt:
|
|
51
|
+
sys.exit(130)
|
|
52
|
+
else:
|
|
53
|
+
parser.error(f"未知子命令: {args.command}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_mcp(args):
|
|
57
|
+
"""启动 MCP stdio server"""
|
|
58
|
+
import anyio
|
|
59
|
+
from .server import mcp
|
|
60
|
+
|
|
61
|
+
# Windows 的 ProactorEventLoop 不支持 add_reader/add_writer,
|
|
62
|
+
# 而 aiomqtt (paho-mqtt) 依赖这些方法。
|
|
63
|
+
# mcp.run() 内部调用 anyio.run() 但不传 loop_factory,
|
|
64
|
+
# 所以直接调用 anyio.run() 显式指定 SelectorEventLoop。
|
|
65
|
+
# MCP stdio transport 用 anyio.wrap_file()(线程模式),不受影响。
|
|
66
|
+
if sys.platform == "win32":
|
|
67
|
+
import asyncio
|
|
68
|
+
backend_options = {"loop_factory": asyncio.SelectorEventLoop}
|
|
69
|
+
else:
|
|
70
|
+
backend_options = {}
|
|
71
|
+
|
|
72
|
+
anyio.run(mcp.run_stdio_async, backend_options=backend_options)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Roborock API.
|
|
2
|
+
|
|
3
|
+
.. include:: ../README.md
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from roborock_cli._vendor.roborock.data import *
|
|
7
|
+
from roborock_cli._vendor.roborock.exceptions import *
|
|
8
|
+
from roborock_cli._vendor.roborock.roborock_typing import *
|
|
9
|
+
|
|
10
|
+
from . import (
|
|
11
|
+
const,
|
|
12
|
+
data,
|
|
13
|
+
devices,
|
|
14
|
+
exceptions,
|
|
15
|
+
roborock_typing,
|
|
16
|
+
web_api,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"devices",
|
|
21
|
+
"data",
|
|
22
|
+
"map",
|
|
23
|
+
"web_api",
|
|
24
|
+
"roborock_typing",
|
|
25
|
+
"exceptions",
|
|
26
|
+
"const",
|
|
27
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from asyncio import BaseTransport, Lock
|
|
8
|
+
|
|
9
|
+
from construct import ( # type: ignore
|
|
10
|
+
Bytes,
|
|
11
|
+
Checksum,
|
|
12
|
+
GreedyBytes,
|
|
13
|
+
Int16ub,
|
|
14
|
+
Int32ub,
|
|
15
|
+
Prefixed,
|
|
16
|
+
RawCopy,
|
|
17
|
+
Struct,
|
|
18
|
+
)
|
|
19
|
+
from Crypto.Cipher import AES
|
|
20
|
+
|
|
21
|
+
from roborock_cli._vendor.roborock import RoborockException
|
|
22
|
+
from roborock_cli._vendor.roborock.data import BroadcastMessage
|
|
23
|
+
from roborock_cli._vendor.roborock.protocol import EncryptionAdapter, Utils, _Parser
|
|
24
|
+
|
|
25
|
+
_LOGGER = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
BROADCAST_TOKEN = b"qWKYcdQWrbm9hPqe"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RoborockProtocol(asyncio.DatagramProtocol):
|
|
31
|
+
def __init__(self, timeout: int = 5):
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
self.transport: BaseTransport | None = None
|
|
34
|
+
self.devices_found: list[BroadcastMessage] = []
|
|
35
|
+
self._mutex = Lock()
|
|
36
|
+
|
|
37
|
+
def datagram_received(self, data: bytes, _):
|
|
38
|
+
"""Handle incoming broadcast datagrams."""
|
|
39
|
+
try:
|
|
40
|
+
version = data[:3]
|
|
41
|
+
if version == b"L01":
|
|
42
|
+
[parsed_msg], _ = L01Parser.parse(data)
|
|
43
|
+
encrypted_payload = parsed_msg.payload
|
|
44
|
+
if encrypted_payload is None:
|
|
45
|
+
raise RoborockException("No encrypted payload found in broadcast message")
|
|
46
|
+
ciphertext = encrypted_payload[:-16]
|
|
47
|
+
tag = encrypted_payload[-16:]
|
|
48
|
+
|
|
49
|
+
key = hashlib.sha256(BROADCAST_TOKEN).digest()
|
|
50
|
+
iv_digest_input = data[:9]
|
|
51
|
+
digest = hashlib.sha256(iv_digest_input).digest()
|
|
52
|
+
iv = digest[:12]
|
|
53
|
+
|
|
54
|
+
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
|
|
55
|
+
decrypted_payload_bytes = cipher.decrypt_and_verify(ciphertext, tag)
|
|
56
|
+
json_payload = json.loads(decrypted_payload_bytes)
|
|
57
|
+
parsed_message = BroadcastMessage(duid=json_payload["duid"], ip=json_payload["ip"], version=version)
|
|
58
|
+
_LOGGER.debug(f"Received L01 broadcast: {parsed_message}")
|
|
59
|
+
self.devices_found.append(parsed_message)
|
|
60
|
+
else:
|
|
61
|
+
# Fallback to the original protocol parser for other versions
|
|
62
|
+
[broadcast_message], _ = BroadcastParser.parse(data)
|
|
63
|
+
if broadcast_message.payload:
|
|
64
|
+
json_payload = json.loads(broadcast_message.payload)
|
|
65
|
+
parsed_message = BroadcastMessage(duid=json_payload["duid"], ip=json_payload["ip"], version=version)
|
|
66
|
+
_LOGGER.debug(f"Received broadcast: {parsed_message}")
|
|
67
|
+
self.devices_found.append(parsed_message)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
_LOGGER.warning(f"Failed to decode message: {data!r}. Error: {e}")
|
|
70
|
+
|
|
71
|
+
async def discover(self) -> list[BroadcastMessage]:
|
|
72
|
+
async with self._mutex:
|
|
73
|
+
try:
|
|
74
|
+
loop = asyncio.get_event_loop()
|
|
75
|
+
self.transport, _ = await loop.create_datagram_endpoint(lambda: self, local_addr=("0.0.0.0", 58866))
|
|
76
|
+
await asyncio.sleep(self.timeout)
|
|
77
|
+
return self.devices_found
|
|
78
|
+
finally:
|
|
79
|
+
self.close()
|
|
80
|
+
self.devices_found = []
|
|
81
|
+
|
|
82
|
+
def close(self):
|
|
83
|
+
self.transport.close() if self.transport else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_BroadcastMessage = Struct(
|
|
87
|
+
"message"
|
|
88
|
+
/ RawCopy(
|
|
89
|
+
Struct(
|
|
90
|
+
"version" / Bytes(3),
|
|
91
|
+
"seq" / Int32ub,
|
|
92
|
+
"protocol" / Int16ub,
|
|
93
|
+
"payload" / EncryptionAdapter(lambda ctx: BROADCAST_TOKEN),
|
|
94
|
+
)
|
|
95
|
+
),
|
|
96
|
+
"checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
_L01BroadcastMessage = Struct(
|
|
100
|
+
"message"
|
|
101
|
+
/ RawCopy(
|
|
102
|
+
Struct(
|
|
103
|
+
"version" / Bytes(3),
|
|
104
|
+
"field1" / Bytes(4), # Unknown field
|
|
105
|
+
"field2" / Bytes(2), # Unknown field
|
|
106
|
+
"payload" / Prefixed(Int16ub, GreedyBytes), # Encrypted payload with length prefix
|
|
107
|
+
)
|
|
108
|
+
),
|
|
109
|
+
"checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
BroadcastParser: _Parser = _Parser(_BroadcastMessage, False)
|
|
114
|
+
L01Parser: _Parser = _Parser(_L01BroadcastMessage, False)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Module for managing callback utility functions."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
_LOGGER = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
K = TypeVar("K")
|
|
10
|
+
V = TypeVar("V")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def safe_callback(
|
|
14
|
+
callback: Callable[[V], None], logger: logging.Logger | logging.LoggerAdapter | None = None
|
|
15
|
+
) -> Callable[[V], None]:
|
|
16
|
+
"""Wrap a callback to catch and log exceptions.
|
|
17
|
+
|
|
18
|
+
This is useful for ensuring that errors in callbacks do not propagate
|
|
19
|
+
and cause unexpected behavior. Any failures during callback execution will be logged.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
if logger is None:
|
|
23
|
+
logger = _LOGGER
|
|
24
|
+
|
|
25
|
+
def wrapper(value: V) -> None:
|
|
26
|
+
try:
|
|
27
|
+
callback(value)
|
|
28
|
+
except Exception as ex: # noqa: BLE001
|
|
29
|
+
logger.error("Uncaught error in callback '%s': %s", callback.__name__, ex)
|
|
30
|
+
|
|
31
|
+
return wrapper
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CallbackMap(Generic[K, V]):
|
|
35
|
+
"""A mapping of callbacks for specific keys.
|
|
36
|
+
|
|
37
|
+
This allows for registering multiple callbacks for different keys and invoking them
|
|
38
|
+
when a value is received for a specific key.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, logger: logging.Logger | logging.LoggerAdapter | None = None) -> None:
|
|
42
|
+
self._callbacks: dict[K, list[Callable[[V], None]]] = {}
|
|
43
|
+
self._logger = logger or _LOGGER
|
|
44
|
+
|
|
45
|
+
def keys(self) -> list[K]:
|
|
46
|
+
"""Get all keys in the callback map."""
|
|
47
|
+
return list(self._callbacks.keys())
|
|
48
|
+
|
|
49
|
+
def add_callback(self, key: K, callback: Callable[[V], None]) -> Callable[[], None]:
|
|
50
|
+
"""Add a callback for a specific key.
|
|
51
|
+
|
|
52
|
+
Any failures during callback execution will be logged.
|
|
53
|
+
|
|
54
|
+
Returns a callable that can be used to remove the callback.
|
|
55
|
+
"""
|
|
56
|
+
self._callbacks.setdefault(key, []).append(callback)
|
|
57
|
+
|
|
58
|
+
def remove_callback() -> None:
|
|
59
|
+
"""Remove the callback for the specific key."""
|
|
60
|
+
if cb_list := self._callbacks.get(key):
|
|
61
|
+
cb_list.remove(callback)
|
|
62
|
+
if not cb_list:
|
|
63
|
+
del self._callbacks[key]
|
|
64
|
+
|
|
65
|
+
return remove_callback
|
|
66
|
+
|
|
67
|
+
def get_callbacks(self, key: K) -> list[Callable[[V], None]]:
|
|
68
|
+
"""Get all callbacks for a specific key."""
|
|
69
|
+
return self._callbacks.get(key, [])
|
|
70
|
+
|
|
71
|
+
def __call__(self, key: K, value: V) -> None:
|
|
72
|
+
"""Invoke all callbacks for a specific key."""
|
|
73
|
+
for callback in self.get_callbacks(key):
|
|
74
|
+
safe_callback(callback, self._logger)(value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CallbackList(Generic[V]):
|
|
78
|
+
"""A list of callbacks that can be invoked.
|
|
79
|
+
|
|
80
|
+
This combines a list of callbacks into a single callable. Callers can add
|
|
81
|
+
additional callbacks to the list at any time.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, logger: logging.Logger | logging.LoggerAdapter | None = None) -> None:
|
|
85
|
+
self._callbacks: list[Callable[[V], None]] = []
|
|
86
|
+
self._logger = logger or _LOGGER
|
|
87
|
+
|
|
88
|
+
def add_callback(self, callback: Callable[[V], None]) -> Callable[[], None]:
|
|
89
|
+
"""Add a callback to the list.
|
|
90
|
+
|
|
91
|
+
Any failures during callback execution will be logged.
|
|
92
|
+
|
|
93
|
+
Returns a callable that can be used to remove the callback.
|
|
94
|
+
"""
|
|
95
|
+
self._callbacks.append(callback)
|
|
96
|
+
|
|
97
|
+
return lambda: self._callbacks.remove(callback)
|
|
98
|
+
|
|
99
|
+
def __call__(self, value: V) -> None:
|
|
100
|
+
"""Invoke all callbacks in the list."""
|
|
101
|
+
for callback in self._callbacks:
|
|
102
|
+
safe_callback(callback, self._logger)(value)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def decoder_callback(
|
|
106
|
+
decoder: Callable[[K], list[V]],
|
|
107
|
+
callback: Callable[[V], None],
|
|
108
|
+
logger: logging.Logger | logging.LoggerAdapter | None = None,
|
|
109
|
+
) -> Callable[[K], None]:
|
|
110
|
+
"""Create a callback that decodes messages using a decoder and invokes a callback.
|
|
111
|
+
|
|
112
|
+
The decoder converts a value into a list of values. The callback is then invoked
|
|
113
|
+
for each value in the list.
|
|
114
|
+
|
|
115
|
+
Any failures during decoding or invoking the callbacks will be logged.
|
|
116
|
+
"""
|
|
117
|
+
if logger is None:
|
|
118
|
+
logger = _LOGGER
|
|
119
|
+
|
|
120
|
+
safe_cb = safe_callback(callback, logger)
|
|
121
|
+
|
|
122
|
+
def wrapper(data: K) -> None:
|
|
123
|
+
if not (messages := decoder(data)):
|
|
124
|
+
logger.debug("Failed to decode message: %s", data)
|
|
125
|
+
return
|
|
126
|
+
for message in messages:
|
|
127
|
+
logger.debug("Decoded message: %s", message)
|
|
128
|
+
safe_cb(message)
|
|
129
|
+
|
|
130
|
+
return wrapper
|