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.
@@ -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."""
@@ -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)
@@ -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."""
@@ -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)
@@ -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
+