pylumencache 0.0.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.
- pylumencache/__init__.py +13 -0
- pylumencache/api.py +73 -0
- pylumencache/domain/__init__.py +1 -0
- pylumencache/domain/commands.py +20 -0
- pylumencache/domain/events.py +48 -0
- pylumencache/domain/model.py +178 -0
- pylumencache/obfuscate.py +84 -0
- pylumencache/py.typed +0 -0
- pylumencache/transport.py +46 -0
- pylumencache/transports/__init__.py +1 -0
- pylumencache/transports/fallback.py +181 -0
- pylumencache/transports/mqtt/__init__.py +4 -0
- pylumencache/transports/mqtt/adapter.py +59 -0
- pylumencache/transports/mqtt/client.py +200 -0
- pylumencache/transports/mqtt/fake.py +118 -0
- pylumencache/transports/mqtt/wire/__init__.py +1 -0
- pylumencache/transports/mqtt/wire/codes.py +27 -0
- pylumencache/transports/mqtt/wire/commands.py +192 -0
- pylumencache/transports/mqtt/wire/models.py +219 -0
- pylumencache/transports/mqtt/wire/parser.py +136 -0
- pylumencache-0.0.1.dist-info/METADATA +165 -0
- pylumencache-0.0.1.dist-info/RECORD +24 -0
- pylumencache-0.0.1.dist-info/WHEEL +4 -0
- pylumencache-0.0.1.dist-info/licenses/LICENSE +202 -0
pylumencache/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Unofficial Python client for LumenCache ReNetA lighting."""
|
|
2
|
+
from .api import LumenCache
|
|
3
|
+
from .domain.commands import Command, SetLight
|
|
4
|
+
from .domain.events import Event
|
|
5
|
+
from .domain.model import Category, Change, Device, LightState, SystemModel
|
|
6
|
+
from .transport import NotConnectedError, Transport, UnsupportedError
|
|
7
|
+
|
|
8
|
+
__version__ = "0.0.1"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Category", "Change", "Command", "Device", "Event", "LightState", "LumenCache", "NotConnectedError",
|
|
12
|
+
"SetLight", "SystemModel", "Transport", "UnsupportedError",
|
|
13
|
+
]
|
pylumencache/api.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""The public entry point: one ReNetA system, reached through a transport.
|
|
2
|
+
|
|
3
|
+
cache = LumenCache.mqtt("broker.example.com", "<Prefix Topic>", username=..., password=...)
|
|
4
|
+
cache.model.add_listener(on_change)
|
|
5
|
+
await cache.start()
|
|
6
|
+
await cache.wait_connected()
|
|
7
|
+
await cache.send(SetLight("zone", 250, brightness=50))
|
|
8
|
+
|
|
9
|
+
The facade owns the SystemModel and keeps it fed from the transport. A caller never needs
|
|
10
|
+
to know which transport is underneath.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .domain.commands import Command
|
|
18
|
+
from .domain.model import SystemModel
|
|
19
|
+
from .transport import Transport, UnsupportedError
|
|
20
|
+
from .transports.fallback import DEFAULT_FAIL_AFTER, FallbackTransport
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LumenCache:
|
|
24
|
+
def __init__(self, transport: Transport, model: SystemModel | None = None) -> None:
|
|
25
|
+
self.transport = transport
|
|
26
|
+
self.model = model if model is not None else SystemModel()
|
|
27
|
+
self.model.attach(transport)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def mqtt(cls, host: str, prefix: str, *, model: SystemModel | None = None, **options: Any) -> LumenCache:
|
|
31
|
+
"""Over the hosted MQTT broker. `options` are those of MqttTransport (port, username, ...)."""
|
|
32
|
+
from .transports.mqtt import MqttTransport
|
|
33
|
+
return cls(MqttTransport(host, prefix, **options), model)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def with_fallback(
|
|
37
|
+
cls,
|
|
38
|
+
transports: Sequence[Transport],
|
|
39
|
+
*,
|
|
40
|
+
fail_after: float = DEFAULT_FAIL_AFTER,
|
|
41
|
+
model: SystemModel | None = None,
|
|
42
|
+
) -> LumenCache:
|
|
43
|
+
"""Several transports in priority order. A later one is connected only while the earlier ones are down.
|
|
44
|
+
|
|
45
|
+
`fail_after` is how many seconds without a connection before the next one is started.
|
|
46
|
+
"""
|
|
47
|
+
return cls(FallbackTransport(transports, fail_after=fail_after), model)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def connected(self) -> bool:
|
|
51
|
+
return self.transport.connected
|
|
52
|
+
|
|
53
|
+
def add_connection_listener(self, callback: Callable[[bool], Any]) -> Callable[[], None]:
|
|
54
|
+
return self.transport.add_connection_listener(callback)
|
|
55
|
+
|
|
56
|
+
async def start(self) -> None:
|
|
57
|
+
await self.transport.start()
|
|
58
|
+
|
|
59
|
+
async def stop(self) -> None:
|
|
60
|
+
await self.transport.stop()
|
|
61
|
+
|
|
62
|
+
async def wait_connected(self, timeout: float | None = None) -> None:
|
|
63
|
+
await self.transport.wait_connected(timeout)
|
|
64
|
+
|
|
65
|
+
async def send(self, command: Command) -> None:
|
|
66
|
+
await self.transport.send(command)
|
|
67
|
+
|
|
68
|
+
async def send_raw(self, message: dict[str, Any]) -> None:
|
|
69
|
+
"""Send a command in the transport's own format, as a fallback. Not every transport has one."""
|
|
70
|
+
send_raw = getattr(self.transport, "send_raw", None)
|
|
71
|
+
if send_raw is None:
|
|
72
|
+
raise UnsupportedError("this transport has no raw commands")
|
|
73
|
+
await send_raw(message)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Transport-independent model, events and commands."""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""What a caller asks of a system, independent of any wire format."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .events import LightKind
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class SetLight:
|
|
11
|
+
"""Set a zone or a fixture. A brightness of 0 turns it off."""
|
|
12
|
+
|
|
13
|
+
kind: LightKind
|
|
14
|
+
key: int
|
|
15
|
+
brightness: float # percent, 0 to 100
|
|
16
|
+
color_temperature: int | None = None # kelvin
|
|
17
|
+
fade: int | None = None # ms
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
Command = SetLight
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""What a transport tells the domain model, independent of any wire format."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
LightKind = Literal["zone", "fixture"]
|
|
8
|
+
Source = Literal["event", "command", "unknown"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class DeviceAnnounced:
|
|
13
|
+
"""A device exists. `path` is where the system placed it in its device tree."""
|
|
14
|
+
|
|
15
|
+
gid: int
|
|
16
|
+
path: str
|
|
17
|
+
name: str | None = None
|
|
18
|
+
hsn: str | None = None
|
|
19
|
+
category: int | None = None
|
|
20
|
+
parent: int | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class DeviceSeen:
|
|
25
|
+
"""A device showed signs of life. `hsn` is its serial number, when the message carried it."""
|
|
26
|
+
|
|
27
|
+
gid: int
|
|
28
|
+
hsn: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class LightChanged:
|
|
33
|
+
"""A zone or fixture changed state. None means the message did not say.
|
|
34
|
+
|
|
35
|
+
`source` is "event" when the system reported it and "command" when it is a command as
|
|
36
|
+
sent, which the system does not confirm.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
kind: LightKind
|
|
40
|
+
key: int
|
|
41
|
+
on: bool | None = None
|
|
42
|
+
brightness: float | None = None # percent, 0 to 100
|
|
43
|
+
color_temperature: int | None = None # kelvin
|
|
44
|
+
fade: int | None = None # ms
|
|
45
|
+
source: Source = "unknown"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
Event = DeviceAnnounced | DeviceSeen | LightChanged
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""What is in the system and what state it is in, built from domain events.
|
|
2
|
+
|
|
3
|
+
The system does not report the state of a light when asked, and it does not keep its
|
|
4
|
+
inventory on the broker (nothing is retained). So the model learns from what passes by:
|
|
5
|
+
|
|
6
|
+
* devices come from announcements, which the system publishes once after it connects.
|
|
7
|
+
Use to_dict()/from_dict() to keep them across restarts;
|
|
8
|
+
* a zone or light changes state when a wall switch reports it or when any client,
|
|
9
|
+
including this one, sends a control command that the transport sees. Those states are
|
|
10
|
+
the commands as sent. The system does not confirm that they happened;
|
|
11
|
+
* signs of life show which devices are alive.
|
|
12
|
+
|
|
13
|
+
Fixtures that belong to a zone are not updated when the zone changes, because the
|
|
14
|
+
membership of a zone cannot be read from the system.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import asdict, dataclass, field
|
|
22
|
+
from enum import IntEnum
|
|
23
|
+
from typing import Any, Protocol
|
|
24
|
+
|
|
25
|
+
from .events import DeviceAnnounced, DeviceSeen, Event, LightChanged
|
|
26
|
+
|
|
27
|
+
_LOGGER = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EventSource(Protocol):
|
|
31
|
+
def add_listener(self, callback: Callable[[Event], Any]) -> Callable[[], None]: ...
|
|
32
|
+
|
|
33
|
+
NO_PARENT = 65535
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Category(IntEnum):
|
|
37
|
+
"""Device category (`cat`). Other values are possible and are kept as plain integers."""
|
|
38
|
+
|
|
39
|
+
POWERPLANE = 32
|
|
40
|
+
PDM = 35
|
|
41
|
+
SOCKET = 36
|
|
42
|
+
SOCKET_CARD = 37
|
|
43
|
+
WALL_SWITCH = 40
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Device:
|
|
48
|
+
gid: int
|
|
49
|
+
path: str
|
|
50
|
+
name: str | None = None
|
|
51
|
+
hsn: str | None = None
|
|
52
|
+
category: int | None = None
|
|
53
|
+
parent: int | None = None # a GID, NO_PARENT for none, 0 not yet assigned
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def kind(self) -> Category | int | None:
|
|
57
|
+
if self.category is None:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
return Category(self.category)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return self.category
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class LightState:
|
|
67
|
+
"""The last known state of a zone or fixture. None means not known."""
|
|
68
|
+
|
|
69
|
+
on: bool | None = None
|
|
70
|
+
brightness: float | None = None # percent, 0 to 100
|
|
71
|
+
color_temperature: int | None = None # kelvin
|
|
72
|
+
fade: int | None = None # ms, from the last command
|
|
73
|
+
source: str = "unknown" # "event" (reported by the system) or "command" (as sent, not confirmed)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Change:
|
|
78
|
+
"""Passed to listeners. `kind` is "device", "zone", "fixture" or "seen"; `key` is the id."""
|
|
79
|
+
|
|
80
|
+
kind: str
|
|
81
|
+
key: int
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class SystemModel:
|
|
86
|
+
devices: dict[int, Device] = field(default_factory=dict)
|
|
87
|
+
zones: dict[int, LightState] = field(default_factory=dict)
|
|
88
|
+
fixtures: dict[int, LightState] = field(default_factory=dict)
|
|
89
|
+
last_seen: dict[int, float] = field(default_factory=dict) # GID -> time.monotonic() of the last sign of life
|
|
90
|
+
_listeners: list[Callable[[Change], Any]] = field(default_factory=list, repr=False)
|
|
91
|
+
|
|
92
|
+
# --- reading -----------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def children(self, gid: int) -> list[Device]:
|
|
95
|
+
return [d for d in self.devices.values() if d.parent == gid]
|
|
96
|
+
|
|
97
|
+
def seconds_since_seen(self, gid: int) -> float | None:
|
|
98
|
+
seen = self.last_seen.get(gid)
|
|
99
|
+
return None if seen is None else time.monotonic() - seen
|
|
100
|
+
|
|
101
|
+
# --- updating ----------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def add_listener(self, callback: Callable[[Change], Any]) -> Callable[[], None]:
|
|
104
|
+
self._listeners.append(callback)
|
|
105
|
+
return lambda: self._listeners.remove(callback)
|
|
106
|
+
|
|
107
|
+
def attach(self, source: EventSource) -> Callable[[], None]:
|
|
108
|
+
"""Feed the model from anything with `add_listener(callback)`, such as a Transport."""
|
|
109
|
+
return source.add_listener(self.apply)
|
|
110
|
+
|
|
111
|
+
def apply(self, event: Event) -> None:
|
|
112
|
+
"""Update from one event."""
|
|
113
|
+
if isinstance(event, DeviceAnnounced):
|
|
114
|
+
self._device(event)
|
|
115
|
+
elif isinstance(event, DeviceSeen):
|
|
116
|
+
self._seen(event)
|
|
117
|
+
elif isinstance(event, LightChanged):
|
|
118
|
+
table = self.zones if event.kind == "zone" else self.fixtures
|
|
119
|
+
self._set_state(table, event.kind, event.key, LightState(
|
|
120
|
+
event.on, event.brightness, event.color_temperature, event.fade, event.source))
|
|
121
|
+
|
|
122
|
+
def _notify(self, kind: str, key: int) -> None:
|
|
123
|
+
for callback in list(self._listeners):
|
|
124
|
+
try:
|
|
125
|
+
callback(Change(kind, key))
|
|
126
|
+
except Exception: # a listener must not break the model
|
|
127
|
+
_LOGGER.exception("A model listener raised an exception")
|
|
128
|
+
|
|
129
|
+
def _device(self, event: DeviceAnnounced) -> None:
|
|
130
|
+
known = self.devices.get(event.gid)
|
|
131
|
+
hsn = event.hsn or (known.hsn if known else None)
|
|
132
|
+
device = Device(event.gid, event.path, event.name, hsn, event.category, event.parent)
|
|
133
|
+
if device != known:
|
|
134
|
+
self.devices[event.gid] = device
|
|
135
|
+
self._notify("device", event.gid)
|
|
136
|
+
|
|
137
|
+
def _seen(self, event: DeviceSeen) -> None:
|
|
138
|
+
self.last_seen[event.gid] = time.monotonic()
|
|
139
|
+
device = self.devices.get(event.gid)
|
|
140
|
+
if device and event.hsn and not device.hsn: # the PowerPlane's announcement has no serial
|
|
141
|
+
self.devices[event.gid] = Device(
|
|
142
|
+
device.gid, device.path, device.name, event.hsn, device.category, device.parent)
|
|
143
|
+
self._notify("device", event.gid)
|
|
144
|
+
self._notify("seen", event.gid)
|
|
145
|
+
|
|
146
|
+
def _set_state(self, table: dict[int, LightState], kind: str, key: int, new: LightState) -> None:
|
|
147
|
+
"""Merge `new` into the stored state: fields the message did not carry keep their value."""
|
|
148
|
+
old = table.get(key, LightState())
|
|
149
|
+
merged = LightState(
|
|
150
|
+
on=old.on if new.on is None else new.on,
|
|
151
|
+
brightness=old.brightness if new.brightness is None else new.brightness,
|
|
152
|
+
color_temperature=old.color_temperature if new.color_temperature is None else new.color_temperature,
|
|
153
|
+
fade=old.fade if new.fade is None else new.fade,
|
|
154
|
+
source=new.source,
|
|
155
|
+
)
|
|
156
|
+
if merged.on and merged.brightness == 0:
|
|
157
|
+
merged = LightState(True, None, merged.color_temperature, merged.fade, merged.source)
|
|
158
|
+
if merged != old:
|
|
159
|
+
table[key] = merged
|
|
160
|
+
self._notify(kind, key)
|
|
161
|
+
|
|
162
|
+
# --- keeping it across restarts ---------------------------------------
|
|
163
|
+
|
|
164
|
+
def to_dict(self) -> dict[str, Any]:
|
|
165
|
+
"""JSON-able copy of the devices and the last known states (not heartbeats)."""
|
|
166
|
+
return {
|
|
167
|
+
"devices": [asdict(d) for d in self.devices.values()],
|
|
168
|
+
"zones": {str(k): asdict(v) for k, v in self.zones.items()},
|
|
169
|
+
"fixtures": {str(k): asdict(v) for k, v in self.fixtures.items()},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_dict(cls, data: dict[str, Any]) -> SystemModel:
|
|
174
|
+
model = cls()
|
|
175
|
+
model.devices = {d["gid"]: Device(**d) for d in data.get("devices", [])}
|
|
176
|
+
model.zones = {int(k): LightState(**v) for k, v in data.get("zones", {}).items()}
|
|
177
|
+
model.fixtures = {int(k): LightState(**v) for k, v in data.get("fixtures", {}).items()}
|
|
178
|
+
return model
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Make captured traffic safe to share: stable fakes for ids, blanked free text.
|
|
2
|
+
|
|
3
|
+
Used by the capture tool for the files it writes and by tools/watch.py for the screen.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Obfuscator:
|
|
12
|
+
"""Make a capture safe to share: stable fakes for ids, blanked free text.
|
|
13
|
+
|
|
14
|
+
Within one run the same real value always gets the same fake, so the
|
|
15
|
+
relationships between messages survive. The mapping is kept in memory only.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
ID_KEYS = {"hsn": "HSN", "sys": "SYS"}
|
|
19
|
+
FREE_TEXT = {"name", "addr1", "addr2", "zip", "phone", "note"}
|
|
20
|
+
FREE_TEXT_PARENTS = {"sys_info", "org_info"} # inside "meta"; dev names stay
|
|
21
|
+
SUFFIX = re.compile(r"^(.+?)(_S\d+)$") # socket hsn = PDM hsn + _S1.._S8
|
|
22
|
+
MAC = re.compile(r"\b[0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5}\b")
|
|
23
|
+
|
|
24
|
+
def __init__(self, prefix: str) -> None:
|
|
25
|
+
self.prefix = prefix
|
|
26
|
+
self.fakes: dict[tuple[str, str], str] = {} # real value -> fake
|
|
27
|
+
|
|
28
|
+
def _fake(self, kind: str, real: str) -> str:
|
|
29
|
+
key = (kind, real)
|
|
30
|
+
if key not in self.fakes:
|
|
31
|
+
n = sum(1 for k in self.fakes if k[0] == kind) + 1
|
|
32
|
+
self.fakes[key] = f"{kind}{n:04d}" if kind != "MAC" else \
|
|
33
|
+
f"02:00:00:00:{n >> 8 & 255:02x}:{n & 255:02x}"
|
|
34
|
+
return self.fakes[key]
|
|
35
|
+
|
|
36
|
+
def _id(self, kind: str, value: Any) -> str:
|
|
37
|
+
real = str(value)
|
|
38
|
+
m = self.SUFFIX.match(real)
|
|
39
|
+
if m:
|
|
40
|
+
return self._fake(kind, m.group(1)) + m.group(2)
|
|
41
|
+
return self._fake(kind, real)
|
|
42
|
+
|
|
43
|
+
def text(self, value: str) -> str:
|
|
44
|
+
"""Scrub free-form text: the prefix, known ids and MAC-shaped strings."""
|
|
45
|
+
value = value.replace(self.prefix, "<prefix>")
|
|
46
|
+
for (kind, real), fake in sorted(self.fakes.items(), key=lambda kv: -len(kv[0][1])):
|
|
47
|
+
if kind != "MAC" and real:
|
|
48
|
+
value = value.replace(real, fake)
|
|
49
|
+
return self.MAC.sub(lambda m: self._fake("MAC", m.group(0).lower()), value)
|
|
50
|
+
|
|
51
|
+
def _learn(self, node: Any) -> None:
|
|
52
|
+
if isinstance(node, dict):
|
|
53
|
+
for k, v in node.items():
|
|
54
|
+
if k in self.ID_KEYS and not isinstance(v, (dict, list)):
|
|
55
|
+
self._id(self.ID_KEYS[k], v)
|
|
56
|
+
else:
|
|
57
|
+
self._learn(v)
|
|
58
|
+
elif isinstance(node, list):
|
|
59
|
+
for v in node:
|
|
60
|
+
self._learn(v)
|
|
61
|
+
|
|
62
|
+
def _walk(self, node: Any, parent: str | None = None, in_meta: bool = False) -> Any:
|
|
63
|
+
if isinstance(node, dict):
|
|
64
|
+
result: dict[str, Any] = {}
|
|
65
|
+
for k, v in node.items():
|
|
66
|
+
if k in self.ID_KEYS and not isinstance(v, (dict, list)):
|
|
67
|
+
result[k] = self._id(self.ID_KEYS[k], v)
|
|
68
|
+
elif (in_meta and parent in self.FREE_TEXT_PARENTS
|
|
69
|
+
and k in self.FREE_TEXT and v not in ("", None)):
|
|
70
|
+
result[k] = ""
|
|
71
|
+
else:
|
|
72
|
+
result[k] = self._walk(v, k, in_meta or k == "meta")
|
|
73
|
+
return result
|
|
74
|
+
if isinstance(node, list):
|
|
75
|
+
return [self._walk(v, parent, in_meta) for v in node]
|
|
76
|
+
if isinstance(node, str):
|
|
77
|
+
return self.text(node)
|
|
78
|
+
return node
|
|
79
|
+
|
|
80
|
+
def payload(self, node: Any) -> Any:
|
|
81
|
+
if isinstance(node, str):
|
|
82
|
+
return self.text(node)
|
|
83
|
+
self._learn(node)
|
|
84
|
+
return self._walk(node)
|
pylumencache/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The interface between the library and a way of reaching a ReNetA system.
|
|
2
|
+
|
|
3
|
+
A transport turns what its wire format carries into domain events and domain commands
|
|
4
|
+
into wire messages. Everything above it (the model, the facade, an integration) never
|
|
5
|
+
sees a topic, an event code or a URL.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any, Protocol
|
|
11
|
+
|
|
12
|
+
from .domain.commands import Command
|
|
13
|
+
from .domain.events import Event
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotConnectedError(ConnectionError):
|
|
17
|
+
"""A command was sent while the transport was not connected."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnsupportedError(NotImplementedError):
|
|
21
|
+
"""The transport cannot do what was asked."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Transport(Protocol):
|
|
25
|
+
@property
|
|
26
|
+
def connected(self) -> bool: ...
|
|
27
|
+
|
|
28
|
+
def add_listener(self, callback: Callable[[Event], Any]) -> Callable[[], None]:
|
|
29
|
+
"""Call `callback(event)` for each domain event. Returns a function that removes it."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def add_connection_listener(self, callback: Callable[[bool], Any]) -> Callable[[], None]:
|
|
33
|
+
"""Call `callback(True)` when connected and `callback(False)` when the connection is lost."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
async def start(self) -> None:
|
|
37
|
+
"""Start connecting in the background. Returns at once."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
async def stop(self) -> None: ...
|
|
41
|
+
|
|
42
|
+
async def wait_connected(self, timeout: float | None = None) -> None: ...
|
|
43
|
+
|
|
44
|
+
async def send(self, command: Command) -> None:
|
|
45
|
+
"""Raises NotConnectedError when not connected."""
|
|
46
|
+
...
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Ways of reaching a ReNetA system. Each subpackage implements pylumencache.transport.Transport."""
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Several transports in priority order, with the later ones used only when the earlier ones are down.
|
|
2
|
+
|
|
3
|
+
FallbackTransport([first, second])
|
|
4
|
+
|
|
5
|
+
Only the first transport is started at first. If it is not connected after `fail_after`
|
|
6
|
+
seconds, the second is started as well, and so on down the list. The earlier transports keep
|
|
7
|
+
trying to connect in the background, and as soon as one of them connects, the transports
|
|
8
|
+
after it are stopped. So a later transport is connected only while every earlier one is down.
|
|
9
|
+
|
|
10
|
+
Events come from the highest-priority transport that is connected, and commands go to it, so
|
|
11
|
+
the model never sees two sources at once. "Down" means not connected: a transport that is
|
|
12
|
+
connected but silent counts as up. The order is the order of the list.
|
|
13
|
+
|
|
14
|
+
This module knows nothing about any particular transport.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
from collections.abc import Callable, Sequence
|
|
21
|
+
from functools import partial
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ..domain.commands import Command
|
|
25
|
+
from ..domain.events import Event
|
|
26
|
+
from ..transport import NotConnectedError, Transport, UnsupportedError
|
|
27
|
+
|
|
28
|
+
_LOGGER = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
DEFAULT_FAIL_AFTER = 30.0 # seconds
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FallbackTransport:
|
|
34
|
+
"""Implements pylumencache.transport.Transport on top of an ordered list of transports."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, transports: Sequence[Transport], *, fail_after: float = DEFAULT_FAIL_AFTER) -> None:
|
|
37
|
+
if not transports:
|
|
38
|
+
raise ValueError("give at least one transport")
|
|
39
|
+
self._transports = list(transports)
|
|
40
|
+
self._fail_after = fail_after
|
|
41
|
+
self._started = 0 # how many, from the front of the list, are running
|
|
42
|
+
self._active: int | None = None
|
|
43
|
+
self._task: asyncio.Task[None] | None = None
|
|
44
|
+
self._connected = asyncio.Event()
|
|
45
|
+
self._changed = asyncio.Event()
|
|
46
|
+
self._listeners: list[Callable[[Event], Any]] = []
|
|
47
|
+
self._connection_listeners: list[Callable[[bool], Any]] = []
|
|
48
|
+
for index, transport in enumerate(self._transports):
|
|
49
|
+
transport.add_listener(partial(self._forward, index))
|
|
50
|
+
transport.add_connection_listener(partial(self._on_connection, index))
|
|
51
|
+
|
|
52
|
+
# --- Transport ----------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def connected(self) -> bool:
|
|
56
|
+
return self._connected.is_set()
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def transports(self) -> tuple[Transport, ...]:
|
|
60
|
+
"""The transports, in priority order."""
|
|
61
|
+
return tuple(self._transports)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def active(self) -> Transport | None:
|
|
65
|
+
"""The transport in use: the highest-priority one that is connected."""
|
|
66
|
+
index = self._first_connected()
|
|
67
|
+
return None if index is None else self._transports[index]
|
|
68
|
+
|
|
69
|
+
def add_listener(self, callback: Callable[[Event], Any]) -> Callable[[], None]:
|
|
70
|
+
self._listeners.append(callback)
|
|
71
|
+
return lambda: self._listeners.remove(callback)
|
|
72
|
+
|
|
73
|
+
def add_connection_listener(self, callback: Callable[[bool], Any]) -> Callable[[], None]:
|
|
74
|
+
"""Called with True when some transport connects and False when none is connected."""
|
|
75
|
+
self._connection_listeners.append(callback)
|
|
76
|
+
return lambda: self._connection_listeners.remove(callback)
|
|
77
|
+
|
|
78
|
+
async def start(self) -> None:
|
|
79
|
+
if self._task is None:
|
|
80
|
+
self._started = 1
|
|
81
|
+
await self._transports[0].start()
|
|
82
|
+
self._task = asyncio.create_task(self._supervise(), name="lumencache-fallback")
|
|
83
|
+
|
|
84
|
+
async def stop(self) -> None:
|
|
85
|
+
if self._task is not None:
|
|
86
|
+
self._task.cancel()
|
|
87
|
+
await asyncio.gather(self._task, return_exceptions=True)
|
|
88
|
+
self._task = None
|
|
89
|
+
for transport in self._transports[:self._started]:
|
|
90
|
+
await transport.stop()
|
|
91
|
+
self._started = 0
|
|
92
|
+
self._update()
|
|
93
|
+
|
|
94
|
+
async def wait_connected(self, timeout: float | None = None) -> None:
|
|
95
|
+
await asyncio.wait_for(self._connected.wait(), timeout)
|
|
96
|
+
|
|
97
|
+
async def send(self, command: Command) -> None:
|
|
98
|
+
await self._send_with(lambda transport: transport.send(command))
|
|
99
|
+
|
|
100
|
+
async def send_raw(self, message: dict[str, Any]) -> None:
|
|
101
|
+
"""Send a command in a transport's own format, to the first connected transport that has one."""
|
|
102
|
+
async def call(transport: Transport) -> None:
|
|
103
|
+
send_raw = getattr(transport, "send_raw", None)
|
|
104
|
+
if send_raw is None:
|
|
105
|
+
raise UnsupportedError("this transport has no raw commands")
|
|
106
|
+
await send_raw(message)
|
|
107
|
+
|
|
108
|
+
await self._send_with(call)
|
|
109
|
+
|
|
110
|
+
# --- internals ----------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
async def _send_with(self, call: Callable[[Transport], Any]) -> None:
|
|
113
|
+
unsupported = False
|
|
114
|
+
for transport in self._transports:
|
|
115
|
+
if not transport.connected:
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
await call(transport)
|
|
119
|
+
return
|
|
120
|
+
except UnsupportedError:
|
|
121
|
+
unsupported = True
|
|
122
|
+
if unsupported:
|
|
123
|
+
raise UnsupportedError("no connected transport can do this")
|
|
124
|
+
raise NotConnectedError("no transport is connected")
|
|
125
|
+
|
|
126
|
+
def _first_connected(self) -> int | None:
|
|
127
|
+
for index, transport in enumerate(self._transports):
|
|
128
|
+
if transport.connected:
|
|
129
|
+
return index
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def _forward(self, index: int, event: Event) -> None:
|
|
133
|
+
if index != self._first_connected():
|
|
134
|
+
return # only the transport in use speaks; a lower-priority one is on its way out
|
|
135
|
+
for callback in list(self._listeners):
|
|
136
|
+
self._call(callback, event)
|
|
137
|
+
|
|
138
|
+
def _on_connection(self, index: int, connected: bool) -> None:
|
|
139
|
+
self._update()
|
|
140
|
+
|
|
141
|
+
def _update(self) -> None:
|
|
142
|
+
active = self._first_connected()
|
|
143
|
+
if active != self._active:
|
|
144
|
+
_LOGGER.info("Using transport %s", "none" if active is None else type(self._transports[active]).__name__)
|
|
145
|
+
self._active = active
|
|
146
|
+
self._changed.set()
|
|
147
|
+
connected = active is not None
|
|
148
|
+
if connected != self._connected.is_set():
|
|
149
|
+
self._connected.set() if connected else self._connected.clear()
|
|
150
|
+
for callback in list(self._connection_listeners):
|
|
151
|
+
self._call(callback, connected)
|
|
152
|
+
|
|
153
|
+
def _call(self, callback: Callable[..., Any], *args: Any) -> None:
|
|
154
|
+
try:
|
|
155
|
+
callback(*args)
|
|
156
|
+
except Exception:
|
|
157
|
+
_LOGGER.exception("A listener raised an exception")
|
|
158
|
+
|
|
159
|
+
async def _supervise(self) -> None:
|
|
160
|
+
"""Start the next transport when all running ones stay down, stop the later ones when an earlier one is up."""
|
|
161
|
+
while True:
|
|
162
|
+
self._changed.clear()
|
|
163
|
+
first = self._first_connected()
|
|
164
|
+
if first is not None:
|
|
165
|
+
while self._started > first + 1:
|
|
166
|
+
self._started -= 1
|
|
167
|
+
_LOGGER.info("Stopping %s, a higher-priority transport is connected",
|
|
168
|
+
type(self._transports[self._started]).__name__)
|
|
169
|
+
await self._transports[self._started].stop()
|
|
170
|
+
await self._changed.wait()
|
|
171
|
+
elif self._started < len(self._transports):
|
|
172
|
+
try:
|
|
173
|
+
await asyncio.wait_for(self._changed.wait(), self._fail_after)
|
|
174
|
+
except TimeoutError:
|
|
175
|
+
transport = self._transports[self._started]
|
|
176
|
+
_LOGGER.warning("No transport connected after %s s, starting %s",
|
|
177
|
+
self._fail_after, type(transport).__name__)
|
|
178
|
+
self._started += 1
|
|
179
|
+
await transport.start()
|
|
180
|
+
else:
|
|
181
|
+
await self._changed.wait()
|