aioebus 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {aioebus-0.2.1 → aioebus-0.2.2}/PKG-INFO +1 -1
- {aioebus-0.2.1 → aioebus-0.2.2}/pyproject.toml +1 -1
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/__init__.py +1 -1
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/active.py +2 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/engine.py +14 -5
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/poll.py +18 -3
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus.egg-info/PKG-INFO +1 -1
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_engine.py +48 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_poll.py +13 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/LICENSE +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/README.md +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/setup.cfg +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/codecs.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/conditions.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/convert.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/frame.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/messages.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/passive.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/py.typed +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/scan.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/translations.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/transport.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus.egg-info/SOURCES.txt +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus.egg-info/dependency_links.txt +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus.egg-info/top_level.txt +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_active.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_active_read.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_bitfields.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_codecs.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_condition_eval.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_conditions.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_datatypes.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_frame.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_messages.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_passive.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_scan.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_translations.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_transport.py +0 -0
- {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_write.py +0 -0
|
@@ -95,6 +95,7 @@ class _CommandDriver:
|
|
|
95
95
|
self.done = False
|
|
96
96
|
self.result = None
|
|
97
97
|
self.error: str | None = None
|
|
98
|
+
self.lost_to: int | None = None # winning master address when arbitration is lost
|
|
98
99
|
|
|
99
100
|
def start(self) -> list[bytes]:
|
|
100
101
|
"""Initial symbols to send: the arbitration START with QQ."""
|
|
@@ -125,6 +126,7 @@ class _CommandDriver:
|
|
|
125
126
|
self._state = "cmd"
|
|
126
127
|
return self._send_next_cmd()
|
|
127
128
|
if code == FAILED:
|
|
129
|
+
self.lost_to = data # the master that won the bus (for passive capture)
|
|
128
130
|
return self._fail(f"arbitration lost to {data:#04x}")
|
|
129
131
|
return [] # ignore SYN/other while waiting to win
|
|
130
132
|
|
|
@@ -196,6 +196,15 @@ class EbusEngine:
|
|
|
196
196
|
if act.future is not None and not act.future.done():
|
|
197
197
|
act.future.set_result(drv.result)
|
|
198
198
|
return
|
|
199
|
+
# We lost the bus to another master: capture ITS telegram passively instead
|
|
200
|
+
# of discarding it. The arbitration consumed only the winner's address byte
|
|
201
|
+
# (reported as drv.lost_to); the rest of its telegram is still arriving, and
|
|
202
|
+
# now that _active is clear those bytes route to the passive buffer -- so
|
|
203
|
+
# seed the buffer with the winner's address and the next SYN decodes the
|
|
204
|
+
# whole telegram (a broadcast, or another master's read incl. the slave part).
|
|
205
|
+
lost_to = getattr(drv, "lost_to", None)
|
|
206
|
+
if isinstance(lost_to, int) and not self._buf:
|
|
207
|
+
self._buf.append(lost_to)
|
|
199
208
|
# error: retry a lost/contended bus (rebuild a fresh driver), else give up
|
|
200
209
|
retryable = "arbitration lost" in drv.error or "collision" in drv.error
|
|
201
210
|
if retryable and act.attempts < self.MAX_ATTEMPTS:
|
|
@@ -205,8 +214,8 @@ class EbusEngine:
|
|
|
205
214
|
act.future.set_exception(ReadError(drv.error))
|
|
206
215
|
elif act.msg is not None and self.scheduler is not None:
|
|
207
216
|
# a poll the device did not answer (NAK/timeout): record the attempt
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
self.scheduler.
|
|
217
|
+
# and back it off. Without recording it, its timer stays at zero so it
|
|
218
|
+
# is forever the most-overdue item and the scheduler keeps re-handing
|
|
219
|
+
# the same dead register, starving the rest; the backoff then fades a
|
|
220
|
+
# persistently unanswered register out of the rotation entirely.
|
|
221
|
+
self.scheduler.mark_failure(act.msg, time.monotonic())
|
|
@@ -38,10 +38,13 @@ if TYPE_CHECKING:
|
|
|
38
38
|
class PollItem:
|
|
39
39
|
msg: MessageDef
|
|
40
40
|
priority: int
|
|
41
|
-
last_update: float = field(default=0.0) # last
|
|
41
|
+
last_update: float = field(default=0.0) # last attempt time (value OR failed poll)
|
|
42
|
+
fails: int = 0 # consecutive unanswered polls; backs the interval off
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
class PollScheduler:
|
|
46
|
+
MAX_BACKOFF: int = 5 # a dead register backs off up to 2**5 = 32x its interval
|
|
47
|
+
|
|
45
48
|
def __init__(self, messages: list[MessageDef], base_interval: float = 60.0,
|
|
46
49
|
default_priority: int = 0):
|
|
47
50
|
"""``default_priority`` is the priority given to plain ``r`` reads (0 =
|
|
@@ -68,17 +71,29 @@ class PollScheduler:
|
|
|
68
71
|
default_priority=default_priority)
|
|
69
72
|
|
|
70
73
|
def interval(self, item: PollItem) -> float:
|
|
71
|
-
|
|
74
|
+
# a register the device keeps not answering is polled exponentially less
|
|
75
|
+
# often, so dead registers stop stealing bus slots from answering ones
|
|
76
|
+
return self.base_interval * item.priority * (1 << min(item.fails, self.MAX_BACKOFF))
|
|
72
77
|
|
|
73
78
|
def mark(self, zz: str, name: str, now: float) -> None:
|
|
74
|
-
"""Record that a fresh value arrived (from a poll or passively)
|
|
79
|
+
"""Record that a fresh value arrived (from a poll or passively); clears
|
|
80
|
+
any backoff."""
|
|
75
81
|
item = self._items.get((zz, name))
|
|
76
82
|
if item is not None:
|
|
77
83
|
item.last_update = now
|
|
84
|
+
item.fails = 0
|
|
78
85
|
|
|
79
86
|
def mark_passive(self, msg: MessageDef, now: float) -> None:
|
|
80
87
|
self.mark(msg.zz, msg.name, now)
|
|
81
88
|
|
|
89
|
+
def mark_failure(self, msg: MessageDef, now: float) -> None:
|
|
90
|
+
"""Record that a poll went unanswered: keep it from being re-handed
|
|
91
|
+
immediately, and back its interval off so it fades out of the rotation."""
|
|
92
|
+
item = self._items.get((msg.zz, msg.name))
|
|
93
|
+
if item is not None:
|
|
94
|
+
item.last_update = now
|
|
95
|
+
item.fails += 1
|
|
96
|
+
|
|
82
97
|
def next_due(self, now: float) -> PollItem | None:
|
|
83
98
|
"""The single most-overdue message to read now, or None if none is due.
|
|
84
99
|
Overdue-ness is measured relative to each item's own interval, so a
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Unified engine: passive decode caches values, and explicit active reads are
|
|
2
2
|
interleaved at bus idle. Driven by a fake async link (no pytest-asyncio)."""
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
4
5
|
|
|
5
6
|
from aioebus.active import ACK, SEND, START, build_read_frame
|
|
6
7
|
from aioebus.engine import EbusEngine, _Active, value_key
|
|
@@ -9,6 +10,7 @@ from aioebus.messages import parse_csv
|
|
|
9
10
|
from aioebus.passive import MessageRegistry
|
|
10
11
|
from aioebus.poll import PollScheduler
|
|
11
12
|
from aioebus.transport import (
|
|
13
|
+
FAILED,
|
|
12
14
|
RECEIVED,
|
|
13
15
|
STARTED,
|
|
14
16
|
SYN,
|
|
@@ -22,6 +24,7 @@ type,circuit,level,name,comment,qq,zz,pbsb,id,*name,part,type,divisor/values,uni
|
|
|
22
24
|
*r,elo,,,,,38
|
|
23
25
|
r1,,,FlowTemp,,,,b509,0d1800,value,,D2C,,°C,,ign,,IGN:1,,,
|
|
24
26
|
r1,,,Statenumber,,,,b509,0dab00,value,,UCH,0=kein_bedarf;31=sommer,,
|
|
27
|
+
u,bc,,Outsidetemp,,,fe,b516,01,value,,D2B,,°C,
|
|
25
28
|
"""
|
|
26
29
|
REG = MessageRegistry(parse_csv(CSV))
|
|
27
30
|
BY = {m.name: m for m in parse_csv(CSV)}
|
|
@@ -89,6 +92,51 @@ def test_passive_decode_caches():
|
|
|
89
92
|
asyncio.run(inner())
|
|
90
93
|
|
|
91
94
|
|
|
95
|
+
class _ArbLossAdapter:
|
|
96
|
+
"""On our arbitration START, reply FAILED(winner) then the winner's telegram
|
|
97
|
+
(minus its already-arbitrated first byte) and a closing SYN."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, winner: int, rest_hex: str):
|
|
100
|
+
self.winner, self.rest, self._dec = winner, bytes.fromhex(rest_hex), EnhDecoder()
|
|
101
|
+
self.fired = False
|
|
102
|
+
|
|
103
|
+
def on_send(self, sent: bytes) -> bytes:
|
|
104
|
+
out = bytearray()
|
|
105
|
+
for code, _data in self._dec.feed(sent):
|
|
106
|
+
if code == START and not self.fired:
|
|
107
|
+
self.fired = True
|
|
108
|
+
out += encode_symbol(FAILED, self.winner)
|
|
109
|
+
for b in self.rest:
|
|
110
|
+
out += encode_symbol(RECEIVED, b)
|
|
111
|
+
out += encode_symbol(RECEIVED, SYN)
|
|
112
|
+
return bytes(out)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_arbitration_loss_captures_winner_passively():
|
|
116
|
+
"""When our poll loses arbitration, the telegram that won is decoded
|
|
117
|
+
passively (seeded with the winner's address) instead of being discarded."""
|
|
118
|
+
async def inner():
|
|
119
|
+
link = _Link()
|
|
120
|
+
# real outside-temp broadcast 10 fe b509... -> here b516 01 c0 0d (13.75 C);
|
|
121
|
+
# winner 0x10, rest is the telegram without its leading (arbitrated) 0x10
|
|
122
|
+
link.adapter = _ArbLossAdapter(0x10, "feb5160301c00d12")
|
|
123
|
+
eng = EbusEngine(link.send, link.recv, REG)
|
|
124
|
+
task = asyncio.create_task(eng.run())
|
|
125
|
+
rtask = asyncio.create_task(eng.read(BY["Statenumber"], timeout=0.2))
|
|
126
|
+
await asyncio.sleep(0)
|
|
127
|
+
link.inject(encode_symbol(RECEIVED, SYN)) # idle -> our read starts -> loses arbitration
|
|
128
|
+
await asyncio.sleep(0.05)
|
|
129
|
+
assert eng.cache.get(value_key(BY["Outsidetemp"], "Outsidetemp")) == 13.75
|
|
130
|
+
rtask.cancel()
|
|
131
|
+
eng.stop()
|
|
132
|
+
link.inject(b"")
|
|
133
|
+
with contextlib.suppress(asyncio.CancelledError, TimeoutError):
|
|
134
|
+
await asyncio.wait_for(rtask, 0.5)
|
|
135
|
+
await asyncio.wait_for(task, 1)
|
|
136
|
+
|
|
137
|
+
asyncio.run(inner())
|
|
138
|
+
|
|
139
|
+
|
|
92
140
|
def test_failed_poll_is_recorded_not_restuck():
|
|
93
141
|
"""A poll the device does not answer must update its scheduler timer, so the
|
|
94
142
|
scheduler moves on instead of handing back the same dead register forever
|
|
@@ -38,6 +38,19 @@ def test_priority_sets_interval():
|
|
|
38
38
|
assert sch.next_due(60.0).msg.name == "Fast"
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def test_failed_poll_backoff():
|
|
42
|
+
sch = PollScheduler(MSGS, base_interval=10.0)
|
|
43
|
+
fast = next(m for m in MSGS if m.name == "Fast") # r1 -> base interval 10
|
|
44
|
+
item = sch._items[("38", "Fast")]
|
|
45
|
+
assert sch.interval(item) == 10.0
|
|
46
|
+
sch.mark_failure(fast, now=0.0)
|
|
47
|
+
assert sch.interval(item) == 20.0 # 2x after one unanswered poll
|
|
48
|
+
sch.mark_failure(fast, now=0.0)
|
|
49
|
+
assert sch.interval(item) == 40.0 # 4x
|
|
50
|
+
sch.mark_passive(fast, now=0.0) # a value clears the backoff
|
|
51
|
+
assert sch.interval(item) == 10.0
|
|
52
|
+
|
|
53
|
+
|
|
41
54
|
def test_passive_update_suppresses_poll():
|
|
42
55
|
sch = PollScheduler(MSGS, base_interval=10.0)
|
|
43
56
|
assert sch.next_due(12.0).msg.name == "Fast" # Fast would be polled at t=12
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|