Skip to content

fix(deb): address RFCOMM directly instead of rebuilding CPython - #8

Merged
impuls42 merged 5 commits into
mainfrom
fix/rfcomm-without-libbluetooth
Jul 29, 2026
Merged

fix(deb): address RFCOMM directly instead of rebuilding CPython#8
impuls42 merged 5 commits into
mainfrom
fix/rfcomm-without-libbluetooth

Conversation

@impuls42

Copy link
Copy Markdown
Member

Supersedes the approach in #7 (and the unmerged follow-up on fix/af-bluetooth-deb).

The bug

$ labelfab probe --mac AA:FD:FD:6B:9F:5F
labelfab: D30ConnectError: this platform has no AF_BLUETOOTH; use transport 'serial' with rfcomm bind

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:

python-build-standalone source build
shared libs glibc only (6) + libssl, libcrypto, liblzma, libsqlite3, libreadline, libncursesw, libffi
declared in depends: yes no
build time (amd64) ~29 s minutes
arm64 release leg download full CPython build under QEMU

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_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 it is nine bytes of stable kernel ABI.

So device/_rfcomm.py builds sockaddr_rc and hands it to libc connect(2). Past connect an RFCOMM socket is an ordinary stream, so sendall/recv/settimeout/close and the reader thread are untouched. Connect does non-blocking + select + SO_ERROR the way the socket module does internally, so connect_timeout_s still means something.

Also drops DEFAULT_TRANSPORT, which chose afbluetooth vs ble by sniffing hasattr(socket, 'AF_BLUETOOTH') — one agent.toml behaved differently depending on how the python reading it was compiled, and it dragged the config layer into importing the device layer. The platform guard is sys.platform now, 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') getsockname after bind
python-build-standalone 3.12.13 False 1f000000000000000000
system 3.12.3, via the shim True 1f000000000000000000
system 3.12.3, native True 1f000000000000000000

Control: addrlen=8 instead of 10 gives EINVAL, 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 .deb installed in a debian:bookworm container:

AF_BLUETOOTH constant present: False
link established: True
  tag 0x17 bt_chip_type = 3
  tag 0x07 firmware = '2.1.2'
  tag 0x08 serial = 'Q223P4C31420105'
  tag 0x06 paper_state = PaperState(raw=137)

hot_state 0xA8 on the earlier run is what HARDWARE-NOTES.md predicts for OK, so this re-validates the RE as well as the transport. The originally failing labelfab 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.hardware end-to-end behind LABELFAB_TEST_MAC.

The build-time check is now behavioural rather than hasattr, and tolerates EAFNOSUPPORT — build hosts and the QEMU arm64 leg have no bluetooth module, so the old check could not have caught a real regression either:

bundled interpreter OK
RFCOMM addressing OK (no kernel bluetooth on the build host; encoding verified)

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.
@impuls42

Copy link
Copy Markdown
Member Author

Release matrix verified locally against the real deploy/build-deb.sh, both legs:

leg time artifact build-time check
amd64 28.7 s 49.3 MB RFCOMM addressing OK (no kernel bluetooth on the build host; encoding verified)
arm64 (QEMU) 3 m 36 s 43.6 MB same

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 apt-get and pip install; resolving and unpacking the aarch64 python-build-standalone asset is seconds, so the asset-matching regex still finds it.

Also confirmed on the installed amd64 package, in a --net=host debian:bookworm container against the printer: link established and four status frames decoded (firmware 2.1.2, serial Q223P4C31420105), with the bundled interpreter linking glibc only.

impuls42 added 4 commits July 30, 2026 00:53
…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
impuls42 merged commit 7920067 into main Jul 29, 2026
1 check passed
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.
impuls42 added a commit that referenced this pull request Jul 30, 2026
RFCOMM addressing without libbluetooth (#8), and the printer's own state on the
status topic (#9) -- including three defects that made the shipped defaults print
nothing: an over-wide raster the printer refused, a completion wait that always
expired, and a density knob the agent could not reach.
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