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.
Files changed (39) hide show
  1. {aioebus-0.2.1 → aioebus-0.2.2}/PKG-INFO +1 -1
  2. {aioebus-0.2.1 → aioebus-0.2.2}/pyproject.toml +1 -1
  3. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/__init__.py +1 -1
  4. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/active.py +2 -0
  5. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/engine.py +14 -5
  6. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/poll.py +18 -3
  7. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus.egg-info/PKG-INFO +1 -1
  8. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_engine.py +48 -0
  9. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_poll.py +13 -0
  10. {aioebus-0.2.1 → aioebus-0.2.2}/LICENSE +0 -0
  11. {aioebus-0.2.1 → aioebus-0.2.2}/README.md +0 -0
  12. {aioebus-0.2.1 → aioebus-0.2.2}/setup.cfg +0 -0
  13. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/codecs.py +0 -0
  14. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/conditions.py +0 -0
  15. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/convert.py +0 -0
  16. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/frame.py +0 -0
  17. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/messages.py +0 -0
  18. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/passive.py +0 -0
  19. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/py.typed +0 -0
  20. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/scan.py +0 -0
  21. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/translations.py +0 -0
  22. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus/transport.py +0 -0
  23. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus.egg-info/SOURCES.txt +0 -0
  24. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus.egg-info/dependency_links.txt +0 -0
  25. {aioebus-0.2.1 → aioebus-0.2.2}/src/aioebus.egg-info/top_level.txt +0 -0
  26. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_active.py +0 -0
  27. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_active_read.py +0 -0
  28. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_bitfields.py +0 -0
  29. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_codecs.py +0 -0
  30. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_condition_eval.py +0 -0
  31. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_conditions.py +0 -0
  32. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_datatypes.py +0 -0
  33. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_frame.py +0 -0
  34. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_messages.py +0 -0
  35. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_passive.py +0 -0
  36. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_scan.py +0 -0
  37. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_translations.py +0 -0
  38. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_transport.py +0 -0
  39. {aioebus-0.2.1 → aioebus-0.2.2}/tests/test_write.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aioebus
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Standalone async eBUS library (decode-first). No ebusd daemon required.
5
5
  Author-email: dneprojects <dne.projects.1@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aioebus"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Standalone async eBUS library (decode-first). No ebusd daemon required."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -64,4 +64,4 @@ __all__ = [
64
64
  "unescape",
65
65
  "value_key",
66
66
  ]
67
- __version__ = "0.2.1"
67
+ __version__ = "0.2.2"
@@ -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
- # so it waits its normal interval before retrying. Without this its
209
- # timer stays at zero, so it is forever the most-overdue item and the
210
- # scheduler keeps handing back the same unanswered register, starving
211
- # every other register behind it.
212
- self.scheduler.mark_passive(act.msg, time.monotonic())
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 value time (poll OR passive)
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
- return self.base_interval * item.priority
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,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aioebus
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Standalone async eBUS library (decode-first). No ebusd daemon required.
5
5
  Author-email: dneprojects <dne.projects.1@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -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