event-connect 0.1.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.
- event_connect/__init__.py +98 -0
- event_connect/_certainty.py +16 -0
- event_connect/_clock.py +31 -0
- event_connect/_errors.py +41 -0
- event_connect/_events.py +108 -0
- event_connect/_key.py +35 -0
- event_connect/_point.py +350 -0
- event_connect/_reachability.py +55 -0
- event_connect/_scheduler.py +337 -0
- event_connect/_status.py +19 -0
- event_connect/_timing.py +57 -0
- event_connect/_unit.py +77 -0
- event_connect/_value.py +54 -0
- event_connect/_writes.py +13 -0
- event_connect/py.typed +0 -0
- event_connect/testing/__init__.py +7 -0
- event_connect/testing/_clock.py +34 -0
- event_connect/testing/_measure.py +86 -0
- event_connect/testing/_point.py +24 -0
- event_connect-0.1.0.dist-info/METADATA +135 -0
- event_connect-0.1.0.dist-info/RECORD +24 -0
- event_connect-0.1.0.dist-info/WHEEL +5 -0
- event_connect-0.1.0.dist-info/licenses/LICENSE +21 -0
- event_connect-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Devices described by their points, independent of protocol: keys, values and their quality,
|
|
2
|
+
how each point behaves, when it is read, and what a write or a change sets off.
|
|
3
|
+
|
|
4
|
+
Nothing here performs I/O or knows a protocol; a protocol package builds its points and client
|
|
5
|
+
on these. Support for tests and for measuring a device's timing is in `event_connect.testing`.
|
|
6
|
+
"""
|
|
7
|
+
from importlib.metadata import version as _installed_version
|
|
8
|
+
|
|
9
|
+
from ._certainty import Certainty
|
|
10
|
+
from ._clock import Clock, SystemClock
|
|
11
|
+
from ._errors import (
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
CannotConnectError,
|
|
14
|
+
ClientError,
|
|
15
|
+
InvalidValueError,
|
|
16
|
+
ModelError,
|
|
17
|
+
NotConnectedError,
|
|
18
|
+
ReadOnlyError,
|
|
19
|
+
UnsupportedDeviceError,
|
|
20
|
+
)
|
|
21
|
+
from ._events import Subscriber, Subscriptions, ValueCallback, changed, tell
|
|
22
|
+
from ._key import Key, is_key, is_state_type
|
|
23
|
+
from ._point import (
|
|
24
|
+
DEFAULT_INTERVALS,
|
|
25
|
+
VALUE_TYPES,
|
|
26
|
+
Change,
|
|
27
|
+
Labels,
|
|
28
|
+
Limits,
|
|
29
|
+
Point,
|
|
30
|
+
PollRate,
|
|
31
|
+
Pulse,
|
|
32
|
+
Refresh,
|
|
33
|
+
Selector,
|
|
34
|
+
Transform,
|
|
35
|
+
Transforms,
|
|
36
|
+
WriteKind,
|
|
37
|
+
is_instance,
|
|
38
|
+
select,
|
|
39
|
+
selector,
|
|
40
|
+
)
|
|
41
|
+
from ._reachability import Reachability
|
|
42
|
+
from ._scheduler import Scheduler
|
|
43
|
+
from ._status import Status, StatusCallback
|
|
44
|
+
from ._timing import DEFAULT_TIMING, Timing
|
|
45
|
+
from ._unit import Unit
|
|
46
|
+
from ._value import DataValue, Quality, Value, stale
|
|
47
|
+
from ._writes import Write
|
|
48
|
+
|
|
49
|
+
__version__ = _installed_version("event_connect")
|
|
50
|
+
__all__ = [
|
|
51
|
+
"DEFAULT_INTERVALS",
|
|
52
|
+
"DEFAULT_TIMING",
|
|
53
|
+
"VALUE_TYPES",
|
|
54
|
+
"AuthenticationError",
|
|
55
|
+
"CannotConnectError",
|
|
56
|
+
"Certainty",
|
|
57
|
+
"Change",
|
|
58
|
+
"ClientError",
|
|
59
|
+
"Clock",
|
|
60
|
+
"DataValue",
|
|
61
|
+
"InvalidValueError",
|
|
62
|
+
"Key",
|
|
63
|
+
"Labels",
|
|
64
|
+
"Limits",
|
|
65
|
+
"ModelError",
|
|
66
|
+
"NotConnectedError",
|
|
67
|
+
"Point",
|
|
68
|
+
"PollRate",
|
|
69
|
+
"Pulse",
|
|
70
|
+
"Quality",
|
|
71
|
+
"ReadOnlyError",
|
|
72
|
+
"Reachability",
|
|
73
|
+
"Refresh",
|
|
74
|
+
"Scheduler",
|
|
75
|
+
"Selector",
|
|
76
|
+
"Status",
|
|
77
|
+
"StatusCallback",
|
|
78
|
+
"Subscriber",
|
|
79
|
+
"Subscriptions",
|
|
80
|
+
"SystemClock",
|
|
81
|
+
"Timing",
|
|
82
|
+
"Transform",
|
|
83
|
+
"Transforms",
|
|
84
|
+
"Unit",
|
|
85
|
+
"UnsupportedDeviceError",
|
|
86
|
+
"Value",
|
|
87
|
+
"ValueCallback",
|
|
88
|
+
"Write",
|
|
89
|
+
"WriteKind",
|
|
90
|
+
"changed",
|
|
91
|
+
"is_instance",
|
|
92
|
+
"is_key",
|
|
93
|
+
"is_state_type",
|
|
94
|
+
"select",
|
|
95
|
+
"selector",
|
|
96
|
+
"stale",
|
|
97
|
+
"tell",
|
|
98
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""How certain it is that a point is what its model says."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Certainty(StrEnum):
|
|
8
|
+
"""How certain it is that a point's place, conversion and meaning are right."""
|
|
9
|
+
|
|
10
|
+
VERIFIED = "verified"
|
|
11
|
+
"""Read on a unit of the model."""
|
|
12
|
+
REPORTED = "reported"
|
|
13
|
+
"""Stated in documentation of the model; not read on a unit of it."""
|
|
14
|
+
INFERRED = "inferred"
|
|
15
|
+
"""Worked out from documentation that does not state it, such as the order of a group of
|
|
16
|
+
points; not read on a unit of the model, and not stated anywhere."""
|
event_connect/_clock.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The two clocks the library uses, behind one object that can be replaced."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class Clock(Protocol):
|
|
11
|
+
"""A monotonic clock to schedule by, and a wall clock to stamp values with."""
|
|
12
|
+
|
|
13
|
+
def monotonic(self) -> float:
|
|
14
|
+
"""Seconds on a clock that never goes backwards; only differences mean anything."""
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
def now(self) -> datetime:
|
|
18
|
+
"""The current time, timezone-aware UTC."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SystemClock:
|
|
23
|
+
"""The real clocks."""
|
|
24
|
+
|
|
25
|
+
def monotonic(self) -> float:
|
|
26
|
+
"""Seconds on the system's monotonic clock."""
|
|
27
|
+
return time.monotonic()
|
|
28
|
+
|
|
29
|
+
def now(self) -> datetime:
|
|
30
|
+
"""The system's time, timezone-aware UTC."""
|
|
31
|
+
return datetime.now(timezone.utc)
|
event_connect/_errors.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Every error the library raises of its own."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ClientError(Exception):
|
|
8
|
+
"""Base for the errors the client raises about its own state."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CannotConnectError(ClientError):
|
|
12
|
+
"""The device could not be reached, or did not answer every request during the scan."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UnsupportedDeviceError(ClientError):
|
|
16
|
+
"""The device answered, but no model matches it."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, identity: Mapping[str, int | float | str | bool | None] | None = None) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.identity: Mapping[str, int | float | str | bool | None] = dict(identity or {})
|
|
21
|
+
"""What the device reported about itself."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NotConnectedError(ClientError):
|
|
25
|
+
"""The operation needs a device that has been scanned."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ReadOnlyError(ClientError):
|
|
29
|
+
"""A write was asked of a read-only client."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthenticationError(ClientError):
|
|
33
|
+
"""The device refused the credentials it was given."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InvalidValueError(ValueError):
|
|
37
|
+
"""A value a point cannot take, or registers that are not a value of it."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ModelError(ValueError):
|
|
41
|
+
"""A model does not resolve cleanly against an identity; lists every problem found."""
|
event_connect/_events.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Subscribers to a client's keys: who is told, and when a change is worth telling."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._key import Key
|
|
10
|
+
from ._point import Change, Point
|
|
11
|
+
from ._value import DataValue
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
type ValueCallback[T] = Callable[[Key[T], DataValue[T] | None, DataValue[T]], None]
|
|
16
|
+
"""Called with the key, the previous value (None the first time) and the new value."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(eq=False)
|
|
20
|
+
class Subscriber:
|
|
21
|
+
"""One subscription: the key, who is told, and whether it wants the key polled."""
|
|
22
|
+
|
|
23
|
+
key: Key[Any]
|
|
24
|
+
"""The key as the subscriber gave it, with the type it expects."""
|
|
25
|
+
callback: ValueCallback[Any]
|
|
26
|
+
polls: bool
|
|
27
|
+
"""Whether this subscriber also wants the key read on its schedule."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Subscriptions:
|
|
31
|
+
"""Who is subscribed to which key, and the last value each key reported."""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._subscribers: dict[str, list[Subscriber]] = {}
|
|
35
|
+
self._last_reported: dict[str, DataValue[Any]] = {}
|
|
36
|
+
|
|
37
|
+
def add(self, key: Key[Any], callback: ValueCallback[Any], *, polls: bool) -> Subscriber:
|
|
38
|
+
"""Subscribe `callback` to `key`, and return the subscription."""
|
|
39
|
+
subscriber = Subscriber(key, callback, polls)
|
|
40
|
+
self._subscribers.setdefault(key, []).append(subscriber)
|
|
41
|
+
return subscriber
|
|
42
|
+
|
|
43
|
+
def remove(self, key: str, subscriber: Subscriber) -> None:
|
|
44
|
+
"""End one subscription to `key`."""
|
|
45
|
+
subscribers = self._subscribers.get(key, [])
|
|
46
|
+
if subscriber in subscribers:
|
|
47
|
+
subscribers.remove(subscriber)
|
|
48
|
+
if not subscribers:
|
|
49
|
+
del self._subscribers[key]
|
|
50
|
+
|
|
51
|
+
def keys(self) -> list[Key[Any]]:
|
|
52
|
+
"""Every key subscribed to, as its first subscriber gave it."""
|
|
53
|
+
return [subscribers[0].key for subscribers in self._subscribers.values()]
|
|
54
|
+
|
|
55
|
+
def polled(self, key: str) -> bool:
|
|
56
|
+
"""Whether a subscriber wants `key` read on its schedule."""
|
|
57
|
+
return any(subscriber.polls for subscriber in self._subscribers.get(key, []))
|
|
58
|
+
|
|
59
|
+
def report(self, point: Point[Any], data: DataValue[Any]) -> None:
|
|
60
|
+
"""Tell `point`'s subscribers about `data`, unless it is no news."""
|
|
61
|
+
last = self._last_reported.get(point.key)
|
|
62
|
+
if last is not None and not _worth_reporting(point, last, data):
|
|
63
|
+
return
|
|
64
|
+
self._last_reported[point.key] = data
|
|
65
|
+
for subscriber in list(self._subscribers.get(point.key, [])):
|
|
66
|
+
# A subscriber whose key names another type than the point's would be handed values
|
|
67
|
+
# it cannot take; the client reports it once, when it learns the model.
|
|
68
|
+
if subscriber.key.type is point.key.type:
|
|
69
|
+
tell(subscriber, last, data)
|
|
70
|
+
|
|
71
|
+
def forget(self, key: str) -> None:
|
|
72
|
+
"""Forget what `key` last reported, so its next value is news."""
|
|
73
|
+
self._last_reported.pop(key, None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def tell(subscriber: Subscriber, old: DataValue[Any] | None, new: DataValue[Any]) -> None:
|
|
77
|
+
"""Call one subscriber with its own key; its error is logged and kept from the others."""
|
|
78
|
+
try:
|
|
79
|
+
subscriber.callback(subscriber.key, old, new)
|
|
80
|
+
except Exception:
|
|
81
|
+
_LOGGER.exception("a subscriber to '%s' raised", subscriber.key)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _worth_reporting(point: Point[Any], last: DataValue[Any], new: DataValue[Any]) -> bool:
|
|
85
|
+
"""A change of quality always is; a change of value is when it clears the deadband."""
|
|
86
|
+
if last.quality is not new.quality:
|
|
87
|
+
return True
|
|
88
|
+
if last.value == new.value:
|
|
89
|
+
return False
|
|
90
|
+
if point.deadband is None:
|
|
91
|
+
return True
|
|
92
|
+
before, after = last.value, new.value
|
|
93
|
+
if isinstance(before, bool) or isinstance(after, bool) \
|
|
94
|
+
or not isinstance(before, (int, float)) or not isinstance(after, (int, float)):
|
|
95
|
+
return True
|
|
96
|
+
return abs(after - before) >= point.deadband
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def changed(old: DataValue[Any], new: DataValue[Any], when: Change) -> bool:
|
|
100
|
+
"""Whether a change from `old` to `new` matches `when`. Only good values compare."""
|
|
101
|
+
if not (old.is_good and new.is_good) or old.value == new.value:
|
|
102
|
+
return False
|
|
103
|
+
if when is Change.ANY:
|
|
104
|
+
return True
|
|
105
|
+
before, after = old.value, new.value
|
|
106
|
+
if not isinstance(before, (int, float)) or not isinstance(after, (int, float)):
|
|
107
|
+
return False
|
|
108
|
+
return after > before if when is Change.RISING else after < before
|
event_connect/_key.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""A point's key: the text a consumer stores, carrying the type of the point's value."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
from typing import Any, TypeGuard
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Key[T](str):
|
|
9
|
+
"""A point's key. At runtime it is its text: equal to it, hashed as it, stored as it.
|
|
10
|
+
|
|
11
|
+
`Key("temperature", float)` names a point whose value is a float, and carries that type
|
|
12
|
+
wherever the key goes. The type is one of `bool`, `int`, `float`, `str`, `date`, `datetime`,
|
|
13
|
+
`time`, or an `IntEnum` naming the states of an integer.
|
|
14
|
+
"""
|
|
15
|
+
type: type[T]
|
|
16
|
+
"""The type of the point's value."""
|
|
17
|
+
|
|
18
|
+
def __new__(cls, text: str, type: type[T]) -> Key[T]:
|
|
19
|
+
key = super().__new__(cls, text)
|
|
20
|
+
key.type = type
|
|
21
|
+
return key
|
|
22
|
+
|
|
23
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
24
|
+
return (Key, (str(self), self.type))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_key(value: object) -> bool:
|
|
29
|
+
"""Whether `value` is a Key, not merely its text."""
|
|
30
|
+
return isinstance(value, Key)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_state_type(value_type: type[object]) -> TypeGuard[type[IntEnum]]:
|
|
34
|
+
"""Whether `value_type` names the states of an integer: an `IntEnum`."""
|
|
35
|
+
return issubclass(value_type, IntEnum)
|
event_connect/_point.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""What a device has: its points, declared by how they behave - not where they live or how they
|
|
2
|
+
are encoded, which each protocol adds."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
7
|
+
from dataclasses import KW_ONLY, dataclass, field, replace
|
|
8
|
+
from datetime import date, datetime, time
|
|
9
|
+
from enum import Enum, IntEnum, auto
|
|
10
|
+
from types import MappingProxyType
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ._key import Key, is_key, is_state_type
|
|
14
|
+
from ._unit import Unit
|
|
15
|
+
|
|
16
|
+
# ================================================================================== enums
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PollRate(Enum):
|
|
20
|
+
"""How fresh a point must be kept."""
|
|
21
|
+
FAST = auto()
|
|
22
|
+
MEDIUM = auto()
|
|
23
|
+
SLOW = auto()
|
|
24
|
+
RARE = auto()
|
|
25
|
+
STATIC = auto()
|
|
26
|
+
"""Read when connecting, when triggered or on request - never on a timer."""
|
|
27
|
+
SCAN = auto()
|
|
28
|
+
"""How often what identifies the unit is read again, to find out whether it has changed.
|
|
29
|
+
|
|
30
|
+
No point is polled at it.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
DEFAULT_INTERVALS: Mapping[PollRate, float | None] = MappingProxyType({
|
|
35
|
+
PollRate.FAST: 10.0,
|
|
36
|
+
PollRate.MEDIUM: 30.0,
|
|
37
|
+
PollRate.SLOW: 60.0,
|
|
38
|
+
PollRate.RARE: 900.0,
|
|
39
|
+
PollRate.STATIC: None,
|
|
40
|
+
PollRate.SCAN: 900.0,
|
|
41
|
+
})
|
|
42
|
+
"""Seconds between reads per poll rate, used when a model does not set its own."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class WriteKind(Enum):
|
|
46
|
+
"""What a write means."""
|
|
47
|
+
STATE = auto()
|
|
48
|
+
"""A setting. Writes that queue up collapse to the newest value."""
|
|
49
|
+
COMMAND = auto()
|
|
50
|
+
"""An action. Every write is sent, in order, however many there are."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Change(Enum):
|
|
54
|
+
"""Which change of a trigger point sets off its refresh."""
|
|
55
|
+
ANY = auto()
|
|
56
|
+
RISING = auto()
|
|
57
|
+
"""The value increased - for a boolean, went from False to True."""
|
|
58
|
+
FALLING = auto()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ============================================================================== selectors
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Labels:
|
|
65
|
+
"""Selects every point carrying all of these labels: `Labels(room=3)`."""
|
|
66
|
+
__slots__ = ("pairs",)
|
|
67
|
+
|
|
68
|
+
def __init__(self, **labels: str | int) -> None:
|
|
69
|
+
if not labels:
|
|
70
|
+
raise ValueError("Labels() needs at least one label to select by")
|
|
71
|
+
self.pairs: tuple[tuple[str, str | int], ...] = tuple(sorted(labels.items()))
|
|
72
|
+
|
|
73
|
+
def matches(self, labels: Mapping[str, str | int]) -> bool:
|
|
74
|
+
"""Whether `labels` carry every pair of these."""
|
|
75
|
+
return all(labels.get(name) == value for name, value in self.pairs)
|
|
76
|
+
|
|
77
|
+
def __eq__(self, other: object) -> bool:
|
|
78
|
+
return isinstance(other, Labels) and other.pairs == self.pairs
|
|
79
|
+
|
|
80
|
+
def __hash__(self) -> int:
|
|
81
|
+
return hash(self.pairs)
|
|
82
|
+
|
|
83
|
+
def __repr__(self) -> str:
|
|
84
|
+
return "Labels(" + ", ".join(f"{name}={value!r}" for name, value in self.pairs) + ")"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
Selector = Labels | tuple[str, ...]
|
|
88
|
+
"""Which points: those with these labels, or these keys."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def selector(targets: Labels | Sequence[str]) -> Selector:
|
|
92
|
+
"""`targets` as a selector: labels as they are, keys as a tuple.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
TypeError: a single string, which would select its characters.
|
|
96
|
+
ValueError: no keys.
|
|
97
|
+
"""
|
|
98
|
+
if isinstance(targets, Labels):
|
|
99
|
+
return targets
|
|
100
|
+
if isinstance(targets, str):
|
|
101
|
+
# A bare string is a sequence of characters - almost certainly a mistake.
|
|
102
|
+
raise TypeError(f"give keys as a list or tuple, not the single string {targets!r}")
|
|
103
|
+
keys = tuple(targets)
|
|
104
|
+
if not keys:
|
|
105
|
+
raise ValueError("an empty list of keys selects nothing")
|
|
106
|
+
return keys
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def select[P: Point[Any]](points: Sequence[P], targets: Selector) -> tuple[P, ...]:
|
|
110
|
+
"""The points of `points` that `targets` names, in the order of `points`."""
|
|
111
|
+
if isinstance(targets, Labels):
|
|
112
|
+
return tuple(point for point in points if targets.matches(point.labels))
|
|
113
|
+
wanted = set(targets)
|
|
114
|
+
return tuple(point for point in points if point.key in wanted)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ======================================================================= behaviour parts
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class Limits:
|
|
122
|
+
"""What may be written, in the units a user sees. Also the entity's min / max / step."""
|
|
123
|
+
min: float | None = None
|
|
124
|
+
max: float | None = None
|
|
125
|
+
step: float | None = None
|
|
126
|
+
|
|
127
|
+
def __post_init__(self) -> None:
|
|
128
|
+
if self.min is not None and self.max is not None and self.min > self.max:
|
|
129
|
+
raise ValueError(f"min {self.min} is greater than max {self.max}")
|
|
130
|
+
if self.step is not None and not self.step > 0:
|
|
131
|
+
raise ValueError(f"step must be positive, got {self.step}")
|
|
132
|
+
|
|
133
|
+
def problem(self, value: float) -> str | None:
|
|
134
|
+
"""Why `value` may not be written, or None if it may."""
|
|
135
|
+
if self.min is not None and value < self.min:
|
|
136
|
+
return f"{value} is below the minimum {self.min}"
|
|
137
|
+
if self.max is not None and value > self.max:
|
|
138
|
+
return f"{value} is above the maximum {self.max}"
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class Transform:
|
|
144
|
+
"""A conversion applied after scaling on read, and before it on write. Always a pair."""
|
|
145
|
+
read: Callable[[float], float]
|
|
146
|
+
write: Callable[[float], float]
|
|
147
|
+
name: str = ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Transforms:
|
|
151
|
+
"""Transforms most devices need."""
|
|
152
|
+
INVERT_BOOL = Transform(read=lambda v: 1 - v, write=lambda v: 1 - v, name="invert_bool")
|
|
153
|
+
"""The device says 0 for on. Also for a BOOL or a bit."""
|
|
154
|
+
SECONDS_AS_MINUTES = Transform(read=lambda s: s / 60, write=lambda m: m * 60, name="seconds_as_minutes")
|
|
155
|
+
MINUTES_AS_HOURS = Transform(read=lambda m: m / 60, write=lambda h: h * 60, name="minutes_as_hours")
|
|
156
|
+
HOURS_AS_DAYS = Transform(read=lambda h: h / 24, write=lambda d: d * 24, name="hours_as_days")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class Pulse[T]:
|
|
161
|
+
"""A command that returns to its idle value by itself: write, wait `after`, write `idle`."""
|
|
162
|
+
idle: T
|
|
163
|
+
after: float
|
|
164
|
+
|
|
165
|
+
def __post_init__(self) -> None:
|
|
166
|
+
if not self.after > 0:
|
|
167
|
+
raise ValueError(f"a pulse needs a positive duration, got {self.after}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True)
|
|
171
|
+
class Refresh:
|
|
172
|
+
"""Read `targets` again `after` seconds after this point is written, or changes.
|
|
173
|
+
|
|
174
|
+
With `until_stable`, keep re-reading them every `after` seconds while they still change,
|
|
175
|
+
for at most that many seconds.
|
|
176
|
+
"""
|
|
177
|
+
targets: Selector
|
|
178
|
+
after: float | None = None
|
|
179
|
+
"""Seconds to wait. None waits, after a write, as long as the written point takes to read
|
|
180
|
+
back, and after a change not at all."""
|
|
181
|
+
until_stable: float | None = None
|
|
182
|
+
when: Change = Change.ANY
|
|
183
|
+
"""Which change sets it off; only a change has one."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, targets: Labels | Sequence[str], after: float | None = None,
|
|
186
|
+
until_stable: float | None = None, *, when: Change = Change.ANY) -> None:
|
|
187
|
+
object.__setattr__(self, "targets", selector(targets))
|
|
188
|
+
object.__setattr__(self, "after", after)
|
|
189
|
+
object.__setattr__(self, "until_stable", until_stable)
|
|
190
|
+
object.__setattr__(self, "when", when)
|
|
191
|
+
if after is not None and after < 0:
|
|
192
|
+
raise ValueError(f"after cannot be negative, got {after}")
|
|
193
|
+
if until_stable is not None and not until_stable > 0:
|
|
194
|
+
raise ValueError(f"until_stable must be positive, got {until_stable}")
|
|
195
|
+
if until_stable is not None and after is not None and not after > 0:
|
|
196
|
+
raise ValueError("until_stable needs a positive after to re-read at")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ================================================================================== point
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
VALUE_TYPES: tuple[type[object], ...] = (bool, int, float, str, date, datetime, time)
|
|
203
|
+
"""The types a point's value can have, besides an `IntEnum` naming states."""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True, eq=False)
|
|
207
|
+
class Point[T]:
|
|
208
|
+
"""One value a device has, by how it behaves: when it is read, what may be written, and what a
|
|
209
|
+
write or a change of it sets off.
|
|
210
|
+
|
|
211
|
+
A protocol subclasses it with where the value lives and how it is encoded, and says whether it
|
|
212
|
+
can be read and written. `key` must never change once a consumer has stored it.
|
|
213
|
+
"""
|
|
214
|
+
key: Key[T]
|
|
215
|
+
_: KW_ONLY
|
|
216
|
+
limits: Limits | None = None
|
|
217
|
+
unit: Unit | None = None
|
|
218
|
+
poll_rate: PollRate = PollRate.MEDIUM
|
|
219
|
+
poll_always: bool = False
|
|
220
|
+
"""Read even when no consumer asked for it - a value the device model itself depends on."""
|
|
221
|
+
deadband: float | None = None
|
|
222
|
+
"""Changes smaller than this, from the last value reported, are not reported."""
|
|
223
|
+
write_kind: WriteKind = WriteKind.STATE
|
|
224
|
+
pulse: Pulse[T] | None = None
|
|
225
|
+
read_back_after: float | None = None
|
|
226
|
+
"""Seconds from a write until a read shows it, where this point differs from its device."""
|
|
227
|
+
on_write: Refresh | None = None
|
|
228
|
+
on_change: Refresh | None = None
|
|
229
|
+
labels: Mapping[str, str | int] = field(default_factory=lambda: MappingProxyType({}))
|
|
230
|
+
|
|
231
|
+
def __post_init__(self) -> None:
|
|
232
|
+
object.__setattr__(self, "labels", MappingProxyType(dict(self.labels)))
|
|
233
|
+
if self.on_change is not None and self.on_change.after is None:
|
|
234
|
+
object.__setattr__(self, "on_change", replace(self.on_change, after=0.0))
|
|
235
|
+
problems = self.problems()
|
|
236
|
+
if problems:
|
|
237
|
+
raise ValueError(f"point {self.key!r}: " + "; ".join(problems))
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def readable(self) -> bool:
|
|
241
|
+
"""Whether the point can be read; its protocol says."""
|
|
242
|
+
raise NotImplementedError
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def writable(self) -> bool:
|
|
246
|
+
"""Whether the point can be written; its protocol says."""
|
|
247
|
+
raise NotImplementedError
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def states(self) -> tuple[IntEnum, ...]:
|
|
251
|
+
"""The states this point can hold, in the order its IntEnum declares them; none for a key
|
|
252
|
+
that names no states."""
|
|
253
|
+
value_type: type[object] = self.key.type
|
|
254
|
+
if not is_state_type(value_type):
|
|
255
|
+
return ()
|
|
256
|
+
return tuple(value_type)
|
|
257
|
+
|
|
258
|
+
def problems(self) -> list[str]:
|
|
259
|
+
"""Everything wrong with the point, so one error names every mistake at once.
|
|
260
|
+
|
|
261
|
+
A protocol adds its own to these.
|
|
262
|
+
"""
|
|
263
|
+
return [*_shape_problems(self), *_type_problems(self), *_read_problems(self),
|
|
264
|
+
*_write_problems(self)]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _shape_problems(point: Point[Any]) -> list[str]:
|
|
268
|
+
found: list[str] = []
|
|
269
|
+
if not point.key or point.key != point.key.strip():
|
|
270
|
+
found.append("the key must be a non-empty string without surrounding spaces")
|
|
271
|
+
if not point.readable and not point.writable:
|
|
272
|
+
found.append("it needs a read side, a write side, or both")
|
|
273
|
+
if any(not name for name in point.labels):
|
|
274
|
+
found.append("a label needs a name")
|
|
275
|
+
return found
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _is_number(value_type: type[object]) -> bool:
|
|
279
|
+
return value_type in (int, float)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _type_problems(point: Point[Any]) -> list[str]:
|
|
283
|
+
if not is_key(point.key):
|
|
284
|
+
return [f"the key must be a Key naming the value's type, such as Key({str(point.key)!r}, float)"]
|
|
285
|
+
value_type: type[object] = point.key.type
|
|
286
|
+
found: list[str] = []
|
|
287
|
+
if not is_state_type(value_type) and value_type not in VALUE_TYPES:
|
|
288
|
+
found.append(f"a value is a bool, int, float, str, date, datetime, time or IntEnum, "
|
|
289
|
+
f"not {value_type.__name__}")
|
|
290
|
+
if not _is_number(value_type):
|
|
291
|
+
if point.deadband is not None:
|
|
292
|
+
found.append(f"a deadband applies to numbers, not to {value_type.__name__}")
|
|
293
|
+
if point.limits is not None:
|
|
294
|
+
found.append(f"limits apply to numbers, not to {value_type.__name__}")
|
|
295
|
+
return found
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _read_problems(point: Point[Any]) -> list[str]:
|
|
299
|
+
found: list[str] = []
|
|
300
|
+
if point.poll_rate is PollRate.SCAN:
|
|
301
|
+
found.append("PollRate.SCAN is how often a scan is checked, not a poll rate a point can have")
|
|
302
|
+
if point.deadband is not None:
|
|
303
|
+
if point.deadband < 0:
|
|
304
|
+
found.append(f"deadband cannot be negative, got {point.deadband}")
|
|
305
|
+
if not point.readable:
|
|
306
|
+
found.append("a deadband filters reads, but it has no read side")
|
|
307
|
+
if point.poll_always and not point.readable:
|
|
308
|
+
found.append("poll_always=True asks for reads, but it has no read side")
|
|
309
|
+
if point.on_change is not None:
|
|
310
|
+
if not point.readable:
|
|
311
|
+
found.append("on_change reacts to reads, but it has no read side")
|
|
312
|
+
if point.on_change.until_stable is not None and not point.on_change.after:
|
|
313
|
+
found.append("on_change re-reads until stable, which needs a positive after to re-read at")
|
|
314
|
+
return found
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _write_problems(point: Point[Any]) -> list[str]:
|
|
318
|
+
found: list[str] = []
|
|
319
|
+
if point.read_back_after is not None:
|
|
320
|
+
if not point.writable:
|
|
321
|
+
found.append("read_back_after describes writes, but it has no write side")
|
|
322
|
+
if not (math.isfinite(point.read_back_after) and point.read_back_after >= 0):
|
|
323
|
+
found.append(f"read_back_after must be a finite number >= 0, got {point.read_back_after}")
|
|
324
|
+
if point.limits is not None and not point.writable:
|
|
325
|
+
found.append("limits describe writes, but it has no write side")
|
|
326
|
+
if point.write_kind is WriteKind.COMMAND and not point.writable:
|
|
327
|
+
found.append("a COMMAND is written, but it has no write side")
|
|
328
|
+
if point.pulse is not None and point.write_kind is not WriteKind.COMMAND:
|
|
329
|
+
found.append("only a COMMAND can pulse")
|
|
330
|
+
if point.pulse is not None and not is_instance(point.pulse.idle, point.key.type):
|
|
331
|
+
found.append(f"the pulse's idle value {point.pulse.idle!r} is not a {point.key.type.__name__}")
|
|
332
|
+
if point.on_write is not None:
|
|
333
|
+
if not point.writable:
|
|
334
|
+
found.append("on_write re-reads after a write, but it has no write side")
|
|
335
|
+
if point.on_write.when is not Change.ANY:
|
|
336
|
+
found.append("on_write has no change to wait for; `when` belongs to on_change")
|
|
337
|
+
after = point.on_write.after if point.on_write.after is not None else point.read_back_after
|
|
338
|
+
if point.on_write.until_stable is not None and after is not None and not after > 0:
|
|
339
|
+
found.append("on_write re-reads until stable, which needs a positive after to re-read at")
|
|
340
|
+
return found
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def is_instance(value: object, value_type: type[object]) -> bool:
|
|
344
|
+
"""Whether `value` is of `value_type`, where an int counts as a float and a bool as neither."""
|
|
345
|
+
if isinstance(value, bool) and value_type is not bool:
|
|
346
|
+
return False
|
|
347
|
+
if value_type is float:
|
|
348
|
+
return isinstance(value, (int, float))
|
|
349
|
+
return isinstance(value, value_type)
|
|
350
|
+
|