Skip to content

Serial: pyserial-asyncio -> serialx - #156

Open
markus-becker-tridonic-com wants to merge 12 commits into
sde1000:masterfrom
tridonic-com:serial
Open

markus-becker-tridonic-com wants to merge 12 commits into
sde1000:masterfrom
tridonic-com:serial

Conversation

@markus-becker-tridonic-com

@markus-becker-tridonic-com markus-becker-tridonic-com commented Oct 2, 2026 •

Copy link
Copy Markdown

pyserial-asyncio -> serialx

Move from serial_asyncio to serialx (due to the preference of HomeAssistant: https://developers.home-assistant.io/blog/2026/04/27/pyserial-to-serialx?_highlight=serialx.

The new serialx dependency requires 3.10+.

Harden the LUBA RS232 driver and control-device autodiscovery

Summary

A series of robustness fixes for the Lunatone LUBA RS232 driver (DriverLubaRs232), plus one fix to control-device autodiscovery that the driver changes surface. Together they make the driver usable against real Lunatone hardware with an integrated power supply and a busy, event-streaming bus.

What's included

  • Migrate off the deprecated pyserial-asyncio to serialx, which offers a compatible create_serial_connection/SerialTransport API. Updates the driver-serial and test extras.
  • Switchable bus power (bus_power option): Lunatone interfaces with an integrated PS (e.g. the DALI-2 USB PS) leave the bus unpowered by default, so every frame fails with "error in transmission: 1". Adds the opt-in setting (HardwareSettings bit 7) plus the two queries needed to decide safely whether to switch it on — QUERY DEVICE DESCRIPTOR (switchable-PS capability) and READ STATUS (current bus voltage error).
  • Fail fast on a rejected TX frame: when the interface reports an "ADD DALI FRAME TO TX BUFFER" error, no frame reaches the bus and the usual confirmation never arrives, so the driver blocked until timeout. Now a TransmissionError carrying the interface's reason code (LubaTxError) is raised immediately.
  • Quieter logging: undecodable transmit echoes (e.g. extended DT8 colour queries that round-trip imperfectly but were sent fine) drop from warning to debug; stale leftover RX frames — expected on a busy bus — are drained fully and logged at debug instead of CRITICAL, fixing two queue-draining bugs along the way.
  • Report timed-out queries with the command's response type instead of a bare Response(None), so a timeout is recognised as a missing value rather than leaking None into callers (which crashed autodiscovery on int(None)).
  • Retry the connect handshake on a busy bus, draining any late LUBA reply between attempts so it isn't mistaken for the next command's response.
  • Mark the DALI-2 USB PS (article nr. 24138246) as tested.
  • Make control-device autodiscover resilient to event collisions: a spontaneous input-device event can corrupt the first QueryDeviceStatus backward frame, making a present device look absent. The scan now lets the bus settle after entering quiescent mode and retries a framing-error probe while still treating a genuinely missing response as an empty address. Both the settle delay and retry count are tunable.

Testing

python3 -m pytest dali/tests/ — 118 passed. New coverage added for the timed-out-query response type, the stale-RX drain, the connect-handshake retry, and the autodiscover event-collision handling.

pyserial-asyncio is deprecated. Switch the RS232 serial drivers to
serialx, which provides a compatible create_serial_connection and
SerialTransport API, and update the driver-serial and test extras.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lunatone DALI interfaces with an integrated power supply (e.g. the DALI-2
USB PS) leave the bus unpowered by default, so every transmission fails
with "error in transmission: 1".

Add an opt-in `bus_power` option to DriverLubaRs232 that sets HardwareSettings
bit 7 when writing the device settings, switching on the interface's bus
power supply.

To let callers decide safely (a separate DALI power supply must not clash
with the interface's own), implement the two queries needed to detect the
situation:

- QUERY DEVICE DESCRIPTOR (0x28/0x29): exposes whether the interface has a
  switchable bus power supply (HardwareFeature bit 7).
- READ STATUS (0x2C/0x2D): exposes whether the bus currently has a voltage
  error, i.e. is unpowered (Status bit 7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the LUBA device reports an "ADD DALI FRAME TO TX BUFFER" error, no
frame reaches the bus, so the "frame sent" event that normally confirms a
transmission never arrives. send_dali_command() therefore blocked until
the confirmation timeout before failing with a generic error.

Surface the rejection immediately: enqueue a transmission confirmation
carrying the interface's reason code, and raise a new TransmissionError
from send_dali_command() as soon as it is dequeued. The reason codes are
enumerated in LubaTxError (LUBA documentation section 2.1.1), letting
callers act on specific causes such as an unpowered bus (bus voltage
error).

Also log frames that cannot be decoded from their transmit echo at debug,
including the raw bytes, to aid diagnosis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The LUBA device echoes each transmitted frame back so the driver can
verify it against what it sent. Some valid frames (for example extended
DT8 colour queries) cannot yet be round-tripped through
Command.from_frame, but the frame was still sent successfully, as the
interface itself confirmed. Emitting a warning for every such frame floods
the log during normal polling for a purely cosmetic decode gap, so drop it
to debug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DALI-2 USB PS (article nr. 24138246) has been exercised successfully
with this driver, so add it to the set of tested article numbers alongside
24166096. Collect them in TESTED_ARTICLE_NUMBERS and widen the "untested
device" warning to list every tested number.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A query that received no reply defaulted to a bare Response(None). That
response passes check_bad_rsp while its value is None, so a timed-out
QueryInstanceType leaked None into control-device autodiscovery and
crashed on int(None), aborting driver setup.

Default a query to the command's own response type instead, so a timeout
is reported as a missing value and check_bad_rsp rejects it as expected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Before each transaction the drivers clear any DALI frame left buffered
from a previous exchange. A backward frame that arrives after its query's
receive timeout is such a leftover and is expected on a busy bus, yet it
was logged at CRITICAL on every occurrence, flooding the log.

Two bugs made this worse: the queue was reported as holding N items but
only a single item was ever discarded, and the SCI info-queue branch
drained the raw-frame queue by mistake, so the info queue was never
cleared. Drain each queue fully through a shared helper and log the
discard at debug level.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The initial device-info handshake can time out while the bus is busy (for
example when an input device is streaming events), which failed the whole
connect. Retry the handshake a few times, draining any late LUBA reply
left in the RX queue between attempts so it is not mistaken for the next
command's response, and only give up after the configured attempt count.
A spontaneous event from an input device can corrupt the backward frame
of the first QueryDeviceStatus probe during a scan, which made a present
device (and all its instances) look absent and get dropped. Let the bus
settle after entering quiescent mode, and retry a status probe that comes
back as a framing error while still treating a genuinely missing response
as an empty address. Both the settle delay and retry count are tunable.
The serial driver now depends on serialx, which requires Python >=3.10.
Raise python_requires accordingly and drop 3.8/3.9 from the CI matrix,
since the test extra pulls in serialx and cannot install on older
interpreters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The driver's own event only records that the handshake once succeeded, so
an unplugged USB interface still claimed to be connected and every frame
ran into its transmit timeout instead of failing fast. For drivers backed
by an asyncio transport the protocol, which is told about the loss through
connection_lost(), now has the last word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The serial drivers depend on API introduced in serialx 1.11.0, so raise
the floor on both the driver-serial and test extras.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant