fix(deb): address RFCOMM directly instead of rebuilding CPython - #8
Merged
Conversation
The .deb could not open an RFCOMM socket:
D30ConnectError: this platform has no AF_BLUETOOTH; use transport 'serial'
with rfcomm bind
#7 fixed that by compiling CPython from source against libbluetooth-dev. That
works, but it gives up the property the bundled interpreter exists for. A
python-build-standalone build links glibc and nothing else; a source build links
the build container's libssl, libcrypto, liblzma, libsqlite3, libreadline,
libncursesw and libffi, none of which are copied into /opt/labelfab and none of
which are in depends:. That is the same soname drift 33b629a set out to kill,
and it would surface far from here -- MQTT over TLS dying on some target distro.
It also puts a full CPython build inside the QEMU-emulated arm64 release leg.
None of it is necessary. AF_BLUETOOTH is a kernel feature. CPython gates only
two compile-time things on bluetooth/bluetooth.h: the constants, and the
sockaddr_rc marshalling inside getsockaddrarg(). socket(31, SOCK_STREAM, 3) is
a plain syscall that works on any build -- only the address encoding is missing,
and that is nine bytes of stable kernel ABI.
So device/_rfcomm.py encodes sockaddr_rc and hands it to libc connect(2). Past
connect, an RFCOMM socket is an ordinary stream, so sendall/recv/settimeout/
close are untouched and the reader thread is unaffected. Connect goes
non-blocking + select + SO_ERROR, the way the socket module does it internally,
so connect_timeout_s still means something.
Verified against the printer from a python-build-standalone interpreter with no
socket.AF_BLUETOOTH: link established, and CHIP_TYPE/FIRMWARE_VERSION/SERIAL/
PAPER_STATE decode to firmware 2.1.2, serial Q223P4C31420105, hot_state 0xA8 --
which is what HARDWARE-NOTES predicts. Then again from the built .deb installed
in a bookworm container, where the bundled interpreter still links only glibc.
Also drops DEFAULT_TRANSPORT. It picked afbluetooth or ble by sniffing
hasattr(socket, 'AF_BLUETOOTH'), which meant one agent.toml behaved differently
depending on how the python reading it had been compiled, and it dragged the
config layer into importing the device layer. The transport default is a literal
again. The platform guard is now sys.platform, which is the honest question:
family 31 is not RFCOMM off Linux.
Tests: byte-exact vectors for the encoder (address reversal and the trailing pad
are silent to get wrong -- a short sockaddr returns EINVAL, which reads like a
caller bug), a differential test binding through both the shim and the socket
module and comparing raw getsockname, and a hardware-marked end-to-end behind
LABELFAB_TEST_MAC.
Member
Author
|
Release matrix verified locally against the real
The arm64 leg is the one that mattered most — under the source-build approach it meant compiling CPython under emulation. Nearly all 3 m 36 s here is emulated Also confirmed on the installed amd64 package, in a |
…sport names Review follow-ups on #8. A malformed device.mac reached _rfcomm.connect and raised ValueError. The worker only catches D30Error, so that took down the print loop rather than failing the one job, and the socket opened a line earlier was never closed. It is now a D30ConfigError with retryable=False, and the fd is closed on the way out. Deliberately not a D30ConnectError: that one means "the printer is away", which the worker is right to retry -- a typo in an address is not, and retrying it would just hide the typo behind a reconnect loop. DEFAULT_TRANSPORT comes back, but as a plain literal alongside TRANSPORTS rather than the hasattr() sniff that #8 removed. The CLI's choices list and the config's regex were two copies of the same four names; they now come from one place, so a new transport is named once. cli.py imports the device layer at module scope for this, which it otherwise avoids to keep `preview` snappy -- measured at ~23ms on a ~160ms import, which is not enough to justify a function-local import. build-deb.sh pins the python-build-standalone release instead of following latest. Rebuilding an old tag now reproduces the interpreter it shipped with, and a PBS release cut between the amd64 and arm64 legs of one matrix can no longer give them different interpreters. PBS_RELEASE overrides it. EINTR keeps its place beside EINPROGRESS/EALREADY, now with a comment saying why: an interrupted connect still completes asynchronously, so waiting for writability is correct and calling connect() again would return EALREADY. Tests cover both halves of the MAC bug -- the error type and retryable flag, and that twenty failed opens do not leak fds.
Every other D30 error is both imported into the device package and listed in __all__; this one was not. Nothing depended on it -- the worker dispatches on the D30Error base -- but the public surface should not have a hole in it.
… around it The bad-MAC test caught OSError to skip on hosts with no bluetooth module, but open() wraps EAFNOSUPPORT into a D30ConnectError, which is not an OSError. So on exactly those hosts the guard missed, pytest.raises saw a non-matching exception, and the test failed rather than skipping -- which is what CI does, since GitHub runners have no bluetooth module loaded. All three kernel-touching tests now share one preflight that opens and closes a socket first, the approach the fd-leak test already used. Verified by forcing EAFNOSUPPORT locally: 12 passed, 4 skipped, nothing failed.
D30ConfigError was introduced as non-retryable, and its docstring said so, but the worker's connect loop caught the D30Error base and retried unconditionally -- so a malformed device.mac still burned all three attempts and ~13s of backoff per job before failing. The print path a few lines below already gated on exc.retryable; the connect path only looked like it did. Two tests pin both directions: a permanent failure gets one attempt, a transient one still gets the full budget. Verified the first fails (3 != 1) without the one-line change.
impuls42
added a commit
that referenced
this pull request
Jul 30, 2026
… state (#9) * feat(agent): publish device truth on the status topic, not just agent state The retained PrinterStatus carried idle/printing/disconnected -- facts about the agent, not the printer. Everything the D30 actually reports was decoded and then dropped on the floor. InvenTree's machine driver reads that topic, so the Machines page could say "connected" about a printer with no tape in it. PrinterStatus now carries firmware, battery_pct, voltage_v and media_ok, and settles to state="error" with the reason in `error` when the printer is complaining. DeviceFeedback grew fault(), which folds the three ways it complains -- media bit clear (0x06), material error (0x3F), print cancelled (0x0B) -- into one line. It is recomputed per batch, so a cleared media error clears the status instead of latching. Feedback is now captured on the *failure* path too. It was only read after a successful print, which threw away the diagnosis in exactly the case where one was available: the fault is usually why the print failed. Three response tags were being logged as unknown and skipped. All three were observed live on fw 2.1.2 this session: 0x2F VOLTAGE 2 bytes big-endian, 10mV units -> 4.16V 0x2D SENSOR_INFO 13 bytes, layout still unknown 0x11 HARDWARE_VERSION 3 bytes -> 1.0.3 Voltage earns its place because BATTERY reported 100% for the entire time the unit was on charge, so it cannot answer "will this survive a long strip". Voltage tracked 4.16 -> 4.17V against 4.09V on the discharged unit's own info label. It is queried via a separate TELEMETRY_QUERIES set rather than being added to session_setup, because that sequence is pinned byte-for-byte to the vendor capture and a wire diff against the vendor app is worth keeping meaningful. Three defects found while verifying against the hardware: post_print_margin_s was 0.3s, but 0x0F lands ~3.0s after the last raster byte -- roughly 7x what the head's line rate predicts. The budget expired before the printer ever answered, so await_print_complete could not succeed on short labels and silently degraded to the duration guess it exists to replace. Now 3.5s, which costs nothing on the happy path since the wait returns as soon as the frame lands. device.raster_width_px was declared in the config and read by nothing, while the worker rendered at tape.width_mm -- default 15mm, which is 120px against a 96-dot head. The printer refuses an over-wide raster rather than clipping it: 120px came back print_cancelled (1a0bb8) and printed nothing, the same label at 96px printed. So the shipped default cancelled every job. Rendering is capped at the head now. The agent had no density knob at all, so D30Config's medium default always won whatever an operator wanted. Added, defaulting to light -- verified scannable by photographing a light-density QR with a webcam and decoding it with zxing-cpp. agent.toml.example switches to the afbluetooth transport: SPP is the throughput choice per HARDWARE-NOTES, and the relocatable interpreter can address RFCOMM as of #8. * docs(feedback): note that only paper_state is re-queried on connect The review asked whether a material fault survives a reconnect. It does not, and nothing can make it: material_error (0x3F) and print_cancelled (0x0B) arrive only unsolicited, and ALL_ERROR (1f1128) -- the opcode that would poll them -- was verified inert on fw 2.1.2 this session. Only paper_state is in the vendor session set and therefore re-asserted per connection. Recorded on fault() so a clean return is not mistaken for proof the consumable is fine.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes the approach in #7 (and the unmerged follow-up on
fix/af-bluetooth-deb).The bug
Why not compile CPython
#7 fixed it by building CPython from source against
libbluetooth-dev. It works, but it costs the exact property the bundled interpreter exists for:libssl,libcrypto,liblzma,libsqlite3,libreadline,libncursesw,libffidepends:Those libraries come from the build container and are not copied into
/opt/labelfab. That is the soname drift 33b629a set out to kill, and it would surface far from the commit that caused it — MQTT over TLS dying on some target distro.What this does instead
AF_BLUETOOTHis a kernel feature. CPython gates only two compile-time things onbluetooth/bluetooth.h: the constants, and thesockaddr_rcmarshalling insidegetsockaddrarg().socket(31, SOCK_STREAM, 3)is a plain syscall that works on any build — only the address encoding is missing, and it is nine bytes of stable kernel ABI.So
device/_rfcomm.pybuildssockaddr_rcand hands it to libcconnect(2). Past connect an RFCOMM socket is an ordinary stream, sosendall/recv/settimeout/closeand the reader thread are untouched. Connect does non-blocking +select+SO_ERRORthe way the socket module does internally, soconnect_timeout_sstill means something.Also drops
DEFAULT_TRANSPORT, which choseafbluetoothvsbleby sniffinghasattr(socket, 'AF_BLUETOOTH')— oneagent.tomlbehaved differently depending on how the python reading it was compiled, and it dragged the config layer into importing the device layer. The platform guard issys.platformnow, which is the honest question: family 31 is not RFCOMM off Linux.Verification
Equivalence — hand-rolled vs what CPython would have produced, same host:
hasattr(socket,'AF_BLUETOOTH')getsocknameafter bindFalse1f000000000000000000True1f000000000000000000True1f000000000000000000Control:
addrlen=8instead of10givesEINVAL, so a bad encoding is loud rather than silent.Against the printer, from an interpreter with no
AF_BLUETOOTH— and then again from the built.debinstalled in adebian:bookwormcontainer:hot_state 0xA8on the earlier run is what HARDWARE-NOTES.md predicts for OK, so this re-validates the RE as well as the transport. The originally failinglabelfab probe --mac ...now reaches "nothing to do", and the installed interpreter still links only glibc.Tests — 215 pass. Byte-exact vectors for the encoder (the address reversal and trailing pad are silent to get wrong), a differential test that binds through both paths and compares raw
getsockname, and a@pytest.mark.hardwareend-to-end behindLABELFAB_TEST_MAC.The build-time check is now behavioural rather than
hasattr, and toleratesEAFNOSUPPORT— build hosts and the QEMU arm64 leg have no bluetooth module, so the old check could not have caught a real regression either: