Skip to main content

Wire mechanics for RS232 device drivers: framing, pacing, exclusivity, sequence anchoring, dispatch, reconnect, and liveness behind a callback API

Project description

serialkit

Test PyPI Python

Wire mechanics for RS232 device drivers, built on serialx.

serialkit handles framing a byte stream, pacing writes, the read loop, liveness, reconnect, and the anchoring that keeps a late reply from being read as the next command's answer — behind an asyncio.Protocol-flavoured callback API.

Your driver owns a SerialLink and implements DeviceHandler, so your library keeps its own public API.

Installation

pip install serial-toolkit

# To talk to a device over an ESPHome serial proxy:
pip install 'serial-toolkit[esphome]'

The distribution is named serial-toolkit on PyPI; the import package is serialkit (import serialkit). Requires Python 3.14+.

Scope

serialkit owns the wire: framing, pacing, exclusivity, sequence anchoring, dispatch, reconnect, and liveness.

It knows nothing about any device: no commands, no responses, no state. Your device model lives in your library as an ordinary attribute, with no kit contract attached to it.

Concepts

  • One dispatch task per connection. It reads the transport, frames each chunk, and calls your sync on_frame once per frame in order. A crash in on_frame is recorded in frame_errors; the loop and the rest of the chunk carry on.
  • on_turn() marks the end of a read chunk, once every frame in it has reached on_frame. Flush your queued events here and a burst of frames becomes a single notification.
  • on_connect() runs with frames already flowing, so send, sweep, expect and exchange all work inside it. Put your full re-query here and every reconnect refreshes your state for free.
  • Two ways to wait, matching how your device talks. exchange() is request/reply for a device where every frame answers something you sent: it holds the wire, sends, and returns the next frame to arrive. expect() waits for a frame matching a predicate, for a device that reports state on its own schedule — where a frame often answers nothing in particular.
  • The transport is injected. You give SerialLink an async connect factory returning a duck-typed (reader, writer). In production that wraps serialx.open_serial_connection; in tests it's serialkit.testing.FakeLink.

Minimal driver

from serialx import open_serial_connection

from serialkit import DelimiterFramer, IdleProbe, Pacing, SerialLink

QUERY_FRAMES = [b"POW?;", b"VOL?;", b"MUT?;"]


class MyReceiver:
    def __init__(self, port: str) -> None:
        self.state = ReceiverState()
        self.link = SerialLink(
            connect=lambda: open_serial_connection(url=port, baudrate=115200),
            framer=DelimiterFramer(b";", strip=b"\x00"),
            handler=self,
            pacing=Pacing(min_interval=0.03),
            liveness=IdleProbe(idle=60.0, probe=b"POW?;", attempts=3),
        )
        self._events: list[Event] = []

    # ---- the DeviceHandler callbacks ----

    def on_frame(self, frame: bytes) -> None:
        event = parse_event(frame)  # a pure function you can unit-test
        if event is not None:
            apply_event(self.state, event)  # ...and so is this
            self._events.append(event)

    def on_turn(self) -> None:
        events, self._events = self._events, []
        for subscriber in self._subscribers:
            subscriber(events)  # one callback per turn, batched

    async def on_connect(self) -> None:
        await self.link.send(b"ECH1;")  # enable auto-reports
        await self.link.sweep(QUERY_FRAMES)  # the full refresh

    def on_disconnect(self, exc: Exception | None) -> None:
        for subscriber in self._subscribers:
            subscriber([Disconnected(reason=exc)])  # consumers go unavailable

    # ---- your public API ----

    async def set_volume(self, db: float) -> None:
        await self.link.send(f"VOL{db:g};".encode())

    async def power_on(self) -> None:
        # Send, wait ~1s, send again: a standby MCU consumes the first frame
        # waking up, so the second one is what acts.
        await self.link.confirm(
            nudge=b"POW1;",
            match=lambda f: f.startswith(b"POW"),
            timeout=1.0,
            retries=1,
        )

on_turn and on_disconnect are optional — omit them if you don't need them.

Waiting for a frame

# Arm BEFORE the send, so a reply in the same read chunk isn't missed.
waiter = link.expect(is_power_report, timeout=1.0)
await link.send(b"POW1;")
frame = await waiter

expect() observes without consuming: the frame still reaches on_frame, because the frame that confirms a power-on is also the state report you must apply. confirm() is the wrapper that arms, sends, waits, and retries.

For a device whose replies carry no identifier and are only decodable against the outstanding command, hold the wire instead:

async with link.exchange() as ex:
    await ex.send(encode_query(fn))
    frame = await ex.next(timeout=2.0)

An exchange claims the frames it reads, so they do not reach on_frame. Its reply is anchored by arrival order, recorded at write time: a late reply to a previous, timed-out exchange lands behind the anchor and is discarded rather than being read as this one's answer.

Frame routing order, per arriving frame:

  1. an open exchange that is expecting a reply claims it;
  2. otherwise every armed expect waiter whose predicate matches resolves;
  3. on_frame(frame) runs regardless of step 2.

Liveness

Two shapes, matching how the device behaves when it is healthy:

IdleProbe(idle=60.0, probe=b"POW?;", attempts=3)  # silence is evidence
FailureCount(consecutive=3)  # unanswered commands are

A device that reports state spontaneously goes quiet when the link dies, so an idle window catches it. Set probe=None for one chatty enough that silence alone is the signal.

A device that speaks only when spoken to is silent at rest, so its unanswered commands are the evidence instead. This is what notices a proxy whose connection died while the port still looks open.

Both judge liveness by idle-window checkpoints — was there any RX during this window? — which is immune to scheduling jitter.

Sharp edges

  • A command either goes out now or raises ConnectionLostError — before start(), during backoff, after stop(). Commands are never buffered for a later connection, because a volume change delivered 60 seconds late is the wrong answer.
  • Pacing is settle-after. The interval selected for a frame is the minimum delay after it is sent. A ;-chained command written once passes the send path once and is one pacing unit.
  • A raise from on_connect fails the connection — it propagates out of start() on the first attempt and triggers backoff-and-retry on a reconnect.
  • The framer is a prototype, deep-copied and reset per connection, so no connection inherits another's residual buffer.
  • sweep() reports completion, not per-frame results. An unanswered nudge is a field that never gets an event.

Testing

serialkit.testing ships the doubles, so nothing needs real hardware:

link = FakeLink()
link.respond({b"POW?\r": b"POW1\r"})
dev = MyDevice(link.connect)
await dev.start()

The fault shapes match what real transports do, so a recovery path is exercised in the form it actually takes:

drop() EOF (b"") — the socket-family graceful FIN
drop(abrupt=True) read() raises OSError(EIO) — a serial unplug
fail_writes(silent=True) writes accepted and discarded, reproducing the fd transport where a failed os.write returns normally
hang_connect() a connect that never resolves, for connect_timeout

FakeClock drives the time_func/sleep_func seam, so a 60-second idle window costs no real time. The seam covers the sleeps the kit initiates — pacing, backoff, the liveness window, the sweep quiet window — which are the expensive ones.

expect/confirm/Exchange.next timeouts stay on loop time, which keeps them accurate deadlines on external events: a clock whose sleep returns immediately would win every race against a real future and fire every timeout instantly. They are short by nature, so tests pass small values.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

serial_toolkit-0.2.0.tar.gz (21.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

serial_toolkit-0.2.0-py3-none-any.whl (25.4 kB view details)

Uploaded Python 3

File details

Details for the file serial_toolkit-0.2.0.tar.gz.

File metadata

  • Download URL: serial_toolkit-0.2.0.tar.gz
  • Upload date:
  • Size: 21.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for serial_toolkit-0.2.0.tar.gz
Algorithm Hash digest
SHA256 ba934ce497639e34e4591efda6c604a8c3e70f3683a945b5660e09e92dfc11b7
MD5 ca2bfa95178e59e5ed221828bb70e0f6
BLAKE2b-256 8903dd678e67264421eeacc993fa13b102e02a256ffea3665930446dde621747

See more details on using hashes here.

Provenance

The following attestation bundles were made for serial_toolkit-0.2.0.tar.gz:

Publisher: publish.yml on bbangert/serialkit

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file serial_toolkit-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: serial_toolkit-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 25.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for serial_toolkit-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8d4201ad574da7e93b1880e1804d989f109b36d6edbeed2de5708fb098e5355e
MD5 09ae78e505988ec6a53a325e4d41c21c
BLAKE2b-256 675646939e8a112c38cbff8a8b9985a8cc93a9427c8033bc3f8feccbbdcc3f08

See more details on using hashes here.

Provenance

The following attestation bundles were made for serial_toolkit-0.2.0-py3-none-any.whl:

Publisher: publish.yml on bbangert/serialkit

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page