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 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)