aioebus 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.
- aioebus/__init__.py +67 -0
- aioebus/active.py +301 -0
- aioebus/codecs.py +367 -0
- aioebus/conditions.py +98 -0
- aioebus/convert.py +106 -0
- aioebus/engine.py +205 -0
- aioebus/frame.py +174 -0
- aioebus/messages.py +227 -0
- aioebus/passive.py +77 -0
- aioebus/poll.py +73 -0
- aioebus/py.typed +0 -0
- aioebus/scan.py +50 -0
- aioebus/translations.py +56 -0
- aioebus/transport.py +143 -0
- aioebus-0.1.0.dist-info/METADATA +187 -0
- aioebus-0.1.0.dist-info/RECORD +19 -0
- aioebus-0.1.0.dist-info/WHEEL +5 -0
- aioebus-0.1.0.dist-info/licenses/LICENSE +202 -0
- aioebus-0.1.0.dist-info/top_level.txt +1 -0
aioebus/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""aioebus -- standalone async eBUS library (work in progress).
|
|
2
|
+
|
|
3
|
+
Pure, offline eBUS decoding validated against real captured telegrams:
|
|
4
|
+
- codecs: datatype decoding (ebusd datatype.cpp port)
|
|
5
|
+
- messages: structured definitions + ebusd-CSV ingest + decode
|
|
6
|
+
- frame: on-the-wire telegram parsing, escaping, eBUS CRC-8
|
|
7
|
+
- passive: raw wire bytes -> matched message -> named values (read-only)
|
|
8
|
+
- transport/active/engine: enhanced-adapter I/O, active read/write, and a
|
|
9
|
+
passive-first engine that caches decoded values and interleaves reads.
|
|
10
|
+
"""
|
|
11
|
+
from .active import (
|
|
12
|
+
ActiveReader,
|
|
13
|
+
ActiveWriter,
|
|
14
|
+
ReadDriver,
|
|
15
|
+
ReadError,
|
|
16
|
+
WriteDriver,
|
|
17
|
+
build_read,
|
|
18
|
+
build_read_frame,
|
|
19
|
+
build_write_frame,
|
|
20
|
+
)
|
|
21
|
+
from .codecs import Codec, decode, encode, make_codec
|
|
22
|
+
from .conditions import ConditionContext, context_from_scan, evaluate, evaluate_all
|
|
23
|
+
from .engine import EbusEngine, value_key
|
|
24
|
+
from .frame import Telegram, crc8, escape, parse_telegram, unescape
|
|
25
|
+
from .messages import FieldDef, MessageDef, parse_csv
|
|
26
|
+
from .passive import MessageRegistry, decode_raw
|
|
27
|
+
from .poll import PollScheduler
|
|
28
|
+
from .scan import ScanResult, decode_scan
|
|
29
|
+
from .transport import EnhDecoder, PassiveListener, listen
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"ActiveReader",
|
|
33
|
+
"ActiveWriter",
|
|
34
|
+
"Codec",
|
|
35
|
+
"ConditionContext",
|
|
36
|
+
"EbusEngine",
|
|
37
|
+
"EnhDecoder",
|
|
38
|
+
"FieldDef",
|
|
39
|
+
"MessageDef",
|
|
40
|
+
"MessageRegistry",
|
|
41
|
+
"PassiveListener",
|
|
42
|
+
"PollScheduler",
|
|
43
|
+
"ReadDriver",
|
|
44
|
+
"ReadError",
|
|
45
|
+
"ScanResult",
|
|
46
|
+
"Telegram",
|
|
47
|
+
"WriteDriver",
|
|
48
|
+
"build_read",
|
|
49
|
+
"build_read_frame",
|
|
50
|
+
"build_write_frame",
|
|
51
|
+
"context_from_scan",
|
|
52
|
+
"crc8",
|
|
53
|
+
"decode",
|
|
54
|
+
"decode_raw",
|
|
55
|
+
"decode_scan",
|
|
56
|
+
"encode",
|
|
57
|
+
"escape",
|
|
58
|
+
"evaluate",
|
|
59
|
+
"evaluate_all",
|
|
60
|
+
"listen",
|
|
61
|
+
"make_codec",
|
|
62
|
+
"parse_csv",
|
|
63
|
+
"parse_telegram",
|
|
64
|
+
"unescape",
|
|
65
|
+
"value_key",
|
|
66
|
+
]
|
|
67
|
+
__version__ = "0.1.0"
|
aioebus/active.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Active reading over the enhanced adapter (Phase 4, read side).
|
|
2
|
+
|
|
3
|
+
Builds the master request and drives the read transaction. The transaction FSM
|
|
4
|
+
is ported from ebusd's protocol_direct.cpp (active-get path):
|
|
5
|
+
|
|
6
|
+
arbitrate(QQ via START) -> STARTED/FAILED
|
|
7
|
+
send ZZ PB SB NN DD (each SEND byte is echoed back as RECEIVED, verified)
|
|
8
|
+
send CRC (echoed, verified)
|
|
9
|
+
recv master ACK (0x00) | NAK (0xff)
|
|
10
|
+
recv slave: SN, SN data bytes, SCRC (unescaped); verify CRC
|
|
11
|
+
send ACK (0x00) (echoed)
|
|
12
|
+
send SYN (releases the bus)
|
|
13
|
+
|
|
14
|
+
`qq` defaults to 0x31 -- the address ebusd itself uses on the reference bus, so
|
|
15
|
+
it is a proven-good master address that is free exactly when ebusd is stopped
|
|
16
|
+
(the single-client adapter needs ebusd down anyway). The core `ReadDriver` is a
|
|
17
|
+
pure byte-level state machine (testable against a simulated adapter); `read()`
|
|
18
|
+
only pumps bytes between it and the socket. Nothing is sent until the caller
|
|
19
|
+
explicitly runs a read -- this module never writes on import or construction.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
from collections.abc import Awaitable, Callable
|
|
25
|
+
|
|
26
|
+
from .frame import SYN, crc8, escape, unescape
|
|
27
|
+
from .messages import MessageDef
|
|
28
|
+
from .scan import SCAN_PBSB, ScanResult, decode_scan
|
|
29
|
+
from .transport import FAILED, RECEIVED, STARTED, EnhDecoder
|
|
30
|
+
|
|
31
|
+
DEFAULT_SOURCE = 0x31 # ebusd's own master address on the reference bus (free when ebusd is down)
|
|
32
|
+
SEND = 0x1
|
|
33
|
+
START = 0x2
|
|
34
|
+
ACK = 0x00
|
|
35
|
+
NAK = 0xFF
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ReadError(Exception):
|
|
39
|
+
"""An active read failed (arbitration lost, collision, NAK, bad CRC)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _symbol2(code: int, value: int) -> bytes:
|
|
43
|
+
"""Encode a host->adapter symbol in the two-byte form. ebusd always uses the
|
|
44
|
+
long form for SEND/START (its EnhancedDevice::send never short-forms), and
|
|
45
|
+
the adapter firmware drops short-form sends, so we match it exactly."""
|
|
46
|
+
return bytes((0xC0 | ((code & 0x0F) << 2) | ((value >> 6) & 0x03),
|
|
47
|
+
0x80 | (value & 0x3F)))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_read_frame(
|
|
51
|
+
zz: int, pbsb: str, msgid: str, qq: int = DEFAULT_SOURCE, master_data: bytes = b""
|
|
52
|
+
) -> bytes:
|
|
53
|
+
"""Return the escaped on-wire master frame (incl. CRC) for a read request.
|
|
54
|
+
|
|
55
|
+
For a plain read the master payload is just the message id; `master_data`
|
|
56
|
+
appends any master-side argument bytes for the rare parametrised reads.
|
|
57
|
+
"""
|
|
58
|
+
pb, sb = int(pbsb[0:2], 16), int(pbsb[2:4], 16)
|
|
59
|
+
data = bytes.fromhex(msgid) + master_data
|
|
60
|
+
logical = bytes((qq, zz, pb, sb, len(data))) + data
|
|
61
|
+
crc = crc8(escape(logical))
|
|
62
|
+
return escape(logical) + escape(bytes((crc,)))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_read(msg: MessageDef, qq: int = DEFAULT_SOURCE) -> bytes:
|
|
66
|
+
"""Build the read frame for a message definition (needs a 2-hex zz)."""
|
|
67
|
+
if not (msg.zz and len(msg.zz) == 2):
|
|
68
|
+
raise ValueError(f"{msg.name}: no destination address to read from")
|
|
69
|
+
return build_read_frame(int(msg.zz, 16), msg.pbsb, msg.msgid, qq)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_write_frame(
|
|
73
|
+
zz: int, pbsb: str, master_data: bytes, qq: int = DEFAULT_SOURCE
|
|
74
|
+
) -> bytes:
|
|
75
|
+
"""Build the on-wire master frame for a write; `master_data` already includes
|
|
76
|
+
the message id and the encoded field bytes (see MessageDef.encode)."""
|
|
77
|
+
return build_read_frame(zz, pbsb, "", qq, master_data)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _CommandDriver:
|
|
81
|
+
"""Pure byte-level FSM shared by active reads and writes: arbitrate (START
|
|
82
|
+
with QQ), send the command bytes (ZZ PB SB NN DD + CRC, each echoed back and
|
|
83
|
+
verified for collision), then handle the master ACK/NAK. Subclasses decide
|
|
84
|
+
what happens once the command is ACKed. No I/O here, so it is fully testable
|
|
85
|
+
against a simulated adapter; `done`/`result`/`error` report the outcome.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, zz: int, pbsb: str, data: bytes, qq: int = DEFAULT_SOURCE):
|
|
89
|
+
wire = build_read_frame(zz, pbsb, "", qq, data) # QQ ZZ PB SB NN data CRC
|
|
90
|
+
self.qq = qq
|
|
91
|
+
self._cmd = wire[len(escape(bytes((qq,)))):] # bytes to SEND after arbitration
|
|
92
|
+
self._dec = EnhDecoder()
|
|
93
|
+
self._state = "arb"
|
|
94
|
+
self._sendpos = 0
|
|
95
|
+
self.done = False
|
|
96
|
+
self.result = None
|
|
97
|
+
self.error: str | None = None
|
|
98
|
+
|
|
99
|
+
def start(self) -> list[bytes]:
|
|
100
|
+
"""Initial symbols to send: the arbitration START with QQ."""
|
|
101
|
+
return [_symbol2(START, self.qq)]
|
|
102
|
+
|
|
103
|
+
def feed(self, chunk: bytes) -> list[bytes]:
|
|
104
|
+
"""Process adapter bytes, return the enh symbols to send next."""
|
|
105
|
+
return self.feed_events(self._dec.feed(chunk))
|
|
106
|
+
|
|
107
|
+
def feed_events(self, events) -> list[bytes]:
|
|
108
|
+
"""Process already-decoded (code, data) events. Used by the engine, which
|
|
109
|
+
owns the single stream decoder and routes events to the active driver."""
|
|
110
|
+
out: list[bytes] = []
|
|
111
|
+
for code, data in events:
|
|
112
|
+
if self.done:
|
|
113
|
+
break
|
|
114
|
+
out += self._step(code, data)
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
def _fail(self, msg: str) -> list[bytes]:
|
|
118
|
+
self.error = msg
|
|
119
|
+
self.done = True
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
def _step(self, code: int, data: int) -> list[bytes]:
|
|
123
|
+
if self._state == "arb":
|
|
124
|
+
if code == STARTED:
|
|
125
|
+
self._state = "cmd"
|
|
126
|
+
return self._send_next_cmd()
|
|
127
|
+
if code == FAILED:
|
|
128
|
+
return self._fail(f"arbitration lost to {data:#04x}")
|
|
129
|
+
return [] # ignore SYN/other while waiting to win
|
|
130
|
+
|
|
131
|
+
if self._state == "cmd": # verify echo of the byte we just sent
|
|
132
|
+
if code != RECEIVED or data != self._cmd[self._sendpos - 1]:
|
|
133
|
+
return self._fail("collision: echo mismatch while sending command")
|
|
134
|
+
return self._send_next_cmd()
|
|
135
|
+
|
|
136
|
+
if self._state == "ack": # master ACK/NAK of our command
|
|
137
|
+
if code == RECEIVED and data == ACK:
|
|
138
|
+
return self._acked()
|
|
139
|
+
if code == RECEIVED and data == NAK:
|
|
140
|
+
return self._fail("command NAKed by slave")
|
|
141
|
+
return self._fail("unexpected symbol waiting for command ACK")
|
|
142
|
+
|
|
143
|
+
return self._handle(code, data)
|
|
144
|
+
|
|
145
|
+
def _send_next_cmd(self) -> list[bytes]:
|
|
146
|
+
if self._sendpos >= len(self._cmd):
|
|
147
|
+
self._state = "ack"
|
|
148
|
+
return []
|
|
149
|
+
b = self._cmd[self._sendpos]
|
|
150
|
+
self._sendpos += 1
|
|
151
|
+
return [_symbol2(SEND, b)]
|
|
152
|
+
|
|
153
|
+
def _acked(self) -> list[bytes]: # subclass: command accepted by the slave
|
|
154
|
+
raise NotImplementedError
|
|
155
|
+
|
|
156
|
+
def _handle(self, code: int, data: int) -> list[bytes]: # subclass: states past ack
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ReadDriver(_CommandDriver):
|
|
161
|
+
"""Active read: after the command is ACKed, collect and verify the slave's
|
|
162
|
+
response frame (SN + data + CRC), then ACK it and release the bus."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, zz: int, pbsb: str, msgid: str, qq: int = DEFAULT_SOURCE,
|
|
165
|
+
master_data: bytes = b""):
|
|
166
|
+
super().__init__(zz, pbsb, bytes.fromhex(msgid) + master_data, qq)
|
|
167
|
+
self._slave_wire = bytearray()
|
|
168
|
+
|
|
169
|
+
def _acked(self) -> list[bytes]:
|
|
170
|
+
self._state = "res"
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
def _handle(self, code: int, data: int) -> list[bytes]:
|
|
174
|
+
if self._state != "res" or code != RECEIVED:
|
|
175
|
+
return []
|
|
176
|
+
self._slave_wire.append(data)
|
|
177
|
+
u = unescape(bytes(self._slave_wire))
|
|
178
|
+
if len(u) >= 1 and len(u) >= u[0] + 2: # SN + SN data + CRC
|
|
179
|
+
return self._finish_slave(u)
|
|
180
|
+
return []
|
|
181
|
+
|
|
182
|
+
def _finish_slave(self, u: bytes) -> list[bytes]:
|
|
183
|
+
sn = u[0]
|
|
184
|
+
sdata = bytes(u[1:1 + sn])
|
|
185
|
+
scrc = u[1 + sn]
|
|
186
|
+
if crc8(escape(bytes((sn,)) + sdata)) != scrc:
|
|
187
|
+
return self._fail("slave CRC mismatch")
|
|
188
|
+
self.result = sdata
|
|
189
|
+
self.done = True
|
|
190
|
+
# ACK the good answer, then release the bus with a SYN
|
|
191
|
+
return [_symbol2(SEND, ACK), _symbol2(SEND, SYN)]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class WriteDriver(_CommandDriver):
|
|
195
|
+
"""Active write: the slave ACKs the command (there is no response frame), so
|
|
196
|
+
once ACKed the write is done; we release the bus with a SYN."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, zz: int, pbsb: str, master_data: bytes, qq: int = DEFAULT_SOURCE):
|
|
199
|
+
super().__init__(zz, pbsb, master_data, qq) # master_data already includes the id
|
|
200
|
+
|
|
201
|
+
def _acked(self) -> list[bytes]:
|
|
202
|
+
self.result = True
|
|
203
|
+
self.done = True
|
|
204
|
+
return [_symbol2(SEND, SYN)]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def _run_with_retry(send, recv, make_driver, timeout: float, tries: int):
|
|
208
|
+
"""Run a command driver to completion, retrying only when the shared bus was
|
|
209
|
+
not actually acquired (lost arbitration / collision before we owned it).
|
|
210
|
+
Returns the finished driver; a NAK or other error is returned, not retried,
|
|
211
|
+
and is never retried for a write because the slave may already have applied
|
|
212
|
+
one copy. The bus acquire itself is safe to retry: an incomplete master frame
|
|
213
|
+
is rejected on CRC, so no half-write lands."""
|
|
214
|
+
last = "no attempt"
|
|
215
|
+
for _ in range(tries):
|
|
216
|
+
drv = make_driver()
|
|
217
|
+
for sym in drv.start():
|
|
218
|
+
await send(sym)
|
|
219
|
+
try:
|
|
220
|
+
while not drv.done:
|
|
221
|
+
chunk = await asyncio.wait_for(recv(), timeout)
|
|
222
|
+
for sym in drv.feed(chunk):
|
|
223
|
+
await send(sym)
|
|
224
|
+
except TimeoutError:
|
|
225
|
+
last = "timeout"
|
|
226
|
+
continue
|
|
227
|
+
if drv.error is None:
|
|
228
|
+
return drv
|
|
229
|
+
last = drv.error
|
|
230
|
+
if "arbitration lost" in last or "collision" in last:
|
|
231
|
+
await asyncio.sleep(0.05) # let the bus reach a fresh SYN
|
|
232
|
+
continue
|
|
233
|
+
break
|
|
234
|
+
raise ReadError(last)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class ActiveReader:
|
|
238
|
+
"""Async active reader over an enh link. `send`/`recv` are the only I/O: on a
|
|
239
|
+
socket, `send(bytes)` writes and `recv()` awaits the next chunk from the
|
|
240
|
+
adapter. Read-only in the sense that it only ever issues read requests."""
|
|
241
|
+
|
|
242
|
+
def __init__(self, send: Callable[[bytes], Awaitable[None]],
|
|
243
|
+
recv: Callable[[], Awaitable[bytes]], qq: int = DEFAULT_SOURCE):
|
|
244
|
+
self._send = send
|
|
245
|
+
self._recv = recv
|
|
246
|
+
self.qq = qq
|
|
247
|
+
|
|
248
|
+
async def read_raw(self, zz: int, pbsb: str, msgid: str, master_data: bytes = b"",
|
|
249
|
+
timeout: float = 3.0, tries: int = 12) -> bytes:
|
|
250
|
+
"""Perform one active read, returning the raw slave data bytes. A lost
|
|
251
|
+
arbitration on the shared bus is retried at the next SYN, as ebusd does."""
|
|
252
|
+
drv = await _run_with_retry(
|
|
253
|
+
self._send, self._recv,
|
|
254
|
+
lambda: ReadDriver(zz, pbsb, msgid, self.qq, master_data), timeout, tries)
|
|
255
|
+
assert drv.result is not None
|
|
256
|
+
return drv.result
|
|
257
|
+
|
|
258
|
+
async def read(self, msg: MessageDef, timeout: float = 3.0) -> dict:
|
|
259
|
+
"""Active-read one message definition and return its labelled values."""
|
|
260
|
+
if not (msg.zz and len(msg.zz) == 2):
|
|
261
|
+
raise ValueError(f"{msg.name}: no destination address to read from")
|
|
262
|
+
slave = await self.read_raw(int(msg.zz, 16), msg.pbsb, msg.msgid, timeout=timeout)
|
|
263
|
+
return msg.decode_labeled(master=b"", slave=slave)
|
|
264
|
+
|
|
265
|
+
async def scan(self, zz: int, timeout: float = 3.0) -> ScanResult:
|
|
266
|
+
"""Identify a device by reading 0704 (manufacturer, id, SW, HW)."""
|
|
267
|
+
slave = await self.read_raw(zz, SCAN_PBSB, "", timeout=timeout)
|
|
268
|
+
return decode_scan(slave)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class ActiveWriter:
|
|
272
|
+
"""Async active writer over an enh link. Writing changes device settings, so
|
|
273
|
+
it is gated: construction requires an explicit `allow_write=True`, and the
|
|
274
|
+
caller is expected to confirm each change. A lost arbitration is retried
|
|
275
|
+
safely (an incomplete frame is rejected on CRC), but a NAK is never retried.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
def __init__(self, send: Callable[[bytes], Awaitable[None]],
|
|
279
|
+
recv: Callable[[], Awaitable[bytes]], qq: int = DEFAULT_SOURCE,
|
|
280
|
+
*, allow_write: bool = False):
|
|
281
|
+
if not allow_write:
|
|
282
|
+
raise PermissionError(
|
|
283
|
+
"ActiveWriter is disabled; pass allow_write=True to send writes to the bus")
|
|
284
|
+
self._send = send
|
|
285
|
+
self._recv = recv
|
|
286
|
+
self.qq = qq
|
|
287
|
+
|
|
288
|
+
async def write_raw(self, zz: int, pbsb: str, master_data: bytes,
|
|
289
|
+
timeout: float = 3.0, tries: int = 12) -> None:
|
|
290
|
+
"""Write a prepared master payload (id + encoded fields) to a device."""
|
|
291
|
+
await _run_with_retry(
|
|
292
|
+
self._send, self._recv,
|
|
293
|
+
lambda: WriteDriver(zz, pbsb, master_data, self.qq), timeout, tries)
|
|
294
|
+
|
|
295
|
+
async def write(self, msg: MessageDef, values: dict, timeout: float = 3.0) -> None:
|
|
296
|
+
"""Encode `values` for a write message and send it (needs a 2-hex zz)."""
|
|
297
|
+
if not msg.is_write:
|
|
298
|
+
raise ValueError(f"{msg.name}: not a write message")
|
|
299
|
+
if not (msg.zz and len(msg.zz) == 2):
|
|
300
|
+
raise ValueError(f"{msg.name}: no destination address to write to")
|
|
301
|
+
await self.write_raw(int(msg.zz, 16), msg.pbsb, msg.encode(values), timeout=timeout)
|