From 4ed724719352c30723d17d2aecd91177d47b9674 Mon Sep 17 00:00:00 2001 From: Fedir Date: Wed, 14 Oct 2026 15:35:09 -0400 Subject: [PATCH 01/14] docs(openspec): propose Bluetooth transport and configurable idle polling --- .../add-bluetooth-transport/.openspec.yaml | 2 + .../changes/add-bluetooth-transport/design.md | 78 +++++++++++++++++++ .../add-bluetooth-transport/proposal.md | 35 +++++++++ .../specs/printer-device/spec.md | 47 +++++++++++ .../specs/server-architecture/spec.md | 56 +++++++++++++ .../changes/add-bluetooth-transport/tasks.md | 33 ++++++++ 6 files changed, 251 insertions(+) create mode 100644 openspec/changes/add-bluetooth-transport/.openspec.yaml create mode 100644 openspec/changes/add-bluetooth-transport/design.md create mode 100644 openspec/changes/add-bluetooth-transport/proposal.md create mode 100644 openspec/changes/add-bluetooth-transport/specs/printer-device/spec.md create mode 100644 openspec/changes/add-bluetooth-transport/specs/server-architecture/spec.md create mode 100644 openspec/changes/add-bluetooth-transport/tasks.md diff --git a/openspec/changes/add-bluetooth-transport/.openspec.yaml b/openspec/changes/add-bluetooth-transport/.openspec.yaml new file mode 100644 index 0000000..49ea85b --- /dev/null +++ b/openspec/changes/add-bluetooth-transport/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-14 diff --git a/openspec/changes/add-bluetooth-transport/design.md b/openspec/changes/add-bluetooth-transport/design.md new file mode 100644 index 0000000..4b41cbf --- /dev/null +++ b/openspec/changes/add-bluetooth-transport/design.md @@ -0,0 +1,78 @@ +# Design + +## Context + +See [proposal.md](proposal.md) for motivation and the capability deltas for the behavioral contract. + +[verified] All printer I/O uses the `ptouch://` libusb scheme in `src/device_usb.c`. `src/driver.c` gates every live status read on `pt_usb_is_uri()` and reads through `pt_usb_read_status()`; `status_cb` (`src/driver.c:612`) also counts USB devices before opening one. A URI that is not a supported USB URI (the socket test transport) skips live status entirely. + +[verified] PAPPL 1.4.11 invokes `status_cb` from `papplPrinterGetReasons()` (`pappl/printer-accessors.c:528`) whenever the printer is idle and the last call is more than one second old. Idle polling is therefore driven by client queries (CUPS, web UI, IPP monitors), not by a timer. When a job cannot open its device, PAPPL stops the queue and retries the open every 5 seconds (`pappl/job-process.c:1192-1213`) until it succeeds or the job is canceled. That retry is independent of `status_cb`. + +[verified] The PT-P710BT accepts the same raster command stream over Bluetooth Classic RFCOMM, channel 1, and replies to the status request with the same 32-byte packet. [robby-cornelissen/pt-p710bt-label-maker](https://github.com/robby-cornelissen/pt-p710bt-label-maker) sends invalidate, initialize, status request, mode settings, raster and print over one RFCOMM socket and parses the reply with the same offsets as `src/status.c`. + +[verified] Mainline Linux refuses Bluetooth sockets outside the initial network namespace (`net/bluetooth/af_bluetooth.c`: `if (net != &init_net) return -EAFNOSUPPORT;`). RFCOMM socket creation requires no capability (`net/bluetooth/rfcomm/sock.c` has no `capable()` check). + +## Goals / Non-Goals + +**Goals:** + +- One queue, one configured transport, chosen at startup, with no runtime fallback between transports. +- Reuse the existing driver lifecycle, status parsing, finishing and recovery code unchanged above the device layer. +- One polling setting that means the same thing on every transport. +- The same image tags run on the existing amd64 host and on a Pi Zero 2 W. + +**Non-Goals:** + +- Bluetooth discovery or pairing inside the server. Pairing is a one-time host step with `bluetoothctl`. +- Bluetooth for PT-2730 (no radio) or for recognized-only models. +- Bluetooth Low Energy, Wi-Fi, or printing from outside Bluetooth range. +- Battery reporting or auto-power-off control. + +## Decisions + +### Bluetooth is a second PAPPL device scheme + +Add `src/device_bt.c` registering a `ptouch-bt://` scheme (`ptouch-bt://Brother/PT-P710BT/
`) with open, close, read, write and status callbacks over a blocking `AF_BLUETOOTH`/`BTPROTO_RFCOMM` socket on channel 1. Open performs one bounded connect; close shuts the socket. PAPPL already opens the device per job and per status read, so per-job connection needs no extra state. + +The driver's USB-only gates become transport-neutral: one function says whether a URI is a live printer transport (USB or Bluetooth), and one bounded status read dispatches by scheme. The USB presence count in `status_cb` stays a USB step; Bluetooth has no enumeration, so its reachability check is the open itself. The socket test transport keeps skipping live status. + +Alternatives: extending `ptouch://` with a query parameter mixes two transports in one scheme and its URI guard; a user-space serial bridge (`rfcomm bind` to `/dev/rfcommN`) adds a host service and a TTY layer for no gain. + +### Configuration selects the transport + +`PTOUCH_BT_ADDRESS` set and non-empty selects Bluetooth. It requires `PTOUCH_MODEL=PT-P710BT` and a well-formed `XX:XX:XX:XX:XX:XX` address; anything else fails startup. When it is set, discovery reports only the Bluetooth URI and the queue never touches USB. When it is absent, behavior is exactly today's USB path. + +Alternative: try USB first, then Bluetooth. Rejected: it is a silent fallback and makes the transport in use depend on cabling. + +### Idle polling is rate-limited in `status_cb` + +`PTOUCH_IDLE_POLL_SECONDS` is parsed once at startup. Absent: `status_cb` runs on every PAPPL call, as now. `0`: `status_cb` returns without device access. `N > 0`: `status_cb` returns without device access unless at least `N` seconds passed since its last live read. Skipped calls leave the last published reasons and media in place. Job-owned media refresh and PAPPL's open retry are unaffected, so queued jobs still print when the printer returns. + +The setting applies to both transports. A Bluetooth deployment chooses its value; the documented recommendation is `0` for battery use. + +Alternative: disable idle polling automatically for Bluetooth. Rejected by the owner in favor of an explicit setting, and it would be a per-transport special case. + +### Deployment runs the same container with host networking + +Bluetooth sockets work only in the host network namespace, so a Bluetooth container runs with `--network host`. The image's own dbus and Avahi then share the host's mDNS port; the Pi guide disables the host's `avahi-daemon` so the container's instance is the only responder. Host BlueZ owns pairing and link keys on the Pi's persistent storage; the container needs no BlueZ and no added capability. + +Alternatives: a native systemd build on the Pi duplicates the pinned CUPS/PAPPL patch build outside CI; mounting the host's dbus socket and using host Avahi changes the entrypoint for one deployment. + +### arm64 image built in the same workflow + +Add `linux/arm64` to the publish step's platforms with QEMU emulation, so `edge` and `sha-*` become multi-arch manifests. The amd64 load-and-smoke step stays as is; the arm64 build runs CTest under emulation during `Dockerfile.deploy`. + +Alternative: a native `ubuntu-24.04-arm` job plus a manifest merge. Faster, but adds a second job and digest stitching. Revisit only if the emulated build time is unacceptable. + +## Risks / Trade-offs + +- [The printer may treat a Bluetooth disconnect as a session end and drop a held chain end between jobs] → The acceptance gate tests chain-only continuation across two jobs. If the held end is lost, the change documents that limit rather than altering the continuation contract silently. +- [Walking out of range mid-job leaves output uncertain] → The existing uncertain-output contract applies: the queue blocks and requires power-cycle plus restart. Documented as the expected cost of losing the link during a print. +- [A connect to an absent printer blocks for the Bluetooth page timeout, about 5 seconds] → Status reads are skipped while a job owns the device, and polling `0` avoids idle connects entirely. +- [Writes may need pacing over RFCOMM] → Blocking writes rely on RFCOMM flow control. If acceptance shows corruption, add pacing then, with evidence. +- [Emulated arm64 build may be slow] → Measured in CI; switch to a native runner only if it blocks routine merges. +- [Pi Zero 2 W range is roughly 10 m indoors] → An operational limit, documented, not mitigated. + +## Migration Plan + +Existing amd64 USB deployments need no change: both new variables stay unset and the amd64 image is unchanged. The Pi deployment is new: flash Raspberry Pi OS Lite, install Docker, pair and trust the printer, disable host `avahi-daemon`, run the image with `--network host`, `PTOUCH_MODEL=PT-P710BT`, `PTOUCH_BT_ADDRESS` and `PTOUCH_IDLE_POLL_SECONDS`. Rollback: stop the Pi container and return to the USB deployment on vault. diff --git a/openspec/changes/add-bluetooth-transport/proposal.md b/openspec/changes/add-bluetooth-transport/proposal.md new file mode 100644 index 0000000..a33b08d --- /dev/null +++ b/openspec/changes/add-bluetooth-transport/proposal.md @@ -0,0 +1,35 @@ +# Proposal + +## Why + +The owner wants the PT-P710BT to run on its own battery and travel around the house, while the print server stays on a wall-powered Raspberry Pi Zero 2 W. Today every status read and print goes through the USB-only `ptouch://` scheme, and the published image is amd64-only, so the printer must stay cabled to an amd64 host. + +## What Changes + +- Add a Bluetooth RFCOMM transport for the PT-P710BT, selected by configuring the printer's Bluetooth address (`PTOUCH_BT_ADDRESS`). The queue then uses only Bluetooth, never USB, and does not fall back to USB. Configuring an address for any other model fails startup. +- Connect only while a job or status read owns the device and disconnect afterwards. The printer stays available to its phone app between jobs. +- Treat an unreachable printer (switched off, out of range, or held by another Bluetooth client) as offline. Queued jobs wait and print when the printer becomes reachable. Reuse the existing offline, media-confirmation, finishing, continuation and uncertain-output contracts unchanged. +- Make idle status polling configurable for every transport with `PTOUCH_IDLE_POLL_SECONDS`. Absent keeps today's behavior, `0` disables idle polling so status refreshes only during jobs, and a positive value sets the minimum seconds between idle polls. An invalid value fails startup. +- Publish the runtime image for `linux/arm64` as well as `linux/amd64` under the same tags. +- Document Pi deployment: pairing, host networking, and the required container settings. + +## Capabilities + +### New Capabilities + +None. Transport selection and status polling belong to the existing printer-device capability. + +### Modified Capabilities + +- `printer-device`: Add Bluetooth device selection and reachability for PT-P710BT, and the configurable idle polling contract. +- `server-architecture`: Extend the private-transport constraint to the Bluetooth scheme, add Bluetooth container deployment, and add the arm64 image. + +`batch-printing` and `label-rendering` remain unchanged: the same command and raster stream travels over either transport. + +## Impact + +Code: a new Bluetooth device scheme beside `src/device_usb.c`, transport-neutral status reads in `src/driver.c`, configuration parsing in `src/main.c`, and polling control in `status_cb`. Tests: fake-socket adapter tests for the Bluetooth scheme and the polling setting, plus core regressions showing unchanged bytes. CI: `.github/workflows/deploy.yml` builds and publishes arm64. Runtime image: no new libraries (RFCOMM uses the kernel socket API). Docs: README, deployment notes, and a bounded Bluetooth acceptance gate. + +No new client attribute, IPP behavior, rendering rule or dependency fork. USB deployments that leave both new variables unset keep their exact current behavior. + +This proposal authorizes planning only. Implementation requires a subsequent apply request; physical tests require a concrete bounded gate, and Pi deployment remains a separate action. diff --git a/openspec/changes/add-bluetooth-transport/specs/printer-device/spec.md b/openspec/changes/add-bluetooth-transport/specs/printer-device/spec.md new file mode 100644 index 0000000..e44ce7a --- /dev/null +++ b/openspec/changes/add-bluetooth-transport/specs/printer-device/spec.md @@ -0,0 +1,47 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: PT-P710BT can use Bluetooth instead of USB + +When `PTOUCH_BT_ADDRESS` is set, the server SHALL reach the selected PT-P710BT only over Bluetooth at that address and SHALL NOT use or fall back to USB. It SHALL send the same print protocol as over USB, hold the connection only while a job or status read owns the device, and treat an unreachable printer as offline. An address with another model, or a malformed address, SHALL fail startup. + +#### Scenario: Bluetooth printer in range +- **WHEN** PT-P710BT is selected with a Bluetooth address and the paired printer is on and in range +- **THEN** jobs print with the same command and raster bytes the USB path would send, and the connection closes after each job + +#### Scenario: Printer unreachable +- **WHEN** a job is queued while the configured printer is off, out of range, or connected to another Bluetooth client +- **THEN** the queue reports the printer unavailable, the job waits without failing, and it prints after the printer becomes reachable and media is confirmed + +#### Scenario: USB printer also attached +- **WHEN** a Bluetooth address is configured and a PT-P710BT is also attached over USB +- **THEN** the server sends no status request or output over USB + +#### Scenario: Address with an unsupported model +- **WHEN** a Bluetooth address is configured and the selected model is PT-2730 or the address is malformed +- **THEN** startup fails with a configuration error naming the problem + +#### Scenario: Link lost during output +- **WHEN** the Bluetooth link fails after page raster has started +- **THEN** the existing uncertain-output recovery contract applies and no new raster is sent until recovery + +### Requirement: Idle status polling is configurable + +`PTOUCH_IDLE_POLL_SECONDS` SHALL control idle status reads on every transport. Absent, idle reads SHALL follow PAPPL's status requests as before. `0` SHALL disable idle device access. A positive integer SHALL set the minimum seconds between idle device reads. Skipped reads SHALL keep the last published status. Status read by jobs SHALL be unaffected. Any other value SHALL fail startup. + +#### Scenario: Polling unset +- **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is absent +- **THEN** idle status behavior is identical to the behavior before this setting existed + +#### Scenario: Polling disabled +- **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is `0` and clients query the idle printer +- **THEN** the server opens no device connection, and the next job still confirms live media before printing + +#### Scenario: Polling rate-limited +- **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is `300` and clients query the idle printer every few seconds +- **THEN** the server reads live status at most once per 300 seconds and reports the last published status between reads + +#### Scenario: Invalid polling value +- **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is negative, fractional, or not a number +- **THEN** startup fails with a configuration error diff --git a/openspec/changes/add-bluetooth-transport/specs/server-architecture/spec.md b/openspec/changes/add-bluetooth-transport/specs/server-architecture/spec.md new file mode 100644 index 0000000..33effcd --- /dev/null +++ b/openspec/changes/add-bluetooth-transport/specs/server-architecture/spec.md @@ -0,0 +1,56 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Supported and unambiguous hardware + +Only the implemented PT-2730 and PT-P710BT paths SHALL receive print output. Missing or empty `PTOUCH_MODEL` SHALL select PT-2730; PT-P710BT SHALL require explicit selection. Unknown or recognized unsupported models SHALL fail configuration. The stable model URI SHALL resolve by the selected VID/PID, accepting exactly one matching device and blocking zero or multiple matches without selecting by enumeration order. When a Bluetooth address is configured, the stable URI SHALL resolve by that address alone. + +#### Scenario: Duplicate supported printers + +- **WHEN** two devices of the selected supported model match the configured queue +- **THEN** the existing queue reports ambiguity and neither device is claimed for printing + +#### Scenario: Two different supported models + +- **WHEN** one PT-2730 and one PT-P710BT are attached and a model is configured +- **THEN** the queue selects only that model, and the other printer does not create ambiguity or receive its output + +#### Scenario: Compatible default + +- **WHEN** no model is configured +- **THEN** the queue retains PT-2730 identity and behavior without automatically adopting an attached PT-P710BT + +#### Scenario: Bluetooth address configured + +- **WHEN** PT-P710BT is selected with a Bluetooth address +- **THEN** the queue's device URI names that address, and USB enumeration plays no part in selection + +## ADDED Requirements + +### Requirement: Private Bluetooth transport + +The adapter SHALL reach a Bluetooth printer through an internal scheme over a kernel RFCOMM socket, without a host serial bridge or in-server pairing. Connects SHALL be bounded. Status readers SHALL validate the owning printer URI before accessing Bluetooth connection data, as for USB. + +#### Scenario: Transport isolation + +- **WHEN** USB, Bluetooth, and socket test URIs are each evaluated by status processing +- **THEN** each reaches only its own transport's operations + +### Requirement: Bluetooth container deployment + +A Bluetooth deployment SHALL run the container in the host network namespace, because the kernel refuses Bluetooth sockets elsewhere. Pairing SHALL be a host step whose keys persist across host reboots. The container SHALL need no BlueZ and no added capability. Exactly one mDNS responder SHALL serve the host. + +#### Scenario: Pi reboot + +- **WHEN** the Pi hosting a paired Bluetooth deployment reboots +- **THEN** the container restarts and prints to the printer without re-pairing + +### Requirement: Multi-architecture runtime image + +Published image tags SHALL include `linux/amd64` and `linux/arm64` variants built from the same `Dockerfile.deploy`, each passing the build's CTest run before publication. + +#### Scenario: Pull on a Pi + +- **WHEN** a Raspberry Pi Zero 2 W running a 64-bit OS pulls a published tag +- **THEN** it receives the arm64 variant of the same build diff --git a/openspec/changes/add-bluetooth-transport/tasks.md b/openspec/changes/add-bluetooth-transport/tasks.md new file mode 100644 index 0000000..dbfe71e --- /dev/null +++ b/openspec/changes/add-bluetooth-transport/tasks.md @@ -0,0 +1,33 @@ +# Tasks + +## 1. Configuration + +- [ ] 1.1 Parse `PTOUCH_BT_ADDRESS` and `PTOUCH_IDLE_POLL_SECONDS` once at startup in `src/main.c`, failing on a malformed address, an address with any model other than PT-P710BT, or a polling value that is not a non-negative integer; verify each accepted and rejected case in a startup test, and that both unset leaves the USB queue URI and identity unchanged. +- [ ] 1.2 Document both variables and their defaults in README; check the text against the 1.1 test cases. + +## 2. Idle polling control + +- [ ] 2.1 Rate-limit `status_cb` by the parsed polling value (absent: unchanged; `0`: no device access; `N`: at most one live read per `N` seconds, keeping the last published status); verify in `test_driver` with a fake clock that unset matches current behavior, `0` opens no device across repeated calls, `N` reads once per window, and job-owned media refresh still runs with polling `0`. + +## 3. Bluetooth transport + +- [ ] 3.1 Add `src/device_bt.*` registering the `ptouch-bt://Brother/PT-P710BT/
` scheme with bounded connect, full write, bounded status read and close over an RFCOMM socket on channel 1; verify URI formatting/parsing and rejection of other models and malformed addresses in a new adapter test using a socketpair in place of RFCOMM. +- [ ] 3.2 Make the driver's live-status gates and status read transport-neutral, keeping the USB presence count USB-only and the socket test transport skipped; verify in `test_driver` that USB, Bluetooth and socket URIs each reach only their own operations, and that an unreachable Bluetooth device publishes offline without the "present but cannot be opened" error. +- [ ] 3.3 When an address is configured, make discovery and queue creation use only the Bluetooth URI; verify with fake USB that an attached USB PT-P710BT receives no status request or output, and that the queue identity is still PT-P710BT. +- [ ] 3.4 Verify byte identity: capture one PNG job per finishing policy over the socketpair transport and compare with the existing P710BT USB captures; any difference fails the task. +- [ ] 3.5 Run the full build and tests in the pinned container (`cmake -S . -B build`, `cmake --build build`, `ctest --test-dir build --output-on-failure`) plus both existing image smoke variants; retain logs. + +## 4. Image and deployment + +- [ ] 4.1 Add `linux/arm64` to the publish step in `.github/workflows/deploy.yml` with QEMU set up; verify on the pull request that the arm64 build runs CTest and record its duration. +- [ ] 4.2 Write the Pi deployment guide in `docs/` (64-bit Raspberry Pi OS Lite, Docker, `bluetoothctl` pair and trust, disabling host `avahi-daemon`, `--network host`, the three variables, recommended polling `0`, about 10 m range, mid-job link loss recovery); review each command against the design and the container's entrypoint. + +## 5. Physical acceptance + +- [ ] 5.1 Prepare a concrete bounded Bluetooth gate in `tests/` before any output: pairing check, one 12 mm default-trim label, a chain-only two-label job followed after 10 seconds by a chain-and-final job, a job queued while the printer is off that prints after power-on, polling `0` showing no idle connects in the log, and a USB-attached unit receiving nothing; state the label budget and stop conditions, and present the gate for approval. +- [ ] 5.2 After approval, run the gate on the Pi with the P710BT on battery; record captures, job logs and operator observations, stopping on the first failed criterion. If the held chain end is lost across the Bluetooth reconnect, stop and report it as a contract decision rather than adjusting behavior. +- [ ] 5.3 Update README capabilities and the acceptance record with the certified Bluetooth scope and anything left software-only. + +## 6. Final reconciliation + +- [ ] 6.1 Review implementation, tests, docs and evidence against both deltas, run strict OpenSpec validation, then sync specs and archive the change before merging. From 5f0b01b1e1b55f12813d761e1597e0b29a492b9a Mon Sep 17 00:00:00 2001 From: Fedir Date: Wed, 14 Oct 2026 15:46:01 -0400 Subject: [PATCH 02/14] docs(openspec): address review of Bluetooth transport change --- .../changes/add-bluetooth-transport/design.md | 23 +++++++--- .../add-bluetooth-transport/proposal.md | 6 +-- .../specs/printer-device/spec.md | 44 ++++++++++++++++--- .../specs/server-architecture/spec.md | 25 +++++++++-- .../changes/add-bluetooth-transport/tasks.md | 29 ++++++------ 5 files changed, 98 insertions(+), 29 deletions(-) diff --git a/openspec/changes/add-bluetooth-transport/design.md b/openspec/changes/add-bluetooth-transport/design.md index 4b41cbf..bd6eece 100644 --- a/openspec/changes/add-bluetooth-transport/design.md +++ b/openspec/changes/add-bluetooth-transport/design.md @@ -8,6 +8,10 @@ See [proposal.md](proposal.md) for motivation and the capability deltas for the [verified] PAPPL 1.4.11 invokes `status_cb` from `papplPrinterGetReasons()` (`pappl/printer-accessors.c:528`) whenever the printer is idle and the last call is more than one second old. Idle polling is therefore driven by client queries (CUPS, web UI, IPP monitors), not by a timer. When a job cannot open its device, PAPPL stops the queue and retries the open every 5 seconds (`pappl/job-process.c:1192-1213`) until it succeeds or the job is canceled. That retry is independent of `status_cb`. +[verified] PAPPL keeps the job device open across queued jobs: it reuses an open device (`pappl/job-process.c:1188`) and closes it only when no active jobs remain (`pappl/job-process.c:1121`). A burst of queued jobs therefore shares one connection; the device closes when the queue goes idle. + +[verified] The live status read loops at most `PT_STATUS_POLL_TRIES` calls, accumulating partial replies (`src/driver.c:89-96`); a read returning 0 means "no data yet" and a negative value ends the read as a failure (`src/device_usb.c:235`). Mainline RFCOMM sockets default to a 30-second connect timeout (`RFCOMM_CONN_TIMEOUT (HZ * 30)`, `include/net/bluetooth/rfcomm.h`). + [verified] The PT-P710BT accepts the same raster command stream over Bluetooth Classic RFCOMM, channel 1, and replies to the status request with the same 32-byte packet. [robby-cornelissen/pt-p710bt-label-maker](https://github.com/robby-cornelissen/pt-p710bt-label-maker) sends invalidate, initialize, status request, mode settings, raster and print over one RFCOMM socket and parses the reply with the same offsets as `src/status.c`. [verified] Mainline Linux refuses Bluetooth sockets outside the initial network namespace (`net/bluetooth/af_bluetooth.c`: `if (net != &init_net) return -EAFNOSUPPORT;`). RFCOMM socket creation requires no capability (`net/bluetooth/rfcomm/sock.c` has no `capable()` check). @@ -32,7 +36,15 @@ See [proposal.md](proposal.md) for motivation and the capability deltas for the ### Bluetooth is a second PAPPL device scheme -Add `src/device_bt.c` registering a `ptouch-bt://` scheme (`ptouch-bt://Brother/PT-P710BT/
`) with open, close, read, write and status callbacks over a blocking `AF_BLUETOOTH`/`BTPROTO_RFCOMM` socket on channel 1. Open performs one bounded connect; close shuts the socket. PAPPL already opens the device per job and per status read, so per-job connection needs no extra state. +Add `src/device_bt.c` registering a `ptouch-bt://` scheme (`ptouch-bt://Brother/PT-P710BT/
`) with open, close, read, write and status callbacks over an `AF_BLUETOOTH`/`BTPROTO_RFCOMM` socket on channel 1. The scheme keeps PAPPL's lifetime: the connection opens when a job starts with no device open, stays open across queued jobs, and closes when the queue goes idle; an idle status read opens and closes its own connection. No extra connection state is added. + +The scheme's callbacks keep the USB return conventions so the driver needs no transport-specific handling: + +- **Connect** is non-blocking with a 10-second deadline, then the socket returns to blocking mode. Ten seconds covers Bluetooth paging (about 5 seconds) plus RFCOMM setup, and replaces the kernel's 30-second default so an absent printer cannot stall a status read for half a minute. +- **Status read** waits up to the same 300 ms as the USB status read and returns the bytes available, 0 on timeout, and -1 on EOF or error. The existing bounded accumulation loop then handles fragmented replies unchanged. +- **Write** loops until every byte is sent and returns -1 on error or EOF, like the USB full write. + +The address is parsed with `sscanf` into `bdaddr_t`, so the build needs only the `libbluetooth-dev` headers (`bluetooth.h`, `rfcomm.h`); the binary links no new library and the runtime image is unchanged. The driver's USB-only gates become transport-neutral: one function says whether a URI is a live printer transport (USB or Bluetooth), and one bounded status read dispatches by scheme. The USB presence count in `status_cb` stays a USB step; Bluetooth has no enumeration, so its reachability check is the open itself. The socket test transport keeps skipping live status. @@ -46,7 +58,7 @@ Alternative: try USB first, then Bluetooth. Rejected: it is a silent fallback an ### Idle polling is rate-limited in `status_cb` -`PTOUCH_IDLE_POLL_SECONDS` is parsed once at startup. Absent: `status_cb` runs on every PAPPL call, as now. `0`: `status_cb` returns without device access. `N > 0`: `status_cb` returns without device access unless at least `N` seconds passed since its last live read. Skipped calls leave the last published reasons and media in place. Job-owned media refresh and PAPPL's open retry are unaffected, so queued jobs still print when the printer returns. +`PTOUCH_IDLE_POLL_SECONDS` is parsed once at startup. Absent: `status_cb` runs on every PAPPL call, as now. `0`: `status_cb` returns without device access. `N > 0`: `status_cb` returns without device access unless at least `N` seconds passed since its last idle probe attempt. The timestamp is taken when the attempt starts, so a failed open or failed read counts; otherwise an unreachable printer would be probed on every client query. Skipped calls leave the last published reasons and media in place. Job-owned media refresh and PAPPL's open retry are unaffected, so queued jobs still print when the printer returns. The setting applies to both transports. A Bluetooth deployment chooses its value; the documented recommendation is `0` for battery use. @@ -60,15 +72,16 @@ Alternatives: a native systemd build on the Pi duplicates the pinned CUPS/PAPPL ### arm64 image built in the same workflow -Add `linux/arm64` to the publish step's platforms with QEMU emulation, so `edge` and `sha-*` become multi-arch manifests. The amd64 load-and-smoke step stays as is; the arm64 build runs CTest under emulation during `Dockerfile.deploy`. +Set up QEMU and add a non-publishing `linux/arm64` build step beside the existing amd64 check build, so pull requests run the arm64 build and its CTest under emulation. Add `linux/arm64` to the publish step's platforms, so `edge` and `sha-*` become multi-arch manifests. The amd64 load-and-smoke step stays as is. The buildx cache shares layers between the check and publish builds. Alternative: a native `ubuntu-24.04-arm` job plus a manifest merge. Faster, but adds a second job and digest stitching. Revisit only if the emulated build time is unacceptable. ## Risks / Trade-offs -- [The printer may treat a Bluetooth disconnect as a session end and drop a held chain end between jobs] → The acceptance gate tests chain-only continuation across two jobs. If the held end is lost, the change documents that limit rather than altering the continuation contract silently. +- [The printer may treat a Bluetooth disconnect as a session end and drop a held chain end between jobs] → Queued jobs share one connection, so only a continuation after the queue went idle crosses a reconnect. The acceptance gate tests that idle boundary. If the held end is lost, the change documents that limit rather than altering the continuation contract silently. +- [The server queries status before initializing and waits up to 20 seconds for receiving-ready (`src/driver.c:74`, `src/driver.c:435`); the reference script initializes first and stops at print-completed] → The acceptance gate checks a reset-free status reply on a fresh connection and records receiving-ready timing. - [Walking out of range mid-job leaves output uncertain] → The existing uncertain-output contract applies: the queue blocks and requires power-cycle plus restart. Documented as the expected cost of losing the link during a print. -- [A connect to an absent printer blocks for the Bluetooth page timeout, about 5 seconds] → Status reads are skipped while a job owns the device, and polling `0` avoids idle connects entirely. +- [A connect to an absent printer blocks for up to the 10-second deadline] → Status reads are skipped while a job owns the device, polling intervals count failed attempts, and polling `0` avoids idle connects entirely. The gate measures the actual connect failure time. - [Writes may need pacing over RFCOMM] → Blocking writes rely on RFCOMM flow control. If acceptance shows corruption, add pacing then, with evidence. - [Emulated arm64 build may be slow] → Measured in CI; switch to a native runner only if it blocks routine merges. - [Pi Zero 2 W range is roughly 10 m indoors] → An operational limit, documented, not mitigated. diff --git a/openspec/changes/add-bluetooth-transport/proposal.md b/openspec/changes/add-bluetooth-transport/proposal.md index a33b08d..80a1e6b 100644 --- a/openspec/changes/add-bluetooth-transport/proposal.md +++ b/openspec/changes/add-bluetooth-transport/proposal.md @@ -7,9 +7,9 @@ The owner wants the PT-P710BT to run on its own battery and travel around the ho ## What Changes - Add a Bluetooth RFCOMM transport for the PT-P710BT, selected by configuring the printer's Bluetooth address (`PTOUCH_BT_ADDRESS`). The queue then uses only Bluetooth, never USB, and does not fall back to USB. Configuring an address for any other model fails startup. -- Connect only while a job or status read owns the device and disconnect afterwards. The printer stays available to its phone app between jobs. +- Keep PAPPL's device lifetime: connect when a job starts, share the connection across queued jobs, and disconnect when the queue goes idle. Idle status reads connect briefly. The printer is free for its phone app whenever the queue is idle. - Treat an unreachable printer (switched off, out of range, or held by another Bluetooth client) as offline. Queued jobs wait and print when the printer becomes reachable. Reuse the existing offline, media-confirmation, finishing, continuation and uncertain-output contracts unchanged. -- Make idle status polling configurable for every transport with `PTOUCH_IDLE_POLL_SECONDS`. Absent keeps today's behavior, `0` disables idle polling so status refreshes only during jobs, and a positive value sets the minimum seconds between idle polls. An invalid value fails startup. +- Make idle status polling configurable for every transport with `PTOUCH_IDLE_POLL_SECONDS`. Absent keeps today's behavior, `0` disables idle polling so status refreshes only during jobs, and a positive value sets the minimum seconds between idle probe attempts, including failed ones. An invalid value fails startup. - Publish the runtime image for `linux/arm64` as well as `linux/amd64` under the same tags. - Document Pi deployment: pairing, host networking, and the required container settings. @@ -28,7 +28,7 @@ None. Transport selection and status polling belong to the existing printer-devi ## Impact -Code: a new Bluetooth device scheme beside `src/device_usb.c`, transport-neutral status reads in `src/driver.c`, configuration parsing in `src/main.c`, and polling control in `status_cb`. Tests: fake-socket adapter tests for the Bluetooth scheme and the polling setting, plus core regressions showing unchanged bytes. CI: `.github/workflows/deploy.yml` builds and publishes arm64. Runtime image: no new libraries (RFCOMM uses the kernel socket API). Docs: README, deployment notes, and a bounded Bluetooth acceptance gate. +Code: a new Bluetooth device scheme beside `src/device_usb.c`, transport-neutral status reads in `src/driver.c`, configuration parsing in `src/main.c`, and polling control in `status_cb`. Tests: fake-socket adapter tests for the Bluetooth scheme and the polling setting, plus core regressions showing unchanged bytes. CI: `.github/workflows/deploy.yml` builds and publishes arm64. Build: `libbluetooth-dev` headers in both Dockerfiles. Runtime image: no new libraries (RFCOMM uses the kernel socket API). Docs: README, deployment notes, and a bounded Bluetooth acceptance gate. No new client attribute, IPP behavior, rendering rule or dependency fork. USB deployments that leave both new variables unset keep their exact current behavior. diff --git a/openspec/changes/add-bluetooth-transport/specs/printer-device/spec.md b/openspec/changes/add-bluetooth-transport/specs/printer-device/spec.md index e44ce7a..b2fb523 100644 --- a/openspec/changes/add-bluetooth-transport/specs/printer-device/spec.md +++ b/openspec/changes/add-bluetooth-transport/specs/printer-device/spec.md @@ -1,14 +1,44 @@ # Spec Delta +## MODIFIED Requirements + +### Requirement: Device selection is unambiguous + +The server SHALL expose one application queue and consistently select its configured supported model. Multiple matching physical devices SHALL be reported as ambiguous; discovery or opening SHALL NOT silently choose the first match. USB renumbering of a single matching device SHALL NOT require queue replacement. PT-2730 SHALL match USB identity 04f9:2041 and PT-P710BT SHALL match 04f9:20af; another attached model SHALL NOT substitute for the selected one. These USB selection rules SHALL apply when no Bluetooth address is configured. + +#### Scenario: Two matching devices +- **WHEN** two units of the selected supported model match the configured queue +- **THEN** printing is blocked with an ambiguity diagnostic + +#### Scenario: Device replug +- **WHEN** the sole matching printer is unplugged and reattached with another USB node number +- **THEN** the existing queue recovers without selecting a different model or requiring a server restart + +#### Scenario: Both supported models attached +- **WHEN** one PT-2730 and one PT-P710BT are attached and either model is selected +- **THEN** discovery, opening, status, and printing consistently use only the selected model + +#### Scenario: Selected model absent +- **WHEN** PT-P710BT is selected but only PT-2730 is attached, or the reverse +- **THEN** the selected queue remains present and unavailable, diagnostics name the attached but unselected model and the applicable PTOUCH_MODEL selection, and no output is sent to the other printer + +#### Scenario: USB identity conflicts with queue identity +- **WHEN** an explicit supported USB URI names a different model from the configured queue identity +- **THEN** queue creation or opening fails before status or print commands are sent + ## ADDED Requirements ### Requirement: PT-P710BT can use Bluetooth instead of USB -When `PTOUCH_BT_ADDRESS` is set, the server SHALL reach the selected PT-P710BT only over Bluetooth at that address and SHALL NOT use or fall back to USB. It SHALL send the same print protocol as over USB, hold the connection only while a job or status read owns the device, and treat an unreachable printer as offline. An address with another model, or a malformed address, SHALL fail startup. +When `PTOUCH_BT_ADDRESS` is set, the server SHALL reach the selected PT-P710BT only over Bluetooth at that address and SHALL NOT use or fall back to USB. It SHALL send the same print protocol as over USB, hold the connection only while jobs are queued or an idle status read runs, and treat an unreachable printer as offline. An address with another model, or a malformed address, SHALL fail startup. #### Scenario: Bluetooth printer in range - **WHEN** PT-P710BT is selected with a Bluetooth address and the paired printer is on and in range -- **THEN** jobs print with the same command and raster bytes the USB path would send, and the connection closes after each job +- **THEN** jobs print with the same command and raster bytes the USB path would send + +#### Scenario: Connection released when idle +- **WHEN** two jobs are queued together and the queue then becomes idle +- **THEN** both jobs use one connection, and the connection closes once no job remains #### Scenario: Printer unreachable - **WHEN** a job is queued while the configured printer is off, out of range, or connected to another Bluetooth client @@ -23,12 +53,12 @@ When `PTOUCH_BT_ADDRESS` is set, the server SHALL reach the selected PT-P710BT o - **THEN** startup fails with a configuration error naming the problem #### Scenario: Link lost during output -- **WHEN** the Bluetooth link fails after page raster has started -- **THEN** the existing uncertain-output recovery contract applies and no new raster is sent until recovery +- **WHEN** the Bluetooth link fails after page raster has started, including during finalization +- **THEN** the existing uncertain-output recovery contract applies, cleanup sends nothing further, and a following job emits no raster until recovery ### Requirement: Idle status polling is configurable -`PTOUCH_IDLE_POLL_SECONDS` SHALL control idle status reads on every transport. Absent, idle reads SHALL follow PAPPL's status requests as before. `0` SHALL disable idle device access. A positive integer SHALL set the minimum seconds between idle device reads. Skipped reads SHALL keep the last published status. Status read by jobs SHALL be unaffected. Any other value SHALL fail startup. +`PTOUCH_IDLE_POLL_SECONDS` SHALL control idle status reads on every transport. Absent, idle reads SHALL follow PAPPL's status requests as before. `0` SHALL disable idle device access. A positive integer SHALL set the minimum seconds between idle device access attempts, successful or not. Skipped reads SHALL keep the last published status. Job status reads SHALL be unaffected. Any other value SHALL fail startup. #### Scenario: Polling unset - **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is absent @@ -42,6 +72,10 @@ When `PTOUCH_BT_ADDRESS` is set, the server SHALL reach the selected PT-P710BT o - **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is `300` and clients query the idle printer every few seconds - **THEN** the server reads live status at most once per 300 seconds and reports the last published status between reads +#### Scenario: Rate limit with the printer unreachable +- **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is `300`, the printer is unreachable, and clients query the idle printer every few seconds +- **THEN** the server attempts at most one connection per 300 seconds + #### Scenario: Invalid polling value - **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is negative, fractional, or not a number - **THEN** startup fails with a configuration error diff --git a/openspec/changes/add-bluetooth-transport/specs/server-architecture/spec.md b/openspec/changes/add-bluetooth-transport/specs/server-architecture/spec.md index 33effcd..8e3b042 100644 --- a/openspec/changes/add-bluetooth-transport/specs/server-architecture/spec.md +++ b/openspec/changes/add-bluetooth-transport/specs/server-architecture/spec.md @@ -26,20 +26,34 @@ Only the implemented PT-2730 and PT-P710BT paths SHALL receive print output. Mis - **WHEN** PT-P710BT is selected with a Bluetooth address - **THEN** the queue's device URI names that address, and USB enumeration plays no part in selection +### Requirement: Container hotplug and addressing + +USB container deployment SHALL bind-mount `/dev/bus/usb` and allow USB character devices with `c 189:* rwm` so newly numbered nodes remain accessible. dbus/Avahi SHALL receive a nofile limit of 65536. Reverse proxies SHALL forward a Host accepted by PAPPL; shared-network clients MAY use a stable alias. Discovery, TLS termination, and network exposure SHALL remain deployment choices. + +#### Scenario: Hotplug behind a proxy + +- **WHEN** the printer is replugged in a container reached through a reverse proxy +- **THEN** the new USB node is accessible and the proxy supplies an accepted Host without changing the print contract + ## ADDED Requirements ### Requirement: Private Bluetooth transport -The adapter SHALL reach a Bluetooth printer through an internal scheme over a kernel RFCOMM socket, without a host serial bridge or in-server pairing. Connects SHALL be bounded. Status readers SHALL validate the owning printer URI before accessing Bluetooth connection data, as for USB. +The adapter SHALL reach a Bluetooth printer through an internal scheme over a kernel RFCOMM socket, without a host serial bridge or in-server pairing. A connect attempt SHALL give up within 10 seconds. Status readers SHALL validate the owning printer URI before accessing Bluetooth connection data, as for USB. #### Scenario: Transport isolation - **WHEN** USB, Bluetooth, and socket test URIs are each evaluated by status processing - **THEN** each reaches only its own transport's operations +#### Scenario: Absent printer + +- **WHEN** the configured printer is off or out of range +- **THEN** each connect attempt fails within 10 seconds and the queue reports the printer offline + ### Requirement: Bluetooth container deployment -A Bluetooth deployment SHALL run the container in the host network namespace, because the kernel refuses Bluetooth sockets elsewhere. Pairing SHALL be a host step whose keys persist across host reboots. The container SHALL need no BlueZ and no added capability. Exactly one mDNS responder SHALL serve the host. +A Bluetooth deployment SHALL run the container in the host network namespace, because the kernel refuses Bluetooth sockets elsewhere. It SHALL need no USB device mount. Pairing SHALL be a host step whose keys persist across host reboots. The container SHALL need no BlueZ and no added capability. Exactly one mDNS responder SHALL serve the host. #### Scenario: Pi reboot @@ -48,7 +62,12 @@ A Bluetooth deployment SHALL run the container in the host network namespace, be ### Requirement: Multi-architecture runtime image -Published image tags SHALL include `linux/amd64` and `linux/arm64` variants built from the same `Dockerfile.deploy`, each passing the build's CTest run before publication. +Published image tags SHALL include `linux/amd64` and `linux/arm64` variants built from the same `Dockerfile.deploy`. Both architectures SHALL build and pass the build's CTest run on pull requests, before anything is published. + +#### Scenario: Pull request build + +- **WHEN** a pull request changes the image +- **THEN** CI builds both architectures and runs CTest in each without publishing #### Scenario: Pull on a Pi diff --git a/openspec/changes/add-bluetooth-transport/tasks.md b/openspec/changes/add-bluetooth-transport/tasks.md index dbfe71e..61ca428 100644 --- a/openspec/changes/add-bluetooth-transport/tasks.md +++ b/openspec/changes/add-bluetooth-transport/tasks.md @@ -1,33 +1,36 @@ # Tasks -## 1. Configuration +## 1. Configuration and build dependency -- [ ] 1.1 Parse `PTOUCH_BT_ADDRESS` and `PTOUCH_IDLE_POLL_SECONDS` once at startup in `src/main.c`, failing on a malformed address, an address with any model other than PT-P710BT, or a polling value that is not a non-negative integer; verify each accepted and rejected case in a startup test, and that both unset leaves the USB queue URI and identity unchanged. -- [ ] 1.2 Document both variables and their defaults in README; check the text against the 1.1 test cases. +- [ ] 1.1 Add `libbluetooth-dev` to the build environments in `Dockerfile` and the build stage of `Dockerfile.deploy`; verify both images build and that `ldd ptouch-app` lists no new library. +- [ ] 1.2 Parse `PTOUCH_BT_ADDRESS` and `PTOUCH_IDLE_POLL_SECONDS` once at startup in `src/main.c`, failing on a malformed address, an address with any model other than PT-P710BT, or a polling value that is not a non-negative integer; verify each accepted and rejected case in a startup test, and that both unset leaves the USB queue URI and identity unchanged. +- [ ] 1.3 Document both variables and their defaults in README; check the text against the 1.2 test cases. ## 2. Idle polling control -- [ ] 2.1 Rate-limit `status_cb` by the parsed polling value (absent: unchanged; `0`: no device access; `N`: at most one live read per `N` seconds, keeping the last published status); verify in `test_driver` with a fake clock that unset matches current behavior, `0` opens no device across repeated calls, `N` reads once per window, and job-owned media refresh still runs with polling `0`. +- [ ] 2.1 Rate-limit `status_cb` by the parsed polling value, timing from the start of each idle probe attempt (absent: unchanged; `0`: no device access; `N`: at most one attempt per `N` seconds, keeping the last published status); verify in `test_driver` with a fake clock that unset matches current behavior, `0` opens no device across repeated calls, `N` attempts once per window both when reads succeed and when the open fails, and job-owned media refresh still runs with polling `0`. ## 3. Bluetooth transport -- [ ] 3.1 Add `src/device_bt.*` registering the `ptouch-bt://Brother/PT-P710BT/
` scheme with bounded connect, full write, bounded status read and close over an RFCOMM socket on channel 1; verify URI formatting/parsing and rejection of other models and malformed addresses in a new adapter test using a socketpair in place of RFCOMM. +- [ ] 3.1 Add `src/device_bt.*` registering the `ptouch-bt://Brother/PT-P710BT/
` scheme on RFCOMM channel 1, with a 10-second non-blocking connect, full write, 300 ms status read (bytes, 0 on timeout, -1 on EOF or error) and close; verify in a new adapter test using a socketpair in place of RFCOMM: URI formatting/parsing, rejection of other models and malformed addresses, a status reply delivered in fragments and after a delay, timeout versus EOF, and a short or failed write. - [ ] 3.2 Make the driver's live-status gates and status read transport-neutral, keeping the USB presence count USB-only and the socket test transport skipped; verify in `test_driver` that USB, Bluetooth and socket URIs each reach only their own operations, and that an unreachable Bluetooth device publishes offline without the "present but cannot be opened" error. -- [ ] 3.3 When an address is configured, make discovery and queue creation use only the Bluetooth URI; verify with fake USB that an attached USB PT-P710BT receives no status request or output, and that the queue identity is still PT-P710BT. -- [ ] 3.4 Verify byte identity: capture one PNG job per finishing policy over the socketpair transport and compare with the existing P710BT USB captures; any difference fails the task. -- [ ] 3.5 Run the full build and tests in the pinned container (`cmake -S . -B build`, `cmake --build build`, `ctest --test-dir build --output-on-failure`) plus both existing image smoke variants; retain logs. +- [ ] 3.3 Verify link loss on the socketpair transport: close the peer during raster and during finalization; the job fails, cleanup writes nothing further, recovery is required, and a following valid job emits no raster. +- [ ] 3.4 When an address is configured, make discovery and queue creation use only the Bluetooth URI; verify with fake USB that an attached USB PT-P710BT receives no status request or output, and that the queue identity is still PT-P710BT. +- [ ] 3.5 Verify byte identity: capture one PNG job per finishing policy over the socketpair transport and compare with the existing P710BT USB captures; any difference fails the task. +- [ ] 3.6 Run the full build and tests in the pinned container (`cmake -S . -B build`, `cmake --build build`, `ctest --test-dir build --output-on-failure`) plus both existing image smoke variants; retain logs. ## 4. Image and deployment -- [ ] 4.1 Add `linux/arm64` to the publish step in `.github/workflows/deploy.yml` with QEMU set up; verify on the pull request that the arm64 build runs CTest and record its duration. -- [ ] 4.2 Write the Pi deployment guide in `docs/` (64-bit Raspberry Pi OS Lite, Docker, `bluetoothctl` pair and trust, disabling host `avahi-daemon`, `--network host`, the three variables, recommended polling `0`, about 10 m range, mid-job link loss recovery); review each command against the design and the container's entrypoint. +- [ ] 4.1 In `.github/workflows/deploy.yml`, set up QEMU, add a non-publishing `linux/arm64` build step beside the amd64 check build, and add `linux/arm64` to the publish step; verify locally with `docker buildx build --platform linux/arm64 -f Dockerfile.deploy .` that the arm64 build and its CTest pass, and record the duration. +- [ ] 4.2 Write the Pi deployment guide in `docs/` (64-bit Raspberry Pi OS Lite, Docker, `bluetoothctl` pair and trust, disabling host `avahi-daemon`, `--network host` with no USB mount, the three variables, recommended polling `0`, about 10 m range, mid-job link loss recovery); review each command against the design and the container's entrypoint. ## 5. Physical acceptance -- [ ] 5.1 Prepare a concrete bounded Bluetooth gate in `tests/` before any output: pairing check, one 12 mm default-trim label, a chain-only two-label job followed after 10 seconds by a chain-and-final job, a job queued while the printer is off that prints after power-on, polling `0` showing no idle connects in the log, and a USB-attached unit receiving nothing; state the label budget and stop conditions, and present the gate for approval. -- [ ] 5.2 After approval, run the gate on the Pi with the P710BT on battery; record captures, job logs and operator observations, stopping on the first failed criterion. If the held chain end is lost across the Bluetooth reconnect, stop and report it as a contract decision rather than adjusting behavior. +- [ ] 5.1 Prepare a concrete bounded Bluetooth gate in `tests/` before any output: pairing check; a reset-free status reply on a fresh connection; one 12 mm default-trim label with receiving-ready time recorded; two jobs queued together sharing one connection, then closed when idle; a chain-only job followed after the queue went idle by a chain-and-final job; the measured connect failure time with the printer off; a job queued while the printer is off that prints after power-on; polling `0` showing no idle connects in the log; a USB-attached unit receiving nothing; and a Pi reboot followed by a label without re-pairing. State the label budget and stop conditions, and present the gate for approval. +- [ ] 5.2 After approval, run the gate on the Pi with the P710BT on battery; record captures, job logs and operator observations, stopping on the first failed criterion. If the held chain end is lost across the idle reconnect, stop and report it as a contract decision rather than adjusting behavior. - [ ] 5.3 Update README capabilities and the acceptance record with the certified Bluetooth scope and anything left software-only. ## 6. Final reconciliation -- [ ] 6.1 Review implementation, tests, docs and evidence against both deltas, run strict OpenSpec validation, then sync specs and archive the change before merging. +- [ ] 6.1 Review implementation, tests, docs and evidence against both deltas, run strict OpenSpec validation, then sync specs and archive the change. +- [ ] 6.2 Open the pull request, confirm CI builds and tests both architectures without publishing, and merge only after 6.1. From 6f46acf14dbadf7c64cb6ad96d7c2963bfcbb5c0 Mon Sep 17 00:00:00 2001 From: Fedir Date: Wed, 14 Oct 2026 15:50:00 -0400 Subject: [PATCH 03/14] docs(openspec): assemble Bluetooth status in one read; record held-job limit --- openspec/changes/add-bluetooth-transport/design.md | 3 ++- openspec/changes/add-bluetooth-transport/tasks.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openspec/changes/add-bluetooth-transport/design.md b/openspec/changes/add-bluetooth-transport/design.md index bd6eece..8e267d5 100644 --- a/openspec/changes/add-bluetooth-transport/design.md +++ b/openspec/changes/add-bluetooth-transport/design.md @@ -41,7 +41,7 @@ Add `src/device_bt.c` registering a `ptouch-bt://` scheme (`ptouch-bt://Brother/ The scheme's callbacks keep the USB return conventions so the driver needs no transport-specific handling: - **Connect** is non-blocking with a 10-second deadline, then the socket returns to blocking mode. Ten seconds covers Bluetooth paging (about 5 seconds) plus RFCOMM setup, and replaces the kernel's 30-second default so an absent printer cannot stall a status read for half a minute. -- **Status read** waits up to the same 300 ms as the USB status read and returns the bytes available, 0 on timeout, and -1 on EOF or error. The existing bounded accumulation loop then handles fragmented replies unchanged. +- **Status read** receives with `MSG_WAITALL` and a 300 ms receive timeout, the same budget as the USB status read. It returns the requested bytes once they all arrive, the partial count at timeout, 0 if nothing arrived, and -1 on EOF or error. A reply split across RFCOMM frames is therefore assembled inside one read call, and the driver's existing ten-call accumulation loop (`src/driver.c:89`) stays unchanged for both transports. - **Write** loops until every byte is sent and returns -1 on error or EOF, like the USB full write. The address is parsed with `sscanf` into `bdaddr_t`, so the build needs only the `libbluetooth-dev` headers (`bluetooth.h`, `rfcomm.h`); the binary links no new library and the runtime image is unchanged. @@ -80,6 +80,7 @@ Alternative: a native `ubuntu-24.04-arm` job plus a manifest merge. Faster, but - [The printer may treat a Bluetooth disconnect as a session end and drop a held chain end between jobs] → Queued jobs share one connection, so only a continuation after the queue went idle crosses a reconnect. The acceptance gate tests that idle boundary. If the held end is lost, the change documents that limit rather than altering the continuation contract silently. - [The server queries status before initializing and waits up to 20 seconds for receiving-ready (`src/driver.c:74`, `src/driver.c:435`); the reference script initializes first and stops at print-completed] → The acceptance gate checks a reset-free status reply on a fresh connection and records receiving-ready timing. +- [A held job keeps the connection open: PAPPL counts held jobs as active (`pappl/job.c:175`), so if job A finishes while job B is held, the device stays open, and canceling B does not close it (`pappl/job.c:42`)] → Accepted as a known limit, not fixed: ordinary `lp` and AirPrint submissions do not request `job-hold-until`, and fixing it means patching PAPPL's device lifetime. Restarting the container releases the connection. Revisit if a held-job workflow appears. - [Walking out of range mid-job leaves output uncertain] → The existing uncertain-output contract applies: the queue blocks and requires power-cycle plus restart. Documented as the expected cost of losing the link during a print. - [A connect to an absent printer blocks for up to the 10-second deadline] → Status reads are skipped while a job owns the device, polling intervals count failed attempts, and polling `0` avoids idle connects entirely. The gate measures the actual connect failure time. - [Writes may need pacing over RFCOMM] → Blocking writes rely on RFCOMM flow control. If acceptance shows corruption, add pacing then, with evidence. diff --git a/openspec/changes/add-bluetooth-transport/tasks.md b/openspec/changes/add-bluetooth-transport/tasks.md index 61ca428..7948ebf 100644 --- a/openspec/changes/add-bluetooth-transport/tasks.md +++ b/openspec/changes/add-bluetooth-transport/tasks.md @@ -12,7 +12,7 @@ ## 3. Bluetooth transport -- [ ] 3.1 Add `src/device_bt.*` registering the `ptouch-bt://Brother/PT-P710BT/
` scheme on RFCOMM channel 1, with a 10-second non-blocking connect, full write, 300 ms status read (bytes, 0 on timeout, -1 on EOF or error) and close; verify in a new adapter test using a socketpair in place of RFCOMM: URI formatting/parsing, rejection of other models and malformed addresses, a status reply delivered in fragments and after a delay, timeout versus EOF, and a short or failed write. +- [ ] 3.1 Add `src/device_bt.*` registering the `ptouch-bt://Brother/PT-P710BT/
` scheme on RFCOMM channel 1, with a 10-second non-blocking connect, full write, 300 ms `MSG_WAITALL` status read (requested bytes, partial count at timeout, 0 if nothing arrived, -1 on EOF or error) and close; verify in a new adapter test using a socketpair in place of RFCOMM: URI formatting/parsing, rejection of other models and malformed addresses, a 32-byte status reply written in many small fragments returned whole by one read, a delayed reply, timeout versus EOF, and a short or failed write. - [ ] 3.2 Make the driver's live-status gates and status read transport-neutral, keeping the USB presence count USB-only and the socket test transport skipped; verify in `test_driver` that USB, Bluetooth and socket URIs each reach only their own operations, and that an unreachable Bluetooth device publishes offline without the "present but cannot be opened" error. - [ ] 3.3 Verify link loss on the socketpair transport: close the peer during raster and during finalization; the job fails, cleanup writes nothing further, recovery is required, and a following valid job emits no raster. - [ ] 3.4 When an address is configured, make discovery and queue creation use only the Bluetooth URI; verify with fake USB that an attached USB PT-P710BT receives no status request or output, and that the queue identity is still PT-P710BT. From 436076322cf758a876c937b3e20b1a75a819c4c7 Mon Sep 17 00:00:00 2001 From: Fedir Date: Wed, 14 Oct 2026 16:15:31 -0400 Subject: [PATCH 04/14] feat(printer): reach PT-P710BT over Bluetooth and make idle polling configurable Add a ptouch-bt:// RFCOMM device scheme selected by PTOUCH_BT_ADDRESS, with a 10-second connect deadline and a status read that assembles fragmented replies. Driver status gates become transport-neutral; USB enumeration stays USB-only. PTOUCH_IDLE_POLL_SECONDS rate-limits idle status attempts on every transport (0 disables them). CI builds and tests linux/arm64 and publishes multi-arch tags. Adds the Pi deployment guide and a prepared Bluetooth acceptance gate. --- .github/workflows/deploy.yml | 9 +- CMakeLists.txt | 19 +- Dockerfile | 2 +- Dockerfile.deploy | 2 +- README.md | 23 ++- docs/PI-DEPLOYMENT.md | 75 ++++++++ .../changes/add-bluetooth-transport/design.md | 4 +- .../changes/add-bluetooth-transport/tasks.md | 24 +-- src/config.c | 40 +++++ src/config.h | 17 ++ src/device_bt.c | 162 ++++++++++++++++++ src/device_bt.h | 23 +++ src/driver.c | 71 ++++++-- src/driver.h | 2 +- src/main.c | 43 ++++- tests/BLUETOOTH-ACCEPTANCE.md | 48 ++++++ tests/CMakeLists.txt | 4 + tests/bluetooth-gate/make-fixture.py | 8 + tests/test_config.c | 25 +++ tests/test_device_bt.c | 136 +++++++++++++++ tests/test_device_usb.c | 31 ++++ tests/test_driver.c | 116 ++++++++++++- tests/test_png_geometry.c | 3 + 23 files changed, 847 insertions(+), 40 deletions(-) create mode 100644 docs/PI-DEPLOYMENT.md create mode 100644 src/config.c create mode 100644 src/config.h create mode 100644 src/device_bt.c create mode 100644 src/device_bt.h create mode 100644 tests/BLUETOOTH-ACCEPTANCE.md create mode 100644 tests/bluetooth-gate/make-fixture.py create mode 100644 tests/test_config.c create mode 100644 tests/test_device_bt.c diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b787276..540f718 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,6 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 - name: Build and test runtime image uses: docker/build-push-action@v6 @@ -20,6 +21,12 @@ jobs: platforms: linux/amd64 load: true tags: ptouch-deploy:check + - name: Build and test arm64 runtime image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.deploy + platforms: linux/arm64 - name: Smoke - IPP jobs and isolated socket capture run: | set -e @@ -48,7 +55,7 @@ jobs: with: context: . file: Dockerfile.deploy - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/CMakeLists.txt b/CMakeLists.txt index dbeb6eb..c8702cc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,7 @@ add_library(ptcore STATIC src/raster.c src/status.c src/cutter.c + src/config.c ) target_include_directories(ptcore PUBLIC src) @@ -26,6 +27,12 @@ if(NOT PTOUCH_CORE_ONLY) pkg_check_modules(PAPPL REQUIRED pappl) pkg_check_modules(USB REQUIRED libusb-1.0) pkg_check_modules(PNG REQUIRED libpng) + # RFCOMM socket declarations only (libbluetooth-dev); nothing is linked. + include(CheckIncludeFiles) + check_include_files("bluetooth/bluetooth.h;bluetooth/rfcomm.h" HAVE_BLUETOOTH_RFCOMM_H) + if(NOT HAVE_BLUETOOTH_RFCOMM_H) + message(FATAL_ERROR "bluetooth/rfcomm.h not found; install libbluetooth-dev") + endif() # Optional live transport for the test-only probe, using the existing scheme. target_sources(ptouch-cut-probe PRIVATE src/device_usb.c) target_compile_definitions(ptouch-cut-probe PRIVATE PT_PROBE_TRANSPORT=1) @@ -62,17 +69,23 @@ if(NOT PTOUCH_CORE_ONLY) target_compile_options(test_png_geometry PRIVATE ${PAPPL_CFLAGS_OTHER}) add_test(NAME png_geometry COMMAND test_png_geometry) set_tests_properties(png_geometry PROPERTIES TIMEOUT 90) - add_executable(test_device_usb tests/test_device_usb.c) + add_executable(test_device_usb tests/test_device_usb.c src/device_bt.c) target_link_libraries(test_device_usb PRIVATE ptcore ${PAPPL_LIBRARIES} ${USB_LIBRARIES}) target_include_directories(test_device_usb PRIVATE ${PAPPL_INCLUDE_DIRS} ${USB_INCLUDE_DIRS}) target_link_directories(test_device_usb PRIVATE ${PAPPL_LIBRARY_DIRS} ${USB_LIBRARY_DIRS}) target_compile_options(test_device_usb PRIVATE ${PAPPL_CFLAGS_OTHER}) add_test(NAME device_usb COMMAND test_device_usb) + add_executable(test_device_bt tests/test_device_bt.c) + target_link_libraries(test_device_bt PRIVATE ptcore ${PAPPL_LIBRARIES}) + target_include_directories(test_device_bt PRIVATE ${PAPPL_INCLUDE_DIRS}) + target_link_directories(test_device_bt PRIVATE ${PAPPL_LIBRARY_DIRS}) + target_compile_options(test_device_bt PRIVATE ${PAPPL_CFLAGS_OTHER}) + add_test(NAME device_bt COMMAND test_device_bt) # Test assertions include fixture setup and must stay active in Release too. - foreach(test_target test_driver test_pappl_raster test_pappl_finishings test_png_geometry test_device_usb) + foreach(test_target test_driver test_pappl_raster test_pappl_finishings test_png_geometry test_device_usb test_device_bt) target_compile_options(${test_target} PRIVATE -UNDEBUG) endforeach() - add_executable(ptouch-app src/main.c src/driver.c src/device_usb.c src/filter_png.c) + add_executable(ptouch-app src/main.c src/driver.c src/device_usb.c src/device_bt.c src/filter_png.c) target_link_libraries(ptouch-app PRIVATE ptcore ${PAPPL_LIBRARIES} ${USB_LIBRARIES} ${PNG_LIBRARIES}) target_include_directories(ptouch-app PRIVATE ${PAPPL_INCLUDE_DIRS} ${USB_INCLUDE_DIRS} ${PNG_INCLUDE_DIRS}) target_link_directories(ptouch-app PRIVATE ${PAPPL_LIBRARY_DIRS} ${USB_LIBRARY_DIRS} ${PNG_LIBRARY_DIRS}) diff --git a/Dockerfile b/Dockerfile index a9b658f..19706fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ FROM debian:bookworm RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential cmake pkg-config git ca-certificates \ autoconf automake libtool gettext autopoint \ - libusb-1.0-0-dev libgd-dev libpng-dev libjpeg-dev zlib1g-dev \ + libusb-1.0-0-dev libbluetooth-dev libgd-dev libpng-dev libjpeg-dev zlib1g-dev \ libcups2-dev libcupsimage2-dev cups-ipp-utils usbutils \ libssl-dev libavahi-client-dev libgnutls28-dev libkrb5-dev \ python3 python3-pil \ diff --git a/Dockerfile.deploy b/Dockerfile.deploy index 1a865b8..881950d 100644 --- a/Dockerfile.deploy +++ b/Dockerfile.deploy @@ -3,7 +3,7 @@ FROM debian:bookworm AS build RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential cmake pkg-config git ca-certificates python3 \ - libusb-1.0-0-dev libpng-dev libjpeg-dev zlib1g-dev \ + libusb-1.0-0-dev libbluetooth-dev libpng-dev libjpeg-dev zlib1g-dev \ libcups2-dev libcupsimage2-dev libssl-dev libavahi-client-dev libgnutls28-dev libkrb5-dev \ && rm -rf /var/lib/apt/lists/* # Retain Debian security fixes while correcting only raster EOF reporting. diff --git a/README.md b/README.md index 6a74ed3..d3e9a04 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ The Brother raster protocol and device table are reused from explicitly below. Its 6/24 mm coverage is software-only. Other recognized models do not receive print output. +PT-P710BT can also be reached over Bluetooth instead of USB (`PTOUCH_BT_ADDRESS`), for example from a Raspberry Pi while the printer runs on battery. That path is software-tested only; physical acceptance is pending. See [docs/PI-DEPLOYMENT.md](docs/PI-DEPLOYMENT.md). + - Prints 1:1 within the tape's imageable width, honoring submitted page geometry; PNG length follows its image when a concrete media length is omitted. - Advertises **nominal** tape sizes with real margins (`om_24x100mm`), so clients @@ -97,8 +99,8 @@ cmake --build build-core ctest --test-dir build-core --output-on-failure ``` -This builds `ptcore` and the command probe, then runs the five core suites -(tables, protocol, raster, status, cutter) and the Python capture suite. +This builds `ptcore` and the command probe, then runs the six core suites +(tables, protocol, raster, status, config, cutter) and the Python capture suite. The default full build requires `pkg-config` packages `pappl`, `libusb-1.0`, and `libpng`, in addition to the core test tools. Missing dependencies fail @@ -246,6 +248,18 @@ protocol still follow `PTOUCH_MODEL`, and the printer connects on an actual prin An explicit `ptouch://Brother/` override must exactly match the configured supported model; conflicting or unsupported custom USB URIs fail before commands. +`PTOUCH_BT_ADDRESS` selects Bluetooth instead of USB for `PTOUCH_MODEL=PT-P710BT`. Set it to the paired printer's address, `XX:XX:XX:XX:XX:XX`. The server connects over RFCOMM channel 1 when a job starts, keeps the connection while jobs are queued, and closes it when the queue goes idle. USB is never used and never a fallback. A printer that is off, out of range, or connected to another device shows as offline, and queued jobs wait until it is reachable. A connection attempt gives up after 10 seconds. An address with another model, or a malformed address, fails startup. Bluetooth needs host networking; see [docs/PI-DEPLOYMENT.md](docs/PI-DEPLOYMENT.md). + +`PTOUCH_IDLE_POLL_SECONDS` controls printer status reads while the queue is idle, on either transport: + +| Value | Idle behavior | +|-------|---------------| +| unset or empty | Read the printer whenever a client asks for status, at most every 2 seconds (the original behavior) | +| `0` | Never read it while idle; tape and offline status update only when a job runs | +| `N` | At most one read attempt per `N` seconds, counting failed attempts | + +Jobs always read live status before printing, whatever the setting. Over Bluetooth every idle read is a connection, which costs the printer's battery; use `0` for a battery-powered printer. Any other value fails startup. + ### USB diagnostics `ptouch-app probe` enumerates only the custom USB scheme and reports whether @@ -261,7 +275,7 @@ initialization (`ESC @`), then checks that those bytes reached USB. Use it only on a known closed tape run: initialization can destroy a held run. It is not a read-only status preflight or the isolated IPP/socket image smoke used in CI. Neither diagnostic chooses a different attached model when selection is absent -or ambiguous. +or ambiguous. Both diagnose USB only and refuse to run while `PTOUCH_BT_ADDRESS` is set. ## Deploy @@ -271,6 +285,7 @@ The deploy image is published to GHCR as `.github/workflows/deploy.yml` on every push to `main`. Both pull requests and main run the core, driver, and capture suites and submit real IPP jobs to a socket sink in the deploy-image smoke before publication. Pull requests do not publish. +Tags carry `linux/amd64` and `linux/arm64` variants; CI builds and tests the arm64 variant under QEMU on every pull request. The image is a multi-stage build (`Dockerfile.deploy`): PAPPL 1.4.11 and the app compile in a Debian build stage, and only the runtime libraries, `avahi-daemon`, @@ -337,6 +352,8 @@ segment itself. Behind a proxy, or on a bridge network, mDNS will not reach LAN clients: publish unicast DNS-SD records (RFC 6763 `_ipp._tcp` PTR/SRV/TXT) in your zone instead, pointing at wherever clients should connect. +The compose example above is the USB deployment. For Bluetooth on a Raspberry Pi, follow [docs/PI-DEPLOYMENT.md](docs/PI-DEPLOYMENT.md). + The PT-2730 USB interface can be held by only one process. A dev/spike container holding the device must be stopped before this container starts; both cannot own the device at once. diff --git a/docs/PI-DEPLOYMENT.md b/docs/PI-DEPLOYMENT.md new file mode 100644 index 0000000..9d81aa5 --- /dev/null +++ b/docs/PI-DEPLOYMENT.md @@ -0,0 +1,75 @@ +# Raspberry Pi Bluetooth deployment + +This guide runs the server on a wall-powered Raspberry Pi Zero 2 W and reaches a battery-powered PT-P710BT over Bluetooth, so the printer can move around the house. The Bluetooth path is software-tested; its physical acceptance is pending (see [README](../README.md#capabilities)). + +## What you need + +- Raspberry Pi Zero 2 W running **64-bit** Raspberry Pi OS Lite (Bookworm). The published image has `linux/arm64` and `linux/amd64` variants; a 32-bit OS cannot run it. +- The PT-P710BT, charged, within about 10 m of the Pi. Walls shorten the range. + +## 1. Install Docker + +```sh +sudo apt update +sudo apt install -y docker.io +``` + +## 2. Pair the printer once + +Switch the printer on, then on the Pi: + +```sh +bluetoothctl +power on +scan on # wait for PT-P710BT and note its address +pair XX:XX:XX:XX:XX:XX +trust XX:XX:XX:XX:XX:XX +scan off +quit +``` + +If pairing asks for a PIN, try `0000`. BlueZ stores the pairing under `/var/lib/bluetooth` on the SD card, so it survives reboots. The container needs no BlueZ of its own. + +## 3. Hand mDNS to the container + +The container must use the host network: Linux refuses Bluetooth sockets inside a container's own network namespace. The container then runs its own Avahi on the host's mDNS port, so stop the Pi's: + +```sh +sudo systemctl disable --now avahi-daemon.socket avahi-daemon.service +``` + +The container advertises the printer under the Pi's hostname. + +## 4. Run the server + +```sh +sudo docker run -d --name ptouch --restart unless-stopped \ + --network host \ + --ulimit nofile=65536:65536 \ + -e PTOUCH_MODEL=PT-P710BT \ + -e PTOUCH_BT_ADDRESS=XX:XX:XX:XX:XX:XX \ + -e PTOUCH_IDLE_POLL_SECONDS=0 \ + ghcr.io/pfa230/ptouch-print-server:edge +``` + +No USB mount, device rule, or added capability is needed. `PTOUCH_IDLE_POLL_SECONDS=0` stops idle status reads, which would otherwise reconnect to the printer and drain its battery; tape width and offline status then update only when a job runs. Use a number of seconds instead to poll at most that often. + +Check it: + +```sh +sudo docker logs ptouch | grep 'Created printer' # ptouch-bt://Brother/PT-P710BT/
+sudo docker exec ptouch ptouch-app status -d ptouch +``` + +The queue is reachable at `ipp://.local:8000/ipp/print/ptouch`. + +## Day to day + +- **Switch the printer on to print.** A job sent while it is off or out of range waits in the queue and prints once the printer is reachable. +- **The connection lasts while jobs are queued.** It closes when the queue goes idle; the phone app can then connect. While the phone app holds the printer, the server sees it as offline and jobs wait. +- **Leaving range mid-print leaves the label uncertain.** The queue blocks new output. Power-cycle the printer, run `sudo docker restart ptouch`, then resubmit the job. +- **A held job keeps the connection open.** Plain `lp` and AirPrint jobs are not held. If a client holds a job (`job-hold-until`), the connection stays open even after that job is canceled; `sudo docker restart ptouch` releases it. + +## Roll back + +Stop the Pi container (`sudo docker rm -f ptouch`), re-enable Avahi if the Pi needs it (`sudo systemctl enable --now avahi-daemon`), and return to the USB deployment. diff --git a/openspec/changes/add-bluetooth-transport/design.md b/openspec/changes/add-bluetooth-transport/design.md index 8e267d5..740842a 100644 --- a/openspec/changes/add-bluetooth-transport/design.md +++ b/openspec/changes/add-bluetooth-transport/design.md @@ -36,12 +36,12 @@ See [proposal.md](proposal.md) for motivation and the capability deltas for the ### Bluetooth is a second PAPPL device scheme -Add `src/device_bt.c` registering a `ptouch-bt://` scheme (`ptouch-bt://Brother/PT-P710BT/
`) with open, close, read, write and status callbacks over an `AF_BLUETOOTH`/`BTPROTO_RFCOMM` socket on channel 1. The scheme keeps PAPPL's lifetime: the connection opens when a job starts with no device open, stays open across queued jobs, and closes when the queue goes idle; an idle status read opens and closes its own connection. No extra connection state is added. +Add `src/device_bt.c` registering a `ptouch-bt://` scheme (`ptouch-bt://Brother/PT-P710BT/
`) with open, close and write callbacks and a bounded status read over an `AF_BLUETOOTH`/`BTPROTO_RFCOMM` socket on channel 1. The scheme keeps PAPPL's lifetime: the connection opens when a job starts with no device open, stays open across queued jobs, and closes when the queue goes idle; an idle status read opens and closes its own connection. No extra connection state is added. The scheme's callbacks keep the USB return conventions so the driver needs no transport-specific handling: - **Connect** is non-blocking with a 10-second deadline, then the socket returns to blocking mode. Ten seconds covers Bluetooth paging (about 5 seconds) plus RFCOMM setup, and replaces the kernel's 30-second default so an absent printer cannot stall a status read for half a minute. -- **Status read** receives with `MSG_WAITALL` and a 300 ms receive timeout, the same budget as the USB status read. It returns the requested bytes once they all arrive, the partial count at timeout, 0 if nothing arrived, and -1 on EOF or error. A reply split across RFCOMM frames is therefore assembled inside one read call, and the driver's existing ten-call accumulation loop (`src/driver.c:89`) stays unchanged for both transports. +- **Status read** loops `poll` and `recv` within a 300 ms deadline, the same budget as the USB status read. It returns the requested bytes once they all arrive, the partial count at timeout, 0 if nothing arrived, and -1 on EOF or error. A reply split across RFCOMM frames is therefore assembled inside one read call, and the driver's existing ten-call accumulation loop (`src/driver.c:89`) stays unchanged for both transports. - **Write** loops until every byte is sent and returns -1 on error or EOF, like the USB full write. The address is parsed with `sscanf` into `bdaddr_t`, so the build needs only the `libbluetooth-dev` headers (`bluetooth.h`, `rfcomm.h`); the binary links no new library and the runtime image is unchanged. diff --git a/openspec/changes/add-bluetooth-transport/tasks.md b/openspec/changes/add-bluetooth-transport/tasks.md index 7948ebf..5205dd5 100644 --- a/openspec/changes/add-bluetooth-transport/tasks.md +++ b/openspec/changes/add-bluetooth-transport/tasks.md @@ -2,31 +2,31 @@ ## 1. Configuration and build dependency -- [ ] 1.1 Add `libbluetooth-dev` to the build environments in `Dockerfile` and the build stage of `Dockerfile.deploy`; verify both images build and that `ldd ptouch-app` lists no new library. -- [ ] 1.2 Parse `PTOUCH_BT_ADDRESS` and `PTOUCH_IDLE_POLL_SECONDS` once at startup in `src/main.c`, failing on a malformed address, an address with any model other than PT-P710BT, or a polling value that is not a non-negative integer; verify each accepted and rejected case in a startup test, and that both unset leaves the USB queue URI and identity unchanged. -- [ ] 1.3 Document both variables and their defaults in README; check the text against the 1.2 test cases. +- [x] 1.1 Add `libbluetooth-dev` to the build environments in `Dockerfile` and the build stage of `Dockerfile.deploy`; verify both images build and that `ldd ptouch-app` lists no new library. +- [x] 1.2 Parse `PTOUCH_BT_ADDRESS` and `PTOUCH_IDLE_POLL_SECONDS` once at startup in `src/main.c`, failing on a malformed address, an address with any model other than PT-P710BT, or a polling value that is not a non-negative integer; verify each accepted and rejected case in a startup test, and that both unset leaves the USB queue URI and identity unchanged. +- [x] 1.3 Document both variables and their defaults in README; check the text against the 1.2 test cases. ## 2. Idle polling control -- [ ] 2.1 Rate-limit `status_cb` by the parsed polling value, timing from the start of each idle probe attempt (absent: unchanged; `0`: no device access; `N`: at most one attempt per `N` seconds, keeping the last published status); verify in `test_driver` with a fake clock that unset matches current behavior, `0` opens no device across repeated calls, `N` attempts once per window both when reads succeed and when the open fails, and job-owned media refresh still runs with polling `0`. +- [x] 2.1 Rate-limit `status_cb` by the parsed polling value, timing from the start of each idle probe attempt (absent: unchanged; `0`: no device access; `N`: at most one attempt per `N` seconds, keeping the last published status); verify in `test_driver` with a fake clock that unset matches current behavior, `0` opens no device across repeated calls, `N` attempts once per window both when reads succeed and when the open fails, and job-owned media refresh still runs with polling `0`. ## 3. Bluetooth transport -- [ ] 3.1 Add `src/device_bt.*` registering the `ptouch-bt://Brother/PT-P710BT/
` scheme on RFCOMM channel 1, with a 10-second non-blocking connect, full write, 300 ms `MSG_WAITALL` status read (requested bytes, partial count at timeout, 0 if nothing arrived, -1 on EOF or error) and close; verify in a new adapter test using a socketpair in place of RFCOMM: URI formatting/parsing, rejection of other models and malformed addresses, a 32-byte status reply written in many small fragments returned whole by one read, a delayed reply, timeout versus EOF, and a short or failed write. -- [ ] 3.2 Make the driver's live-status gates and status read transport-neutral, keeping the USB presence count USB-only and the socket test transport skipped; verify in `test_driver` that USB, Bluetooth and socket URIs each reach only their own operations, and that an unreachable Bluetooth device publishes offline without the "present but cannot be opened" error. -- [ ] 3.3 Verify link loss on the socketpair transport: close the peer during raster and during finalization; the job fails, cleanup writes nothing further, recovery is required, and a following valid job emits no raster. -- [ ] 3.4 When an address is configured, make discovery and queue creation use only the Bluetooth URI; verify with fake USB that an attached USB PT-P710BT receives no status request or output, and that the queue identity is still PT-P710BT. -- [ ] 3.5 Verify byte identity: capture one PNG job per finishing policy over the socketpair transport and compare with the existing P710BT USB captures; any difference fails the task. -- [ ] 3.6 Run the full build and tests in the pinned container (`cmake -S . -B build`, `cmake --build build`, `ctest --test-dir build --output-on-failure`) plus both existing image smoke variants; retain logs. +- [x] 3.1 Add `src/device_bt.*` registering the `ptouch-bt://Brother/PT-P710BT/
` scheme on RFCOMM channel 1, with a 10-second non-blocking connect, full write, 300 ms assembling status read (requested bytes, partial count at timeout, 0 if nothing arrived, -1 on EOF or error) and close; verify in a new adapter test using a socketpair in place of RFCOMM: URI formatting/parsing, rejection of other models and malformed addresses, a 32-byte status reply written in many small fragments returned whole by one read, a delayed reply, timeout versus EOF, and a short or failed write. +- [x] 3.2 Make the driver's live-status gates and status read transport-neutral, keeping the USB presence count USB-only and the socket test transport skipped; verify in `test_driver` that USB, Bluetooth and socket URIs each reach only their own operations, and that an unreachable Bluetooth device publishes offline without the "present but cannot be opened" error. +- [x] 3.3 Verify link loss on the socketpair transport: close the peer during raster and during finalization; the job fails, cleanup writes nothing further, recovery is required, and a following valid job emits no raster. +- [x] 3.4 When an address is configured, make discovery and queue creation use only the Bluetooth URI; verify with fake USB that an attached USB PT-P710BT receives no status request or output, and that the queue identity is still PT-P710BT. +- [x] 3.5 Verify byte identity: capture one PNG job per finishing policy over the socketpair transport and compare with the existing P710BT USB captures; any difference fails the task. +- [x] 3.6 Run the full build and tests in the pinned container (`cmake -S . -B build`, `cmake --build build`, `ctest --test-dir build --output-on-failure`) plus both existing image smoke variants; retain logs. ## 4. Image and deployment - [ ] 4.1 In `.github/workflows/deploy.yml`, set up QEMU, add a non-publishing `linux/arm64` build step beside the amd64 check build, and add `linux/arm64` to the publish step; verify locally with `docker buildx build --platform linux/arm64 -f Dockerfile.deploy .` that the arm64 build and its CTest pass, and record the duration. -- [ ] 4.2 Write the Pi deployment guide in `docs/` (64-bit Raspberry Pi OS Lite, Docker, `bluetoothctl` pair and trust, disabling host `avahi-daemon`, `--network host` with no USB mount, the three variables, recommended polling `0`, about 10 m range, mid-job link loss recovery); review each command against the design and the container's entrypoint. +- [x] 4.2 Write the Pi deployment guide in `docs/` (64-bit Raspberry Pi OS Lite, Docker, `bluetoothctl` pair and trust, disabling host `avahi-daemon`, `--network host` with no USB mount, the three variables, recommended polling `0`, about 10 m range, mid-job link loss recovery); review each command against the design and the container's entrypoint. ## 5. Physical acceptance -- [ ] 5.1 Prepare a concrete bounded Bluetooth gate in `tests/` before any output: pairing check; a reset-free status reply on a fresh connection; one 12 mm default-trim label with receiving-ready time recorded; two jobs queued together sharing one connection, then closed when idle; a chain-only job followed after the queue went idle by a chain-and-final job; the measured connect failure time with the printer off; a job queued while the printer is off that prints after power-on; polling `0` showing no idle connects in the log; a USB-attached unit receiving nothing; and a Pi reboot followed by a label without re-pairing. State the label budget and stop conditions, and present the gate for approval. +- [x] 5.1 Prepare a concrete bounded Bluetooth gate in `tests/` before any output: pairing check; a reset-free status reply on a fresh connection; one 12 mm default-trim label with receiving-ready time recorded; two jobs queued together sharing one connection, then closed when idle; a chain-only job followed after the queue went idle by a chain-and-final job; the measured connect failure time with the printer off; a job queued while the printer is off that prints after power-on; polling `0` showing no idle connects in the log; a USB-attached unit receiving nothing; and a Pi reboot followed by a label without re-pairing. State the label budget and stop conditions, and present the gate for approval. - [ ] 5.2 After approval, run the gate on the Pi with the P710BT on battery; record captures, job logs and operator observations, stopping on the first failed criterion. If the held chain end is lost across the idle reconnect, stop and report it as a contract decision rather than adjusting behavior. - [ ] 5.3 Update README capabilities and the acceptance record with the certified Bluetooth scope and anything left software-only. diff --git a/src/config.c b/src/config.c new file mode 100644 index 0000000..1667a7d --- /dev/null +++ b/src/config.c @@ -0,0 +1,40 @@ +#include +#include +#include +#include "config.h" + +bool pt_parse_idle_poll(const char *text, int *seconds) +{ + if (!text || !*text) { + *seconds = -1; + return true; + } + long value = 0; + for (const char *p = text; *p; p++) { + if (!isdigit((unsigned char)*p)) return false; + value = value * 10 + (*p - '0'); + if (value > INT_MAX) return false; + } + *seconds = (int)value; + return true; +} + +static int hex_digit(char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +bool pt_parse_bt_address(const char *text, uint8_t address[6]) +{ + if (!text) return false; + for (int i = 0; i < 6; i++) { + const char *p = text + i * 3; + int high = hex_digit(p[0]), low = high < 0 ? -1 : hex_digit(p[1]); + if (low < 0 || p[2] != (i < 5 ? ':' : '\0')) return false; + address[i] = (uint8_t)(high << 4 | low); + } + return true; +} diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..0504182 --- /dev/null +++ b/src/config.h @@ -0,0 +1,17 @@ +#ifndef PT_CONFIG_H +#define PT_CONFIG_H + +#include +#include + +/* Startup setting parsers. Pure: no environment access, no I/O. */ + +/* PTOUCH_IDLE_POLL_SECONDS. NULL/empty yields -1 (follow every PAPPL status + * request); otherwise a non-negative decimal integer, where 0 disables idle + * device access. Anything else is rejected. */ +bool pt_parse_idle_poll(const char *text, int *seconds); + +/* PTOUCH_BT_ADDRESS as XX:XX:XX:XX:XX:XX hex, most significant byte first. */ +bool pt_parse_bt_address(const char *text, uint8_t address[6]); + +#endif /* PT_CONFIG_H */ diff --git a/src/device_bt.c b/src/device_bt.c new file mode 100644 index 0000000..d0a5f74 --- /dev/null +++ b/src/device_bt.c @@ -0,0 +1,162 @@ +/* Custom "ptouch-bt://" PAPPL device scheme over a kernel RFCOMM socket. + * Pairing is a host step (bluetoothctl); this scheme only connects to the + * configured address. Return conventions match the "ptouch://" USB scheme. + * Replies are read only through pt_bt_read_status, so there is no read_cb. */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "config.h" +#include "device_bt.h" + +#define PT_BT_PREFIX "ptouch-bt://Brother/PT-P710BT/" +#define PT_BT_CHANNEL 1 +#define PT_BT_CONNECT_TIMEOUT_MS 10000 +#define PT_BT_STATUS_TIMEOUT_MS 300 + +typedef struct { int fd; } pt_bt_conn; + +static bool pt_bt_address_from_uri(const char *uri, uint8_t address[6]) +{ + size_t n = strlen(PT_BT_PREFIX); + return uri && !strncmp(uri, PT_BT_PREFIX, n) && pt_parse_bt_address(uri + n, address); +} + +bool pt_bt_is_uri(const char *uri) +{ + uint8_t address[6]; + return pt_bt_address_from_uri(uri, address); +} + +void pt_bt_format_uri(const char *address, char *buffer, size_t size) +{ + snprintf(buffer, size, PT_BT_PREFIX "%s", address); +} + +static int pt_ms_left(const struct timespec *deadline) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + long ms = (deadline->tv_sec - now.tv_sec) * 1000 + (deadline->tv_nsec - now.tv_nsec) / 1000000; + return ms > 0 ? (int)ms : 0; +} + +/* Non-blocking connect bounded by our deadline, replacing the kernel's 30 s + * RFCOMM default; the connected socket is returned in blocking mode. */ +static int pt_bt_connect(const uint8_t address[6]) +{ + int fd = socket(AF_BLUETOOTH, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, BTPROTO_RFCOMM); + if (fd < 0) + return -1; + struct sockaddr_rc peer = { .rc_family = AF_BLUETOOTH, .rc_channel = PT_BT_CHANNEL }; + for (int i = 0; i < 6; i++) + peer.rc_bdaddr.b[i] = address[5 - i]; /* bdaddr_t is little-endian */ + struct pollfd p = { .fd = fd, .events = POLLOUT }; + int error = 0; + socklen_t length = sizeof error; + bool connected = connect(fd, (struct sockaddr *)&peer, sizeof peer) == 0 || + (errno == EINPROGRESS && poll(&p, 1, PT_BT_CONNECT_TIMEOUT_MS) == 1 && + getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &length) == 0 && error == 0); + if (connected && fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) & ~O_NONBLOCK) == 0) + return fd; + close(fd); + return -1; +} + +/* Receive every requested byte within timeout_ms. Timeout yields the count so + * far (0 = no data yet); EOF is a failure. */ +static ssize_t pt_bt_recv(int fd, void *buffer, size_t bytes, int timeout_ms) +{ + struct timespec deadline; + clock_gettime(CLOCK_MONOTONIC, &deadline); + deadline.tv_sec += timeout_ms / 1000; + deadline.tv_nsec += (long)(timeout_ms % 1000) * 1000000; + if (deadline.tv_nsec >= 1000000000L) { deadline.tv_sec++; deadline.tv_nsec -= 1000000000L; } + size_t got = 0; + while (got < bytes) { + struct pollfd p = { .fd = fd, .events = POLLIN }; + int ready = poll(&p, 1, pt_ms_left(&deadline)); + if (ready < 0 && errno == EINTR) continue; + if (ready < 0) return -1; + if (ready == 0) break; + ssize_t n = recv(fd, (char *)buffer + got, bytes - got, 0); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return -1; + got += (size_t)n; + } + return (ssize_t)got; +} + +static bool pt_bt_open(pappl_device_t *device, const char *device_uri, const char *name) +{ + (void)name; + uint8_t address[6]; + if (!pt_bt_address_from_uri(device_uri, address)) { + papplDeviceError(device, "Unsupported P-touch Bluetooth URI: %s", device_uri); + return false; + } + int fd = pt_bt_connect(address); + if (fd < 0) { + papplDeviceError(device, "PT-P710BT at %s is not reachable over Bluetooth", + device_uri + strlen(PT_BT_PREFIX)); + return false; + } + pt_bt_conn *c = calloc(1, sizeof *c); + if (!c) { + close(fd); + return false; + } + c->fd = fd; + papplDeviceSetData(device, c); + return true; +} + +static void pt_bt_close(pappl_device_t *device) +{ + pt_bt_conn *c = papplDeviceGetData(device); + if (!c) + return; + close(c->fd); + free(c); + papplDeviceSetData(device, NULL); +} + +ssize_t pt_bt_read_status(pappl_device_t *device, const char *device_uri, + void *buffer, size_t bytes) +{ + if (!device || !pt_bt_is_uri(device_uri)) + return -1; + pt_bt_conn *c = papplDeviceGetData(device); + return c ? pt_bt_recv(c->fd, buffer, bytes, PT_BT_STATUS_TIMEOUT_MS) : -1; +} + +static ssize_t pt_bt_write(pappl_device_t *device, const void *buffer, size_t bytes) +{ + pt_bt_conn *c = papplDeviceGetData(device); + if (!c) + return -1; + const char *p = buffer; + size_t sent = 0; + while (sent < bytes) { /* full-write: PAPPL won't retry the tail */ + ssize_t n = send(c->fd, p + sent, bytes - sent, MSG_NOSIGNAL); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return -1; + sent += (size_t)n; + } + return (ssize_t)bytes; +} + +void pt_bt_register(void) +{ + papplDeviceAddScheme("ptouch-bt", PAPPL_DEVTYPE_CUSTOM_LOCAL, + NULL, pt_bt_open, pt_bt_close, + NULL, pt_bt_write, NULL, NULL); +} diff --git a/src/device_bt.h b/src/device_bt.h new file mode 100644 index 0000000..af62ce7 --- /dev/null +++ b/src/device_bt.h @@ -0,0 +1,23 @@ +#ifndef PT_DEVICE_BT_H +#define PT_DEVICE_BT_H + +#include +#include /* ssize_t */ +#include /* pappl_device_t */ + +/* Exact PT-P710BT Bluetooth URI with a well-formed address; no radio access. */ +bool pt_bt_is_uri(const char *uri); +void pt_bt_format_uri(const char *address, char *buffer, size_t size); + +/* Register the custom "ptouch-bt://" RFCOMM device scheme with PAPPL. + * Must be called before any device open. */ +void pt_bt_register(void); + +/* 300 ms status read that assembles the requested bytes: the full count, the + * partial count at timeout, 0 if nothing arrived, -1 on EOF or error. + * device_uri must be the owning printer's URI; the guard runs before + * accessing connection data. */ +ssize_t pt_bt_read_status(pappl_device_t *device, const char *device_uri, + void *buffer, size_t bytes); + +#endif /* PT_DEVICE_BT_H */ diff --git a/src/driver.c b/src/driver.c index 959851a..edbd0b6 100644 --- a/src/driver.c +++ b/src/driver.c @@ -9,6 +9,8 @@ #include "raster.h" #include "status.h" #include "device_usb.h" +#include "device_bt.h" +#include "config.h" #include "cutter.h" #include #include @@ -57,6 +59,18 @@ static bool pt_dev_write(pappl_device_t *device, const uint8_t *buf, size_t n) return accepted == (ssize_t)n && after.write_bytes - before.write_bytes == n; } +/* Live printer transports; the socket test sink has no status path. */ +static bool pt_live_uri(const char *uri) +{ + return pt_usb_is_uri(uri) || pt_bt_is_uri(uri); +} + +static ssize_t pt_read_status(pappl_device_t *device, const char *uri, void *buffer, size_t bytes) +{ + return pt_bt_is_uri(uri) ? pt_bt_read_status(device, uri, buffer, bytes) + : pt_usb_read_status(device, uri, buffer, bytes); +} + /* The device may not have the 32-byte status reply ready on the first read, so * poll a few times with a short delay, mirroring upstream ptouch_getstatus. Each * read is itself bounded (300 ms) so the loop cannot hang: ~4 s worst case. */ @@ -70,12 +84,12 @@ typedef struct { ssize_t n; bool parsed; pt_status st; } pt_live_read; /* Read the live tape from an already-open device: ESC i S, then poll for * the reply. The caller owns the device and interprets the result; a supported * tape is `parsed && st.tape_px > 0`. Validate transport before any command or - * connection-data access; a socket sink has no USB status path. */ + * connection-data access; a socket sink has no status path. */ static pt_live_read pt_read_live_tape(pappl_device_t *device, const char *uri) { uint8_t cmd[8]; pt_live_read r = {0}; - if (!pt_usb_is_uri(uri)) { + if (!pt_live_uri(uri)) { r.n = -1; return r; } @@ -89,7 +103,7 @@ static pt_live_read pt_read_live_tape(pappl_device_t *device, const char *uri) for (int tries = 0; tries < PT_STATUS_POLL_TRIES; tries++) { struct timespec w = {0, PT_STATUS_POLL_NS}; nanosleep(&w, NULL); - ssize_t n = pt_usb_read_status(device, uri, buf + used, sizeof buf - used); + ssize_t n = pt_read_status(device, uri, buf + used, sizeof buf - used); if (n < 0) { r.n = n; break; } used += (size_t)n; r.n = (ssize_t)used; @@ -216,6 +230,9 @@ typedef struct { const pt_dev *model; pt_run run; bool media_confirmed; + int idle_poll_seconds; /* -1: every PAPPL request; 0: never; N: minimum gap */ + bool idle_polled; + struct timespec last_idle_poll; } pt_printer_state; static pt_printer_state *pt_state(pappl_printer_t *printer) @@ -430,7 +447,7 @@ static bool r_writeline(pappl_job_t *j, pappl_pr_options_t *o, pappl_device_t *d return pt_dev_write(d, buf, length) || pt_fail_job(j); } -/* Wait for receive-ready, not just print-completed. This is USB flow control, +/* Wait for receive-ready, not just print-completed. This is printer flow control, * independent of the client finishing choice. No commands are sent here. */ #define PT_PRINT_READY_TIMEOUT_MS 20000 static long pt_ms_since(const struct timespec *t0); @@ -438,14 +455,14 @@ static long pt_ms_since(const struct timespec *t0); static bool pt_wait_ready(pappl_job_t *j, pappl_device_t *d) { const char *uri = papplPrinterGetDeviceURI(papplJobGetPrinter(j)); - if (!pt_usb_is_uri(uri)) return true; + if (!pt_live_uri(uri)) return true; uint8_t packet[32]; size_t used = 0; struct timespec start; clock_gettime(CLOCK_MONOTONIC, &start); while (pt_ms_since(&start) < PT_PRINT_READY_TIMEOUT_MS) { if (papplJobIsCanceled(j)) return false; - ssize_t n = pt_usb_read_status(d, uri, packet + used, sizeof packet - used); + ssize_t n = pt_read_status(d, uri, packet + used, sizeof packet - used); if (n < 0) { papplJobSetMessage(j, "printer status read failed after printing"); return false; @@ -609,15 +626,36 @@ static bool pt_publish_tape(pappl_printer_t *printer, pt_live_read live) return false; } +/* Claim an idle probe slot. The slot is taken when the attempt starts, so a + * failed open also waits out the interval instead of retrying on every query. */ +static bool pt_idle_poll_due(pappl_printer_t *printer) +{ + pt_printer_state *state = pt_state(printer); + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + pthread_mutex_lock(&state->lock); + bool due = state->idle_poll_seconds < 0 || + (state->idle_poll_seconds > 0 && + (!state->idle_polled || now.tv_sec - state->last_idle_poll.tv_sec >= state->idle_poll_seconds)); + if (due) { + state->idle_polled = true; + state->last_idle_poll = now; + } + pthread_mutex_unlock(&state->lock); + return due; +} + static bool status_cb(pappl_printer_t *printer) { /* The job owns live status and may have an unfinished command in flight. */ if (papplPrinterGetState(printer) == IPP_PSTATE_PROCESSING) return true; if (pt_recovery_required(printer)) return true; const char *uri = papplPrinterGetDeviceURI(printer); - if (!pt_usb_is_uri(uri)) return true; + if (!pt_live_uri(uri) || !pt_idle_poll_due(printer)) return true; const pt_dev *model = pt_state(printer)->model; - ssize_t count = pt_usb_count(model->vid, model->pid); + /* USB enumeration tells "unplugged" from "in use"; Bluetooth's only + * reachability check is the connection attempt itself. */ + ssize_t count = pt_usb_is_uri(uri) ? pt_usb_count(model->vid, model->pid) : 1; if (count != 1) { papplPrinterSetReasons(printer, PAPPL_PREASON_OFFLINE, PT_STATUS_REASONS & ~PAPPL_PREASON_OFFLINE); pt_set_media_confirmed(printer, false); @@ -631,7 +669,8 @@ static bool status_cb(pappl_printer_t *printer) if (papplPrinterGetState(printer) == IPP_PSTATE_PROCESSING) return true; papplPrinterSetReasons(printer, PAPPL_PREASON_OFFLINE, PT_STATUS_REASONS & ~PAPPL_PREASON_OFFLINE); pt_set_media_confirmed(printer, false); - papplLogPrinter(printer, PAPPL_LOGLEVEL_ERROR, "%s is present but cannot be opened or claimed", model->name); + if (pt_usb_is_uri(uri)) + papplLogPrinter(printer, PAPPL_LOGLEVEL_ERROR, "%s is present but cannot be opened or claimed", model->name); return true; } /* Status requests do not initialize or finalize the printer. */ @@ -664,7 +703,7 @@ bool pt_refresh_ready_media(pappl_job_t *job, pappl_device_t *device) return false; } const char *uri = papplPrinterGetDeviceURI(printer); - if (!pt_usb_is_uri(uri)) return true; + if (!pt_live_uri(uri)) return true; struct timespec start; clock_gettime(CLOCK_MONOTONIC, &start); do { @@ -699,11 +738,23 @@ bool pt_driver_cb(pappl_system_t *system, const char *driver_name, const char *d "Printer identity '%s' does not match USB URI '%s'", model, device_uri); return false; } + if (device_uri && !strncmp(device_uri, "ptouch-bt://", 12) && + (!pt_bt_is_uri(device_uri) || dev->pid != 0x20af)) { + papplLog(system, PAPPL_LOGLEVEL_ERROR, + "Printer identity '%s' does not match Bluetooth URI '%s'", model, device_uri); + return false; + } + int idle_poll_seconds; + if (!pt_parse_idle_poll(getenv("PTOUCH_IDLE_POLL_SECONDS"), &idle_poll_seconds)) { + papplLog(system, PAPPL_LOGLEVEL_ERROR, "Invalid PTOUCH_IDLE_POLL_SECONDS"); + return false; + } pt_printer_state *state = calloc(1, sizeof *state); if (!state) return false; if (pthread_mutex_init(&state->lock, NULL)) { free(state); return false; } state->model = dev; + state->idle_poll_seconds = idle_poll_seconds; data->extension = state; data->rstartjob_cb = r_startjob; diff --git a/src/driver.h b/src/driver.h index 149b6be..416dbe6 100644 --- a/src/driver.h +++ b/src/driver.h @@ -8,7 +8,7 @@ #define PT_ROLL_MAX_CENTIMM 30000 /* 300 mm */ /* Confirm clean live tape before geometry is constructed, using a bounded - * status query on the job-owned USB handle. Non-USB test transports retain + * status query on the job-owned printer connection. Socket test transports retain * configured media. Uncertain output blocks every transport until recovery. */ bool pt_refresh_ready_media(pappl_job_t *job, pappl_device_t *device); diff --git a/src/main.c b/src/main.c index 0fb65af..0badd22 100644 --- a/src/main.c +++ b/src/main.c @@ -6,6 +6,8 @@ #include "driver.h" #include "filter_png.h" #include "device_usb.h" +#include "device_bt.h" +#include "config.h" #include "protocol.h" #include "tables.h" @@ -18,6 +20,13 @@ static pappl_pr_driver_t g_drivers[] = { * Passing NULL keeps PAPPL from auto-adding its built-in usb:// device, which * binds to the hanging transport (M0 Probe H). */ +/* Configured Bluetooth address, or NULL for the USB transport. */ +static const char *bt_address(void) +{ + const char *address = getenv("PTOUCH_BT_ADDRESS"); + return address && *address ? address : NULL; +} + /* `probe` subcommand: list ONLY our custom scheme (never PAPPL's built-in usb://, * whose device-ID probe is the M0 hang). Safe regression check for the scheme. */ static bool probe_print_cb(const char *info, const char *uri, const char *id, void *data) @@ -71,7 +80,9 @@ static pappl_system_t *system_cb(int num_options, cups_option_t *options, void * /* PAPPL 1.4.11 localizes its optional footer without checking for NULL. */ papplSystemSetFooterHTML(sys, ""); papplSystemAddListeners(sys, NULL); - pt_usb_register(); /* register the scheme BEFORE any list/open */ + /* Register the one configured transport BEFORE any list/open. */ + if (bt_address()) pt_bt_register(); + else pt_usb_register(); papplSystemSetPrinterDrivers(sys, NDRIVERS, g_drivers, NULL /*autoadd*/, NULL, pt_driver_cb, NULL); papplSystemAddMIMEFilter(sys, "image/png", "image/png", pt_filter_png, NULL); @@ -94,6 +105,16 @@ static pappl_system_t *system_cb(int num_options, cups_option_t *options, void * return sys; } + /* Bluetooth has no enumeration: the address alone selects the printer, and + * reachability is learned by connecting. USB is never touched. */ + if (bt_address()) { + pt_bt_format_uri(bt_address(), uri, sizeof uri); + pappl_printer_t *p = papplPrinterCreate(sys, 0, "ptouch", "brother_ptouch", id, uri); + papplLog(sys, p ? PAPPL_LOGLEVEL_INFO : PAPPL_LOGLEVEL_ERROR, + "%s printer for %s", p ? "Created" : "FAILED to create", uri); + return sys; + } + /* Always create the configured supported model's stable queue. Discovery * supplies diagnostics, never a first-device fallback or a different model. */ papplDeviceList(PAPPL_DEVTYPE_CUSTOM_LOCAL, discovery_cb, NULL, discovery_error_cb, sys); @@ -168,6 +189,26 @@ int main(int argc, char *argv[]) override_uri, model->name); return EXIT_FAILURE; } + const char *address = bt_address(); + uint8_t parsed[6]; + if (address && !pt_parse_bt_address(address, parsed)) { + fprintf(stderr, "PTOUCH_BT_ADDRESS=%s is not a Bluetooth address (XX:XX:XX:XX:XX:XX)\n", address); + return EXIT_FAILURE; + } + if (address && model->pid != 0x20af) { + fprintf(stderr, "PTOUCH_BT_ADDRESS requires PTOUCH_MODEL=PT-P710BT; %s has no Bluetooth\n", model->name); + return EXIT_FAILURE; + } + const char *poll = getenv("PTOUCH_IDLE_POLL_SECONDS"); + int seconds; + if (!pt_parse_idle_poll(poll, &seconds)) { + fprintf(stderr, "PTOUCH_IDLE_POLL_SECONDS=%s must be a whole number of seconds, 0 or more\n", poll); + return EXIT_FAILURE; + } + if (address && argc >= 2 && (!strcmp(argv[1], "smoke") || !strcmp(argv[1], "probe"))) { + fprintf(stderr, "%s diagnoses USB only; unset PTOUCH_BT_ADDRESS to use it\n", argv[1]); + return EXIT_FAILURE; + } if (argc >= 2 && strcmp(argv[1], "smoke") == 0) return run_smoke(); return papplMainloop(argc, argv, "1.0", NULL, diff --git a/tests/BLUETOOTH-ACCEPTANCE.md b/tests/BLUETOOTH-ACCEPTANCE.md new file mode 100644 index 0000000..b03a74e --- /dev/null +++ b/tests/BLUETOOTH-ACCEPTANCE.md @@ -0,0 +1,48 @@ +# PT-P710BT Bluetooth acceptance gate + +Status: **prepared, not run; awaiting approval.** Preparation used only socket-sink containers and printed nothing. The software evidence (fake-socket driver and adapter tests, byte identity with USB, both image smokes) does not replace these physical observations. + +## Bound + +Print **9 complete labels** on 12 mm tape, all from `label.png` (76×180 dots, 25.4 mm). Content totals 22.9 cm plus leaders; have at least 0.5 m loaded. There are no helper prints and no retries. Stop at the first failed criterion, unexpected output, job error, or `Output uncertain` message, and report it. A changed or repeated step needs a revised gate. + +## Setup + +On the Pi, follow [docs/PI-DEPLOYMENT.md](../docs/PI-DEPLOYMENT.md) through pairing, but start the server with idle polling **unset** (drop `-e PTOUCH_IDLE_POLL_SECONDS=0`). Load 12 mm tape and charge the printer. Keep the phone app closed. + +On the client (any host with `ipptool` and Pillow, a checkout of this repository, and the Pi reachable): + +```sh +ADDR=XX:XX:XX:XX:XX:XX # the printer's Bluetooth address +U=ipp://.local:8000/ipp/print/ptouch +G=tests/bluetooth-gate +python3 -I $G/make-fixture.py label.png +enum() { ipptool -t -d finishings=$1 -d copies=$2 -f label.png "$U" $G/print-enum.test; } +chain() { ipptool -t -d copies=$1 -f label.png "$U" $G/print-chain.test; } +``` + +On the Pi, in a second terminal, log the link once a second for the whole gate: + +```sh +while sleep 1; do echo "$(date +%T) $(bluetoothctl info $ADDR | grep Connected)"; done | tee connected.log +``` + +## Steps + +| Step | Action | Labels | Pass criteria | +|------|--------|-------:|---------------| +| 1 | `sudo docker exec ptouch ptouch-app status -d ptouch` with the printer on | 0 | Status shows no `offline`; `media-ready` reports 12 mm. The printer neither feeds nor cuts (status reads send no reset). | +| 2 | `enum 11 1`; record the submit-to-`completed` time from `ptouch-app jobs -d ptouch` | 1 | One intact 76-dot outline at 1:1, fresh leader removed, final cut. Job `completed`, not aborted. | +| 3 | Run `enum 11 1; enum 11 1` back to back | 2 | Two separate cut labels. `connected.log` shows one continuous `Connected: yes` span covering both jobs, then `no` once the queue is idle. | +| 4 | `chain 2`; wait for `Connected: no`, then 10 more seconds; `enum 60 2` | 4 | First job: internal cut, last end held. Second job: the held end is released without a new discarded leader, internal cut, final cut. If the held end was lost or a new leader appears, **stop**: that is a contract decision, not a retry. | +| 5 | Switch the printer off. `time sudo docker exec ptouch ptouch-app status -d ptouch` | 0 | Reports `offline`; the command returns within 11 seconds. Record the time. | +| 6 | With the printer still off, `enum 11 1`; wait 30 s; switch the printer on | 1 | The job waits (not aborted) while off and prints once the printer is on. Intact outline, final cut. | +| 7 | Recreate the container with `-e PTOUCH_IDLE_POLL_SECONDS=0`; run `ptouch-app status -d ptouch` five times over one minute | 0 | `connected.log` stays `Connected: no` for that minute; `docker logs` shows no new `not reachable` or status lines. | +| 8 | `sudo reboot` the Pi; after boot, without pairing again, `enum 11 1` | 1 | The container is running after boot and the label prints. | +| | **Total** | **9** | | + +The "USB unit receives nothing" requirement is covered by software only: the Bluetooth container has no USB mount, so it cannot reach USB at all. + +## Record + +Keep `connected.log`, `sudo docker logs ptouch` (before step 7's recreate and at the end), every `ipptool` output, the step 2 and step 5 timings, and photos or notes of each strip. Then update README capabilities with the certified Bluetooth scope. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index abf40bd..f90d80a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -17,6 +17,10 @@ add_executable(test_status test_status.c) target_link_libraries(test_status ptcore) add_test(NAME status COMMAND test_status) +add_executable(test_config test_config.c) +target_link_libraries(test_config ptcore) +add_test(NAME config COMMAND test_config) + add_executable(test_cutter test_cutter.c) target_link_libraries(test_cutter ptcore) add_test(NAME cutter COMMAND test_cutter) diff --git a/tests/bluetooth-gate/make-fixture.py b/tests/bluetooth-gate/make-fixture.py new file mode 100644 index 0000000..b0092a9 --- /dev/null +++ b/tests/bluetooth-gate/make-fixture.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""Write label.png: a 76x180-dot outline at 180 dpi, the full 12 mm imageable width.""" +import sys +from PIL import Image, ImageDraw + +image = Image.new("L", (76, 180), 255) +ImageDraw.Draw(image).rectangle((0, 0, 75, 179), outline=0, width=2) +image.save(sys.argv[1] if len(sys.argv) > 1 else "label.png", dpi=(180, 180)) diff --git a/tests/test_config.c b/tests/test_config.c new file mode 100644 index 0000000..af054b1 --- /dev/null +++ b/tests/test_config.c @@ -0,0 +1,25 @@ +#include +#include +#include "config.h" + +int main(void) +{ + int seconds = 7; + assert(pt_parse_idle_poll(NULL, &seconds) && seconds == -1); + assert(pt_parse_idle_poll("", &seconds) && seconds == -1); + assert(pt_parse_idle_poll("0", &seconds) && seconds == 0); + assert(pt_parse_idle_poll("300", &seconds) && seconds == 300); + const char *bad_polls[] = {"-1", "1.5", "5s", " 5", "abc", "99999999999"}; + for (unsigned i = 0; i < sizeof bad_polls / sizeof bad_polls[0]; i++) + assert(!pt_parse_idle_poll(bad_polls[i], &seconds)); + + uint8_t address[6]; + const uint8_t expected[6] = {0xA4, 0xC1, 0x38, 0xAB, 0xCD, 0xEF}; + assert(pt_parse_bt_address("A4:C1:38:AB:CD:EF", address) && !memcmp(address, expected, 6)); + assert(pt_parse_bt_address("a4:c1:38:ab:cd:ef", address) && !memcmp(address, expected, 6)); + const char *bad_addresses[] = {NULL, "", "A4:C1:38:AB:CD", "A4:C1:38:AB:CD:EF:", "A4-C1-38-AB-CD-EF", + "A4:C1:38:AB:CD:EG", "A4:C1:38:AB:CD:E", "A4C138ABCDEF"}; + for (unsigned i = 0; i < sizeof bad_addresses / sizeof bad_addresses[0]; i++) + assert(!pt_parse_bt_address(bad_addresses[i], address)); + return 0; +} diff --git a/tests/test_device_bt.c b/tests/test_device_bt.c new file mode 100644 index 0000000..de2c269 --- /dev/null +++ b/tests/test_device_bt.c @@ -0,0 +1,136 @@ +/* Production Bluetooth scheme I/O over a socketpair in place of RFCOMM. + * No radio, pairing, or BlueZ: only URI checks, the bounded connect's failure + * path, and the stream read/write conventions the driver depends on. */ +#include +#include +#include "../src/device_bt.c" + +#define URI "ptouch-bt://Brother/PT-P710BT/A4:C1:38:AB:CD:EF" + +static int peer = -1; + +/* Test scheme: production callbacks on one end of a socketpair. */ +static bool pair_open(pappl_device_t *device, const char *uri, const char *name) +{ + (void)uri; (void)name; + int fds[2]; + assert(!socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + pt_bt_conn *c = calloc(1, sizeof *c); assert(c); + c->fd = fds[0]; peer = fds[1]; + papplDeviceSetData(device, c); + return true; +} + +static long ms_since(const struct timespec *t0) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (now.tv_sec - t0->tv_sec) * 1000 + (now.tv_nsec - t0->tv_nsec) / 1000000; +} + +static void sleep_ms(long ms) +{ + struct timespec delay = {0, ms * 1000000L}; + nanosleep(&delay, NULL); +} + +static size_t chunk, delay_ms, total; +static unsigned char reply[32]; + +static void *send_reply(void *arg) +{ + (void)arg; + sleep_ms((long)delay_ms); + for (size_t sent = 0; sent < total; sent += chunk) { + assert(write(peer, reply + sent, chunk) == (ssize_t)chunk); + sleep_ms(5); + } + return NULL; +} + +static ssize_t read_with(pappl_device_t *device, size_t piece, size_t wait, size_t bytes, unsigned char *out) +{ + chunk = piece; delay_ms = wait; total = bytes; + pthread_t writer; assert(!pthread_create(&writer, NULL, send_reply, NULL)); + ssize_t n = pt_bt_read_status(device, URI, out, 32); + pthread_join(writer, NULL); + return n; +} + +static size_t drained; +static void *drain(void *arg) +{ + (void)arg; + char buffer[4096]; + ssize_t n; + while ((n = read(peer, buffer, sizeof buffer)) > 0) drained += (size_t)n; + return NULL; +} + +int main(void) +{ + assert(pt_bt_is_uri(URI)); + assert(pt_bt_is_uri("ptouch-bt://Brother/PT-P710BT/a4:c1:38:ab:cd:ef")); + const char *bad[] = {NULL, "", "ptouch-bt://Brother/PT-P710BT/", "ptouch-bt://Brother/PT-2730/A4:C1:38:AB:CD:EF", + "ptouch-bt://Brother/PT-P710BT/A4:C1:38:AB:CD:EF/", "ptouch-bt://Brother/PT-P710BT/A4:C1:38:AB:CD", + "ptouch://Brother/PT-P710BT", "socket://127.0.0.1:9100"}; + for (unsigned i = 0; i < sizeof bad / sizeof bad[0]; i++) assert(!pt_bt_is_uri(bad[i])); + char uri[80]; + pt_bt_format_uri("A4:C1:38:AB:CD:EF", uri, sizeof uri); + assert(!strcmp(uri, URI)); + + /* A malformed URI never reaches a socket; an unreachable address fails + * within the connect deadline (here no adapter exists at all). */ + pt_bt_register(); + assert(!papplDeviceOpen("ptouch-bt://Brother/PT-P710BT/zz", "bt-test", NULL, NULL)); + struct timespec start; + clock_gettime(CLOCK_MONOTONIC, &start); + assert(!papplDeviceOpen(URI, "bt-test", NULL, NULL)); + assert(ms_since(&start) < PT_BT_CONNECT_TIMEOUT_MS + 1000); + + papplDeviceAddScheme("btpair", PAPPL_DEVTYPE_CUSTOM_LOCAL, NULL, pair_open, pt_bt_close, + NULL, pt_bt_write, NULL, NULL); + pappl_device_t *device = papplDeviceOpen("btpair://test", "bt-test", NULL, NULL); assert(device); + for (unsigned i = 0; i < sizeof reply; i++) reply[i] = (unsigned char)(i + 1); + unsigned char got[32]; + + /* A reply split across many frames is assembled by one status read. */ + memset(got, 0, sizeof got); + assert(read_with(device, 2, 0, 32, got) == 32 && !memcmp(got, reply, 32)); + /* A delayed reply within the budget still arrives whole. */ + memset(got, 0, sizeof got); + assert(read_with(device, 32, 100, 32, got) == 32 && !memcmp(got, reply, 32)); + /* Silence is "no data yet" after the 300 ms budget, not an error. */ + clock_gettime(CLOCK_MONOTONIC, &start); + assert(read_with(device, 1, 0, 0, got) == 0); + long waited = ms_since(&start); + assert(waited >= PT_BT_STATUS_TIMEOUT_MS - 10 && waited < PT_BT_STATUS_TIMEOUT_MS + 500); + /* A partial reply returns its count at timeout for the caller to continue. */ + assert(read_with(device, 10, 0, 10, got) == 10); + /* The status guard rejects other URIs before touching connection data. */ + assert(pt_bt_read_status(device, "socket://127.0.0.1:9100", got, sizeof got) == -1); + assert(pt_bt_read_status(device, NULL, got, sizeof got) == -1); + + /* Full write across short sends: a tiny send buffer forces partial writes. */ + int small = 1024; + pt_bt_conn *c = papplDeviceGetData(device); + assert(!setsockopt(c->fd, SOL_SOCKET, SO_SNDBUF, &small, sizeof small)); + static unsigned char block[256 * 1024]; + drained = 0; + pthread_t reader; assert(!pthread_create(&reader, NULL, drain, NULL)); + assert(pt_bt_write(device, block, sizeof block) == (ssize_t)sizeof block); + shutdown(c->fd, SHUT_WR); + pthread_join(reader, NULL); + assert(drained == sizeof block); + close(peer); + papplDeviceClose(device); + + /* A closed link is a failure for reads and writes, never "no data". */ + device = papplDeviceOpen("btpair://closed", "bt-test", NULL, NULL); assert(device); + close(peer); + assert(pt_bt_read_status(device, URI, got, sizeof got) == -1); + assert(pt_bt_write(device, reply, sizeof reply) == -1); + papplDeviceClose(device); + puts("Bluetooth device checks passed"); + return 0; +} diff --git a/tests/test_device_usb.c b/tests/test_device_usb.c index ed29223..1336859 100644 --- a/tests/test_device_usb.c +++ b/tests/test_device_usb.c @@ -317,6 +317,37 @@ int main(void) } } assert(init_calls == previous_inits && opens == previous_opens && output_bytes == previous_output); + + /* Bluetooth and polling settings fail before any USB access. */ + unsetenv("PTOUCH_MODEL"); + setenv("PTOUCH_BT_ADDRESS", "A4:C1:38:AB:CD:EF", 1); + char *server[] = { "ptouch-app", "server", NULL }; + assert(pt_application_main(2, server) == EXIT_FAILURE); + setenv("PTOUCH_MODEL", "PT-P710BT", 1); + for (size_t j = 1; j < sizeof commands / sizeof commands[0]; j++) { + char *args[] = { "ptouch-app", (char *)commands[j], NULL }; + assert(pt_application_main(2, args) == EXIT_FAILURE); + } + setenv("PTOUCH_BT_ADDRESS", "A4:C1:38:AB:CD", 1); + assert(pt_application_main(2, server) == EXIT_FAILURE); + setenv("PTOUCH_BT_ADDRESS", "A4:C1:38:AB:CD:EF", 1); + const char *bad_polls[] = { "-1", "1.5", "soon" }; + for (size_t i = 0; i < sizeof bad_polls / sizeof bad_polls[0]; i++) { + setenv("PTOUCH_IDLE_POLL_SECONDS", bad_polls[i], 1); + assert(pt_application_main(2, server) == EXIT_FAILURE); + } + unsetenv("PTOUCH_IDLE_POLL_SECONDS"); + assert(init_calls == previous_inits && opens == previous_opens && output_bytes == previous_output); + /* A configured address creates only the Bluetooth queue, even with the + * same model attached over USB, and never enumerates USB. */ + selected = pt_lookup_name("PT-P710BT"); inventory(1, false); + char expected_id[100]; pt_usb_format_id(selected, expected_id, sizeof expected_id); + pappl_system_t *sys = system_cb(0, NULL, NULL); assert(sys); + assert(!strcmp(queue_uri, "ptouch-bt://Brother/PT-P710BT/A4:C1:38:AB:CD:EF")); + assert(!strcmp(queue_id, expected_id)); + assert(init_calls == previous_inits && opens == previous_opens && output_bytes == previous_output); + papplSystemDelete(sys); + unsetenv("PTOUCH_BT_ADDRESS"); puts("Both USB models, production diagnostics, startup identity, replug, and socket guards passed"); return 0; } diff --git a/tests/test_driver.c b/tests/test_driver.c index 8dad2fb..29b55a5 100644 --- a/tests/test_driver.c +++ b/tests/test_driver.c @@ -27,7 +27,8 @@ static char selected_id[128], selected_uri[128]; static struct { size_t rows, ff, eject, modes, cuts, final, infos, inits; } counts; static bool usb_present = true, can_open = true; static ssize_t usb_matches = 1; -static unsigned usb_enumerations, usb_reads; +static unsigned usb_enumerations, usb_reads, bt_reads, opens; +static bool bt_eof; static pappl_printer_t *poll_during_job; static bool busy_poll_checked; @@ -47,10 +48,8 @@ const pt_dev *pt_usb_model_from_uri(const char *uri) return NULL; } bool pt_usb_is_uri(const char *uri) { return pt_usb_model_from_uri(uri) != NULL; } -ssize_t pt_usb_read_status(pappl_device_t *d, const char *uri, void *buffer, size_t bytes) +static ssize_t feed_status(void *buffer, size_t bytes) { - (void)d; (void)uri; - usb_reads++; size_t n = status_size - status_used; if (n > bytes) n = bytes; if (n > status_chunk) n = status_chunk; @@ -58,8 +57,14 @@ ssize_t pt_usb_read_status(pappl_device_t *d, const char *uri, void *buffer, siz status_used += n; return (ssize_t)n; } +ssize_t pt_usb_read_status(pappl_device_t *d, const char *uri, void *buffer, size_t bytes) +{ (void)d; (void)uri; usb_reads++; return feed_status(buffer, bytes); } +bool pt_bt_is_uri(const char *uri) +{ return uri && !strcmp(uri, "ptouch-bt://Brother/PT-P710BT/A4:C1:38:AB:CD:EF"); } +ssize_t pt_bt_read_status(pappl_device_t *d, const char *uri, void *buffer, size_t bytes) +{ (void)d; assert(pt_bt_is_uri(uri)); bt_reads++; return bt_eof ? -1 : feed_status(buffer, bytes); } static bool dev_open(pappl_device_t *d, const char *uri, const char *name) -{ (void)d; (void)uri; (void)name; return can_open; } +{ (void)d; (void)uri; (void)name; opens++; return can_open; } static void dev_close(pappl_device_t *d) { (void)d; } static ssize_t dev_write(pappl_device_t *d, const void *buffer, size_t n) { @@ -635,6 +640,103 @@ static void reject_unsupported_finishing(pappl_printer_t *p) ippDelete(response); } +/* Two labels with deferred finalization; returns the complete wire. */ +static size_t transport_batch(pappl_printer_t *printer, const char *finishing, pappl_device_t *d, + unsigned char *out) +{ + pt_run_set(printer, PT_RUN_CLOSED); reset_wire(); + ready_status(0, 7); statuses[18] = statuses[19] = 0; status_size = 32; + pappl_job_t *j = job(printer, finishing, 1, false); + pappl_pr_options_t *o = begin(j, d); + assert(page_test(j, o, d)); + ready_status(0, 7); assert(page_test(j, o, d)); + ready_status(0, 7); end(j, o, d); + memcpy(out, wire, wire_used); + return wire_used; +} + +static void clean_snapshot(void) +{ + ready_status(0, 32); statuses[18] = statuses[19] = 0; status_size = 32; +} + +/* Bluetooth: identical driver bytes, its own status path, and no USB access. */ +static void bluetooth_cases(pappl_system_t *sys, pappl_printer_t *usb, pappl_device_t *d) +{ + const char *bt_uri = "ptouch-bt://Brother/PT-P710BT/A4:C1:38:AB:CD:EF"; + pappl_pr_driver_data_t rejected = {0}; ipp_t *attrs = NULL; + assert(!pt_driver_cb(sys, "brother_ptouch", "ptouch-bt://Brother/PT-P710BT/zz", + selected_id, &rejected, &attrs, NULL) && !rejected.extension); + pappl_printer_t *bt = papplPrinterCreate(sys, 0, "bt", "brother_ptouch", selected_id, bt_uri); + assert(bt); + unsigned enumerations = usb_enumerations, reads = usb_reads; + + clean_snapshot(); reset_wire(); unsigned before = bt_reads; + assert(status_cb(bt) && pt_media_confirmed(bt) && bt_reads > before); + assert(wire_used == 3 && !memcmp(wire, "\x1b\x69\x53", 3)); + can_open = false; assert(status_cb(bt)); can_open = true; + assert(!pt_media_confirmed(bt) && (papplPrinterGetReasons(bt) & PAPPL_PREASON_OFFLINE)); + + static unsigned char usb_wire[sizeof wire], bt_wire[sizeof wire]; + const char *finishings[] = {"none", "trim", "trim-after-job", "ptouch-chain"}; + for (unsigned i = 0; i < sizeof finishings / sizeof finishings[0]; i++) { + unsigned saved_usb = usb_enumerations, saved_reads = usb_reads; + size_t usb_size = transport_batch(usb, finishings[i], d, usb_wire); + usb_enumerations = saved_usb; usb_reads = saved_reads; + size_t bt_size = transport_batch(bt, finishings[i], d, bt_wire); + assert(usb_size == bt_size && !memcmp(usb_wire, bt_wire, bt_size)); + } + + /* Link loss during raster: the job fails, cleanup sends nothing, and the + * uncertain run blocks the next job before any command. */ + pt_run_set(bt, PT_RUN_CLOSED); reset_wire(); clean_snapshot(); + pappl_job_t *j = job(bt, "trim", 1, false); + pappl_pr_options_t *o = begin(j, d); + fail_opcode = 'G'; assert(!page_test(j, o, d)); + size_t sent = wire_used; end(j, o, d); + assert(wire_used == sent && pt_run_get(bt) == PT_RUN_UNCERTAIN); + pappl_job_t *follower = job(bt, "trim", 1, false); + o = papplJobCreatePrintOptions(follower, 1, false); + reset_wire(); assert(!r_startjob(follower, o, d) && wire_used == 0); + end(follower, o, d); + /* Link loss while finalizing: the status stream ends after the print command. */ + pt_run_set(bt, PT_RUN_CLOSED); reset_wire(); clean_snapshot(); + j = job(bt, "trim", 1, false); o = begin(j, d); assert(page_test(j, o, d)); + bt_eof = true; assert(!r_endjob(j, o, d)); bt_eof = false; + sent = wire_used; assert(r_endjob(j, o, d) && wire_used == sent); + papplJobDeletePrintOptions(o); + assert(pt_run_get(bt) == PT_RUN_UNCERTAIN); + follower = job(bt, "trim", 1, false); + o = papplJobCreatePrintOptions(follower, 1, false); + reset_wire(); assert(!r_startjob(follower, o, d) && wire_used == 0); + end(follower, o, d); + pt_run_set(bt, PT_RUN_CLOSED); + + /* Idle polling: 0 never touches the device, N allows one attempt per window + * whether or not it succeeds, and job status reads are unaffected. */ + pt_printer_state *state = pt_state(bt); + assert(state->idle_poll_seconds == -1); + unsigned opened = opens; + clean_snapshot(); assert(status_cb(bt)); clean_snapshot(); assert(status_cb(bt)); + assert(opens == opened + 2); + state->idle_poll_seconds = 0; opened = opens; before = bt_reads; + for (int i = 0; i < 3; i++) assert(status_cb(bt)); + assert(opens == opened && bt_reads == before); + clean_snapshot(); j = job(bt, "none", 1, false); + assert(pt_refresh_ready_media(j, d) && bt_reads > before); + state->idle_poll_seconds = 300; state->idle_polled = false; opened = opens; + clean_snapshot(); assert(status_cb(bt)); assert(status_cb(bt)); + assert(opens == opened + 1 && pt_media_confirmed(bt)); + state->last_idle_poll.tv_sec -= 300; + clean_snapshot(); assert(status_cb(bt) && opens == opened + 2); + can_open = false; state->idle_polled = false; opened = opens; + for (int i = 0; i < 3; i++) assert(status_cb(bt)); + assert(opens == opened + 1); + can_open = true; state->idle_poll_seconds = -1; + + assert(usb_enumerations == enumerations && usb_reads == reads); +} + int main(int argc, char **argv) { assert(argc <= 2); @@ -646,6 +748,8 @@ int main(int argc, char **argv) NULL, dev_write, dev_status, dev_id); papplDeviceAddScheme("ptouch", PAPPL_DEVTYPE_CUSTOM_LOCAL, NULL, dev_open, dev_close, NULL, dev_write, dev_status, dev_id); + papplDeviceAddScheme("ptouch-bt", PAPPL_DEVTYPE_CUSTOM_LOCAL, NULL, dev_open, dev_close, + NULL, dev_write, dev_status, dev_id); port = 20000 + getpid() % 20000; pappl_system_t *sys = papplSystemCreate(PAPPL_SOPTIONS_MULTI_QUEUE, "finishing-test", port, NULL, NULL, "/tmp/ptouch-driver-test.log", PAPPL_LOGLEVEL_ERROR, NULL, false); @@ -976,6 +1080,8 @@ int main(int argc, char **argv) o = papplJobCreatePrintOptions(j, 1, false); assert(o->header.cupsWidth == 85); papplJobDeletePrintOptions(o); + if (selected_model->pid == 0x20af) bluetooth_cases(sys, usb, d); + /* State belongs to each queue, with no arbitrary eight-printer ceiling. */ for (int i = 0; i < 10; i++) { char name[32]; snprintf(name, sizeof name, "owned-%d", i); diff --git a/tests/test_png_geometry.c b/tests/test_png_geometry.c index 16bdc27..4be73ea 100644 --- a/tests/test_png_geometry.c +++ b/tests/test_png_geometry.c @@ -36,6 +36,9 @@ ssize_t pt_usb_count(uint16_t vid, uint16_t pid) { (void)vid; (void)pid; return bool pt_usb_is_uri(const char *uri) { (void)uri; return false; } ssize_t pt_usb_read_status(pappl_device_t *d, const char *uri, void *buffer, size_t bytes) { (void)d; (void)uri; (void)buffer; (void)bytes; assert(!"Unexpected USB read"); return -1; } +bool pt_bt_is_uri(const char *uri) { (void)uri; return false; } +ssize_t pt_bt_read_status(pappl_device_t *d, const char *uri, void *buffer, size_t bytes) +{ (void)d; (void)uri; (void)buffer; (void)bytes; assert(!"Unexpected Bluetooth read"); return -1; } static bool device_open(pappl_device_t *d, const char *uri, const char *name) { (void)d; (void)uri; (void)name; return true; } static void device_close(pappl_device_t *d) { (void)d; } From 55ebe4e33148a6de05ddf4b80488e84b4eca1b2e Mon Sep 17 00:00:00 2001 From: Fedir Date: Wed, 14 Oct 2026 16:15:42 -0400 Subject: [PATCH 05/14] test(printer): add the Bluetooth gate's IPP request files --- tests/BLUETOOTH-ACCEPTANCE.md | 4 ++-- tests/bluetooth-gate/print-chain.ipp | 19 +++++++++++++++++++ tests/bluetooth-gate/print-enum.ipp | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 tests/bluetooth-gate/print-chain.ipp create mode 100644 tests/bluetooth-gate/print-enum.ipp diff --git a/tests/BLUETOOTH-ACCEPTANCE.md b/tests/BLUETOOTH-ACCEPTANCE.md index b03a74e..7e671b3 100644 --- a/tests/BLUETOOTH-ACCEPTANCE.md +++ b/tests/BLUETOOTH-ACCEPTANCE.md @@ -17,8 +17,8 @@ ADDR=XX:XX:XX:XX:XX:XX # the printer's Bluetooth address U=ipp://.local:8000/ipp/print/ptouch G=tests/bluetooth-gate python3 -I $G/make-fixture.py label.png -enum() { ipptool -t -d finishings=$1 -d copies=$2 -f label.png "$U" $G/print-enum.test; } -chain() { ipptool -t -d copies=$1 -f label.png "$U" $G/print-chain.test; } +enum() { ipptool -t -d finishings=$1 -d copies=$2 -f label.png "$U" $G/print-enum.ipp; } +chain() { ipptool -t -d copies=$1 -f label.png "$U" $G/print-chain.ipp; } ``` On the Pi, in a second terminal, log the link once a second for the whole gate: diff --git a/tests/bluetooth-gate/print-chain.ipp b/tests/bluetooth-gate/print-chain.ipp new file mode 100644 index 0000000..ccb7ccd --- /dev/null +++ b/tests/bluetooth-gate/print-chain.ipp @@ -0,0 +1,19 @@ +# Print-Job with the chain-only template: ipptool -d copies=2 -f label.png URI print-chain.ipp +{ + NAME "Print-Job ptouch-chain copies=$copies" + OPERATION Print-Job + GROUP operation-attributes-tag + ATTR charset attributes-charset utf-8 + ATTR naturalLanguage attributes-natural-language en + ATTR uri printer-uri $uri + ATTR name requesting-user-name bluetooth-gate + ATTR mimeMediaType document-format image/png + GROUP job-attributes-tag + ATTR integer copies $copies + ATTR collection finishings-col { + MEMBER keyword finishing-template ptouch-chain + } + FILE $filename + STATUS successful-ok + EXPECT job-id +} diff --git a/tests/bluetooth-gate/print-enum.ipp b/tests/bluetooth-gate/print-enum.ipp new file mode 100644 index 0000000..1ff5546 --- /dev/null +++ b/tests/bluetooth-gate/print-enum.ipp @@ -0,0 +1,17 @@ +# Print-Job with an enum finishing: ipptool -d finishings=11 -d copies=1 -f label.png URI print-enum.ipp +{ + NAME "Print-Job finishings=$finishings copies=$copies" + OPERATION Print-Job + GROUP operation-attributes-tag + ATTR charset attributes-charset utf-8 + ATTR naturalLanguage attributes-natural-language en + ATTR uri printer-uri $uri + ATTR name requesting-user-name bluetooth-gate + ATTR mimeMediaType document-format image/png + GROUP job-attributes-tag + ATTR integer copies $copies + ATTR enum finishings $finishings + FILE $filename + STATUS successful-ok + EXPECT job-id +} From e9f13bca41b0b13f7a4d1a13db01d95c3555d58e Mon Sep 17 00:00:00 2001 From: Fedir Date: Wed, 14 Oct 2026 16:43:29 -0400 Subject: [PATCH 06/14] fix(printer): address implementation review of the Bluetooth transport Measure the idle poll interval to the millisecond, reject PTOUCH_DEVICE_URI together with PTOUCH_BT_ADDRESS, submit chain-only as its template in the transport comparison, and intercept socket calls so the connect deadline and short sends are exercised. The acceptance gate now reads media-ready and job times over IPP and records connection events from BlueZ signals. --- .../changes/add-bluetooth-transport/tasks.md | 4 +- src/driver.c | 6 +- src/main.c | 4 + tests/BLUETOOTH-ACCEPTANCE.md | 20 ++-- tests/bluetooth-gate/get-job-times.ipp | 13 +++ tests/bluetooth-gate/get-printer-state.ipp | 12 ++ tests/test_device_bt.c | 106 +++++++++++++----- tests/test_device_usb.c | 3 + tests/test_driver.c | 22 +++- 9 files changed, 143 insertions(+), 47 deletions(-) create mode 100644 tests/bluetooth-gate/get-job-times.ipp create mode 100644 tests/bluetooth-gate/get-printer-state.ipp diff --git a/openspec/changes/add-bluetooth-transport/tasks.md b/openspec/changes/add-bluetooth-transport/tasks.md index 5205dd5..d675f44 100644 --- a/openspec/changes/add-bluetooth-transport/tasks.md +++ b/openspec/changes/add-bluetooth-transport/tasks.md @@ -14,9 +14,9 @@ - [x] 3.1 Add `src/device_bt.*` registering the `ptouch-bt://Brother/PT-P710BT/
` scheme on RFCOMM channel 1, with a 10-second non-blocking connect, full write, 300 ms assembling status read (requested bytes, partial count at timeout, 0 if nothing arrived, -1 on EOF or error) and close; verify in a new adapter test using a socketpair in place of RFCOMM: URI formatting/parsing, rejection of other models and malformed addresses, a 32-byte status reply written in many small fragments returned whole by one read, a delayed reply, timeout versus EOF, and a short or failed write. - [x] 3.2 Make the driver's live-status gates and status read transport-neutral, keeping the USB presence count USB-only and the socket test transport skipped; verify in `test_driver` that USB, Bluetooth and socket URIs each reach only their own operations, and that an unreachable Bluetooth device publishes offline without the "present but cannot be opened" error. -- [x] 3.3 Verify link loss on the socketpair transport: close the peer during raster and during finalization; the job fails, cleanup writes nothing further, recovery is required, and a following valid job emits no raster. +- [x] 3.3 Verify link loss: in `test_device_bt`, a closed socketpair peer makes the production status read and write fail; in `test_driver`, a Bluetooth queue whose write fails during raster, or whose status stream ends during finalization, fails the job, cleanup writes nothing further, recovery is required, and a following valid job emits no raster. - [x] 3.4 When an address is configured, make discovery and queue creation use only the Bluetooth URI; verify with fake USB that an attached USB PT-P710BT receives no status request or output, and that the queue identity is still PT-P710BT. -- [x] 3.5 Verify byte identity: capture one PNG job per finishing policy over the socketpair transport and compare with the existing P710BT USB captures; any difference fails the task. +- [x] 3.5 Verify byte identity: in `test_driver`, a two-label batch per finishing policy (chain-only submitted as its collection template) produces the same command and raster bytes on a USB and a Bluetooth queue, and the four policies produce distinct bytes; any difference fails the task. - [x] 3.6 Run the full build and tests in the pinned container (`cmake -S . -B build`, `cmake --build build`, `ctest --test-dir build --output-on-failure`) plus both existing image smoke variants; retain logs. ## 4. Image and deployment diff --git a/src/driver.c b/src/driver.c index edbd0b6..37c38a4 100644 --- a/src/driver.c +++ b/src/driver.c @@ -631,15 +631,13 @@ static bool pt_publish_tape(pappl_printer_t *printer, pt_live_read live) static bool pt_idle_poll_due(pappl_printer_t *printer) { pt_printer_state *state = pt_state(printer); - struct timespec now; - clock_gettime(CLOCK_MONOTONIC, &now); pthread_mutex_lock(&state->lock); bool due = state->idle_poll_seconds < 0 || (state->idle_poll_seconds > 0 && - (!state->idle_polled || now.tv_sec - state->last_idle_poll.tv_sec >= state->idle_poll_seconds)); + (!state->idle_polled || pt_ms_since(&state->last_idle_poll) >= state->idle_poll_seconds * 1000L)); if (due) { state->idle_polled = true; - state->last_idle_poll = now; + clock_gettime(CLOCK_MONOTONIC, &state->last_idle_poll); } pthread_mutex_unlock(&state->lock); return due; diff --git a/src/main.c b/src/main.c index 0badd22..c2f37a6 100644 --- a/src/main.c +++ b/src/main.c @@ -195,6 +195,10 @@ int main(int argc, char *argv[]) fprintf(stderr, "PTOUCH_BT_ADDRESS=%s is not a Bluetooth address (XX:XX:XX:XX:XX:XX)\n", address); return EXIT_FAILURE; } + if (address && override_uri && *override_uri) { + fprintf(stderr, "PTOUCH_DEVICE_URI and PTOUCH_BT_ADDRESS cannot both be set\n"); + return EXIT_FAILURE; + } if (address && model->pid != 0x20af) { fprintf(stderr, "PTOUCH_BT_ADDRESS requires PTOUCH_MODEL=PT-P710BT; %s has no Bluetooth\n", model->name); return EXIT_FAILURE; diff --git a/tests/BLUETOOTH-ACCEPTANCE.md b/tests/BLUETOOTH-ACCEPTANCE.md index 7e671b3..afa9bd6 100644 --- a/tests/BLUETOOTH-ACCEPTANCE.md +++ b/tests/BLUETOOTH-ACCEPTANCE.md @@ -19,25 +19,29 @@ G=tests/bluetooth-gate python3 -I $G/make-fixture.py label.png enum() { ipptool -t -d finishings=$1 -d copies=$2 -f label.png "$U" $G/print-enum.ipp; } chain() { ipptool -t -d copies=$1 -f label.png "$U" $G/print-chain.ipp; } +state() { ipptool -tv "$U" $G/get-printer-state.ipp | grep -E 'printer-state|media-ready'; } +times() { ipptool -tv "$U" $G/get-job-times.ipp | grep -E 'job-id|job-state|time-at'; } ``` -On the Pi, in a second terminal, log the link once a second for the whole gate: +On the Pi, in a second terminal, record every connect and disconnect for the whole gate. BlueZ signals each change of the device's `Connected` property, so no event is missed between samples: ```sh -while sleep 1; do echo "$(date +%T) $(bluetoothctl info $ADDR | grep Connected)"; done | tee connected.log +dbus-monitor --system "type='signal',path='/org/bluez/hci0/dev_${ADDR//:/_}'" | tee connected.log ``` +`grep -A1 '"Connected"' connected.log` lists the changes in order; each signal header line carries its time. + ## Steps | Step | Action | Labels | Pass criteria | |------|--------|-------:|---------------| -| 1 | `sudo docker exec ptouch ptouch-app status -d ptouch` with the printer on | 0 | Status shows no `offline`; `media-ready` reports 12 mm. The printer neither feeds nor cuts (status reads send no reset). | -| 2 | `enum 11 1`; record the submit-to-`completed` time from `ptouch-app jobs -d ptouch` | 1 | One intact 76-dot outline at 1:1, fresh leader removed, final cut. Job `completed`, not aborted. | -| 3 | Run `enum 11 1; enum 11 1` back to back | 2 | Two separate cut labels. `connected.log` shows one continuous `Connected: yes` span covering both jobs, then `no` once the queue is idle. | -| 4 | `chain 2`; wait for `Connected: no`, then 10 more seconds; `enum 60 2` | 4 | First job: internal cut, last end held. Second job: the held end is released without a new discarded leader, internal cut, final cut. If the held end was lost or a new leader appears, **stop**: that is a contract decision, not a retry. | -| 5 | Switch the printer off. `time sudo docker exec ptouch ptouch-app status -d ptouch` | 0 | Reports `offline`; the command returns within 11 seconds. Record the time. | +| 1 | With the printer on, `state` | 0 | A fresh connection answers status: `printer-state-reasons` is `none` and `media-ready` is a 12 mm size. The printer neither feeds nor cuts (status reads send no reset). | +| 2 | `enum 11 1`, then `times` | 1 | One intact 76-dot outline at 1:1, fresh leader removed, final cut. The job is `completed`, not aborted; record `time-at-completed` minus `time-at-processing`. The job only completes after the printer reports receiving-ready, which the server waits up to 20 s for, so this duration bounds the receiving-ready time. | +| 3 | Switch the printer off. Run `enum 11 1` twice; both jobs wait. Switch the printer on | 2 | Both queued jobs print as two separate cut labels. `connected.log` shows exactly one connect before the first label and one disconnect after the second, with none between them. | +| 4 | `chain 2`; wait until `connected.log` shows the disconnect, then 10 more seconds; `enum 60 2` | 4 | First job: internal cut, last end held. Second job, on a new connection: the held end is released without a new discarded leader, internal cut, final cut. If the held end was lost or a new leader appears, **stop**: that is a contract decision, not a retry. | +| 5 | Switch the printer off. `time state` | 0 | Reports `offline` and returns within 11 seconds. Record the time. | | 6 | With the printer still off, `enum 11 1`; wait 30 s; switch the printer on | 1 | The job waits (not aborted) while off and prints once the printer is on. Intact outline, final cut. | -| 7 | Recreate the container with `-e PTOUCH_IDLE_POLL_SECONDS=0`; run `ptouch-app status -d ptouch` five times over one minute | 0 | `connected.log` stays `Connected: no` for that minute; `docker logs` shows no new `not reachable` or status lines. | +| 7 | Recreate the container with `-e PTOUCH_IDLE_POLL_SECONDS=0`; with the printer on and idle, run `state` five times over one minute | 0 | `connected.log` records no connect during that minute. | | 8 | `sudo reboot` the Pi; after boot, without pairing again, `enum 11 1` | 1 | The container is running after boot and the label prints. | | | **Total** | **9** | | diff --git a/tests/bluetooth-gate/get-job-times.ipp b/tests/bluetooth-gate/get-job-times.ipp new file mode 100644 index 0000000..4b7f94c --- /dev/null +++ b/tests/bluetooth-gate/get-job-times.ipp @@ -0,0 +1,13 @@ +# Completed jobs with their timing: ipptool -tv URI get-job-times.ipp +{ + NAME "Get-Jobs completed with times" + OPERATION Get-Jobs + GROUP operation-attributes-tag + ATTR charset attributes-charset utf-8 + ATTR naturalLanguage attributes-natural-language en + ATTR uri printer-uri $uri + ATTR name requesting-user-name bluetooth-gate + ATTR keyword which-jobs completed + ATTR keyword requested-attributes job-id,job-state,job-state-message,time-at-processing,time-at-completed + STATUS successful-ok +} diff --git a/tests/bluetooth-gate/get-printer-state.ipp b/tests/bluetooth-gate/get-printer-state.ipp new file mode 100644 index 0000000..c8ef064 --- /dev/null +++ b/tests/bluetooth-gate/get-printer-state.ipp @@ -0,0 +1,12 @@ +# Loaded tape and printer state: ipptool -tv URI get-printer-state.ipp +{ + NAME "Get-Printer-Attributes state and media-ready" + OPERATION Get-Printer-Attributes + GROUP operation-attributes-tag + ATTR charset attributes-charset utf-8 + ATTR naturalLanguage attributes-natural-language en + ATTR uri printer-uri $uri + ATTR name requesting-user-name bluetooth-gate + ATTR keyword requested-attributes printer-state,printer-state-reasons,media-ready + STATUS successful-ok +} diff --git a/tests/test_device_bt.c b/tests/test_device_bt.c index de2c269..e5937a4 100644 --- a/tests/test_device_bt.c +++ b/tests/test_device_bt.c @@ -1,9 +1,62 @@ /* Production Bluetooth scheme I/O over a socketpair in place of RFCOMM. - * No radio, pairing, or BlueZ: only URI checks, the bounded connect's failure - * path, and the stream read/write conventions the driver depends on. */ + * No radio, pairing, or BlueZ: socket creation, connect, the connect wait, and + * send are intercepted so the deadline and short-send paths run deterministically. */ #include #include +#include +#include + +static int fake_socket(int domain, int type, int protocol); +static int fake_connect(int fd, const struct sockaddr *address, socklen_t length); +static int fake_poll(struct pollfd *fds, nfds_t n, int timeout); +static ssize_t fake_send(int fd, const void *buffer, size_t n, int flags); +#define socket fake_socket +#define connect fake_connect +#define poll fake_poll +#define send fake_send #include "../src/device_bt.c" +#undef socket +#undef connect +#undef poll +#undef send + +static int connect_wait_ms = -2, connect_ready, connect_sockets; +static uint8_t connected_address[6]; +static size_t send_limit; +static unsigned sends; + +/* An unconnected AF_UNIX socket stands in: SO_ERROR reads 0 and close works. */ +static int fake_socket(int domain, int type, int protocol) +{ + assert(domain == AF_BLUETOOTH && protocol == BTPROTO_RFCOMM && (type & SOCK_NONBLOCK)); + connect_sockets++; + return socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0); +} +static int fake_connect(int fd, const struct sockaddr *address, socklen_t length) +{ + (void)fd; + const struct sockaddr_rc *rc = (const struct sockaddr_rc *)address; + assert(length == sizeof *rc && rc->rc_family == AF_BLUETOOTH && rc->rc_channel == PT_BT_CHANNEL); + for (int i = 0; i < 6; i++) connected_address[i] = rc->rc_bdaddr.b[5 - i]; + errno = EINPROGRESS; + return -1; +} +/* The connect wait reports its deadline; data waits use the real poll. */ +static int fake_poll(struct pollfd *fds, nfds_t n, int timeout) +{ + if (n == 1 && fds[0].events == POLLOUT) { + connect_wait_ms = timeout; + fds[0].revents = connect_ready ? POLLOUT : 0; + return connect_ready; + } + return poll(fds, n, timeout); +} +static ssize_t fake_send(int fd, const void *buffer, size_t n, int flags) +{ + assert(flags & MSG_NOSIGNAL); + sends++; + return send(fd, buffer, send_limit && n > send_limit ? send_limit : n, flags); +} #define URI "ptouch-bt://Brother/PT-P710BT/A4:C1:38:AB:CD:EF" @@ -57,16 +110,6 @@ static ssize_t read_with(pappl_device_t *device, size_t piece, size_t wait, size return n; } -static size_t drained; -static void *drain(void *arg) -{ - (void)arg; - char buffer[4096]; - ssize_t n; - while ((n = read(peer, buffer, sizeof buffer)) > 0) drained += (size_t)n; - return NULL; -} - int main(void) { assert(pt_bt_is_uri(URI)); @@ -79,14 +122,21 @@ int main(void) pt_bt_format_uri("A4:C1:38:AB:CD:EF", uri, sizeof uri); assert(!strcmp(uri, URI)); - /* A malformed URI never reaches a socket; an unreachable address fails - * within the connect deadline (here no adapter exists at all). */ + /* A malformed URI never creates a socket. An unanswered connect waits the + * 10 s deadline, not the kernel's 30 s, then fails; a ready one opens. */ pt_bt_register(); assert(!papplDeviceOpen("ptouch-bt://Brother/PT-P710BT/zz", "bt-test", NULL, NULL)); - struct timespec start; - clock_gettime(CLOCK_MONOTONIC, &start); + assert(connect_sockets == 0); + const uint8_t expected[6] = {0xA4, 0xC1, 0x38, 0xAB, 0xCD, 0xEF}; assert(!papplDeviceOpen(URI, "bt-test", NULL, NULL)); - assert(ms_since(&start) < PT_BT_CONNECT_TIMEOUT_MS + 1000); + assert(connect_sockets == 1 && connect_wait_ms == PT_BT_CONNECT_TIMEOUT_MS); + assert(!memcmp(connected_address, expected, 6)); + connect_ready = 1; + pappl_device_t *connected = papplDeviceOpen(URI, "bt-test", NULL, NULL); assert(connected); + pt_bt_conn *conn = papplDeviceGetData(connected); + assert(!(fcntl(conn->fd, F_GETFL) & O_NONBLOCK)); + papplDeviceClose(connected); + struct timespec start; papplDeviceAddScheme("btpair", PAPPL_DEVTYPE_CUSTOM_LOCAL, NULL, pair_open, pt_bt_close, NULL, pt_bt_write, NULL, NULL); @@ -111,17 +161,19 @@ int main(void) assert(pt_bt_read_status(device, "socket://127.0.0.1:9100", got, sizeof got) == -1); assert(pt_bt_read_status(device, NULL, got, sizeof got) == -1); - /* Full write across short sends: a tiny send buffer forces partial writes. */ - int small = 1024; - pt_bt_conn *c = papplDeviceGetData(device); - assert(!setsockopt(c->fd, SOL_SOCKET, SO_SNDBUF, &small, sizeof small)); - static unsigned char block[256 * 1024]; - drained = 0; - pthread_t reader; assert(!pthread_create(&reader, NULL, drain, NULL)); + /* Short sends continue until every byte is written, in order. */ + unsigned char block[1000], received[sizeof block]; + for (unsigned i = 0; i < sizeof block; i++) block[i] = (unsigned char)(i * 7); + send_limit = 7; sends = 0; assert(pt_bt_write(device, block, sizeof block) == (ssize_t)sizeof block); - shutdown(c->fd, SHUT_WR); - pthread_join(reader, NULL); - assert(drained == sizeof block); + assert(sends == (sizeof block + 6) / 7); + send_limit = 0; + size_t used = 0; + while (used < sizeof received) { + ssize_t n = read(peer, received + used, sizeof received - used); assert(n > 0); + used += (size_t)n; + } + assert(!memcmp(block, received, sizeof block)); close(peer); papplDeviceClose(device); diff --git a/tests/test_device_usb.c b/tests/test_device_usb.c index 1336859..434041a 100644 --- a/tests/test_device_usb.c +++ b/tests/test_device_usb.c @@ -331,6 +331,9 @@ int main(void) setenv("PTOUCH_BT_ADDRESS", "A4:C1:38:AB:CD", 1); assert(pt_application_main(2, server) == EXIT_FAILURE); setenv("PTOUCH_BT_ADDRESS", "A4:C1:38:AB:CD:EF", 1); + setenv("PTOUCH_DEVICE_URI", "socket://127.0.0.1:9100", 1); + assert(pt_application_main(2, server) == EXIT_FAILURE); + unsetenv("PTOUCH_DEVICE_URI"); const char *bad_polls[] = { "-1", "1.5", "soon" }; for (size_t i = 0; i < sizeof bad_polls / sizeof bad_polls[0]; i++) { setenv("PTOUCH_IDLE_POLL_SECONDS", bad_polls[i], 1); diff --git a/tests/test_driver.c b/tests/test_driver.c index 29b55a5..a52fa3d 100644 --- a/tests/test_driver.c +++ b/tests/test_driver.c @@ -646,7 +646,7 @@ static size_t transport_batch(pappl_printer_t *printer, const char *finishing, p { pt_run_set(printer, PT_RUN_CLOSED); reset_wire(); ready_status(0, 7); statuses[18] = statuses[19] = 0; status_size = 32; - pappl_job_t *j = job(printer, finishing, 1, false); + pappl_job_t *j = job(printer, finishing, 1, !strcmp(finishing, "ptouch-chain")); pappl_pr_options_t *o = begin(j, d); assert(page_test(j, o, d)); ready_status(0, 7); assert(page_test(j, o, d)); @@ -677,14 +677,18 @@ static void bluetooth_cases(pappl_system_t *sys, pappl_printer_t *usb, pappl_dev can_open = false; assert(status_cb(bt)); can_open = true; assert(!pt_media_confirmed(bt) && (papplPrinterGetReasons(bt) & PAPPL_PREASON_OFFLINE)); - static unsigned char usb_wire[sizeof wire], bt_wire[sizeof wire]; + /* Each policy yields distinct bytes, identical on both transports. */ + static unsigned char usb_wire[sizeof wire], bt_wire[4][sizeof wire]; + size_t bt_sizes[4]; const char *finishings[] = {"none", "trim", "trim-after-job", "ptouch-chain"}; - for (unsigned i = 0; i < sizeof finishings / sizeof finishings[0]; i++) { + for (unsigned i = 0; i < 4; i++) { unsigned saved_usb = usb_enumerations, saved_reads = usb_reads; size_t usb_size = transport_batch(usb, finishings[i], d, usb_wire); usb_enumerations = saved_usb; usb_reads = saved_reads; - size_t bt_size = transport_batch(bt, finishings[i], d, bt_wire); - assert(usb_size == bt_size && !memcmp(usb_wire, bt_wire, bt_size)); + bt_sizes[i] = transport_batch(bt, finishings[i], d, bt_wire[i]); + assert(usb_size == bt_sizes[i] && !memcmp(usb_wire, bt_wire[i], bt_sizes[i])); + for (unsigned k = 0; k < i; k++) + assert(bt_sizes[k] != bt_sizes[i] || memcmp(bt_wire[k], bt_wire[i], bt_sizes[i])); } /* Link loss during raster: the job fails, cleanup sends nothing, and the @@ -727,7 +731,13 @@ static void bluetooth_cases(pappl_system_t *sys, pappl_printer_t *usb, pappl_dev state->idle_poll_seconds = 300; state->idle_polled = false; opened = opens; clean_snapshot(); assert(status_cb(bt)); assert(status_cb(bt)); assert(opens == opened + 1 && pt_media_confirmed(bt)); - state->last_idle_poll.tv_sec -= 300; + /* The interval is exact: 299.5 s is too early, 300 s is due. */ + clock_gettime(CLOCK_MONOTONIC, &state->last_idle_poll); + state->last_idle_poll.tv_sec -= 299; + state->last_idle_poll.tv_nsec -= 500000000L; + if (state->last_idle_poll.tv_nsec < 0) { state->last_idle_poll.tv_sec--; state->last_idle_poll.tv_nsec += 1000000000L; } + assert(status_cb(bt) && opens == opened + 1); + state->last_idle_poll.tv_sec -= 1; clean_snapshot(); assert(status_cb(bt) && opens == opened + 2); can_open = false; state->idle_polled = false; opened = opens; for (int i = 0; i < 3; i++) assert(status_cb(bt)); From fcb8c31bbe76a823bbee08a9e25a2bd9cf394ca5 Mon Sep 17 00:00:00 2001 From: Fedir Date: Wed, 14 Oct 2026 16:43:34 -0400 Subject: [PATCH 07/14] docs: note PTOUCH_BT_ADDRESS excludes PTOUCH_DEVICE_URI --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d3e9a04..c89efae 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ protocol still follow `PTOUCH_MODEL`, and the printer connects on an actual prin An explicit `ptouch://Brother/` override must exactly match the configured supported model; conflicting or unsupported custom USB URIs fail before commands. -`PTOUCH_BT_ADDRESS` selects Bluetooth instead of USB for `PTOUCH_MODEL=PT-P710BT`. Set it to the paired printer's address, `XX:XX:XX:XX:XX:XX`. The server connects over RFCOMM channel 1 when a job starts, keeps the connection while jobs are queued, and closes it when the queue goes idle. USB is never used and never a fallback. A printer that is off, out of range, or connected to another device shows as offline, and queued jobs wait until it is reachable. A connection attempt gives up after 10 seconds. An address with another model, or a malformed address, fails startup. Bluetooth needs host networking; see [docs/PI-DEPLOYMENT.md](docs/PI-DEPLOYMENT.md). +`PTOUCH_BT_ADDRESS` selects Bluetooth instead of USB for `PTOUCH_MODEL=PT-P710BT`. Set it to the paired printer's address, `XX:XX:XX:XX:XX:XX`. The server connects over RFCOMM channel 1 when a job starts, keeps the connection while jobs are queued, and closes it when the queue goes idle. USB is never used and never a fallback. A printer that is off, out of range, or connected to another device shows as offline, and queued jobs wait until it is reachable. A connection attempt gives up after 10 seconds. An address with another model, a malformed address, or an address combined with `PTOUCH_DEVICE_URI` fails startup. Bluetooth needs host networking; see [docs/PI-DEPLOYMENT.md](docs/PI-DEPLOYMENT.md). `PTOUCH_IDLE_POLL_SECONDS` controls printer status reads while the queue is idle, on either transport: From e2c6758daccfad5d9ead54eaeba43c28b2ef0712 Mon Sep 17 00:00:00 2001 From: Fedir Date: Wed, 14 Oct 2026 17:07:52 -0400 Subject: [PATCH 08/14] docs(openspec): record arm64 CI verification --- openspec/changes/add-bluetooth-transport/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/add-bluetooth-transport/tasks.md b/openspec/changes/add-bluetooth-transport/tasks.md index d675f44..1793df0 100644 --- a/openspec/changes/add-bluetooth-transport/tasks.md +++ b/openspec/changes/add-bluetooth-transport/tasks.md @@ -21,7 +21,7 @@ ## 4. Image and deployment -- [ ] 4.1 In `.github/workflows/deploy.yml`, set up QEMU, add a non-publishing `linux/arm64` build step beside the amd64 check build, and add `linux/arm64` to the publish step; verify locally with `docker buildx build --platform linux/arm64 -f Dockerfile.deploy .` that the arm64 build and its CTest pass, and record the duration. +- [x] 4.1 In `.github/workflows/deploy.yml`, set up QEMU, add a non-publishing `linux/arm64` build step beside the amd64 check build, and add `linux/arm64` to the publish step; verify on the pull request that the arm64 build passes all CTest suites under emulation, and record the duration. Done on PR #52, run 38634794603: 14/14 suites passed; the arm64 step took 14 min 50 s (amd64: 4 min 7 s). - [x] 4.2 Write the Pi deployment guide in `docs/` (64-bit Raspberry Pi OS Lite, Docker, `bluetoothctl` pair and trust, disabling host `avahi-daemon`, `--network host` with no USB mount, the three variables, recommended polling `0`, about 10 m range, mid-job link loss recovery); review each command against the design and the container's entrypoint. ## 5. Physical acceptance From 9971bdc30259707ee18fd365ef17a4d6cdafc84a Mon Sep 17 00:00:00 2001 From: pfa230 Date: Thu, 15 Oct 2026 18:01:01 -0400 Subject: [PATCH 09/14] feat(deploy): build an unattended Pi Zero 2 W SD card for the Bluetooth server deploy/pi/build-card.py writes Raspberry Pi OS Lite (Trixie, arm64) to an SD card on macOS, grows root to 6 GiB and adds an ext4 data partition. cloud-init (deploy/pi/user-data) provisions it on first boot: Docker with the bundled arm64 server image, ptouch.service, persistent journal, and ptouch-update / ptouch-pair / ptouch-logs. It then reboots into a read-only overlay root (overlayroot recurse=0, so /data stays real ext4). First boot keeps password SSH as a fallback; the guide disables it after a key login works. The image ships Bluetooth soft-blocked, so provisioning unblocks it and its saved rfkill state. Refs #56 --- deploy/pi/build-card.py | 102 +++++++++++ deploy/pi/user-data | 167 ++++++++++++++++++ docs/PI-DEPLOYMENT.md | 34 ++++ .../changes/add-bluetooth-transport/tasks.md | 1 + 4 files changed, 304 insertions(+) create mode 100755 deploy/pi/build-card.py create mode 100644 deploy/pi/user-data diff --git a/deploy/pi/build-card.py b/deploy/pi/build-card.py new file mode 100755 index 0000000..4d543a3 --- /dev/null +++ b/deploy/pi/build-card.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Write the ptouch Bluetooth print server SD card on macOS. + +Usage: sudo python3 build-card.py + +wifi.env holds WIFI_SSID=, WIFI_PSK=, WIFI_COUNTRY=, LOGIN_PASSWORD= and optionally +BT_ADDRESS=. Only the password's SHA-512 crypt hash goes onto the card. +The card gets the stock image with partition 2 (root) grown to 6 GiB and a data +partition 3 filling the rest; cloud-init provisions everything on first boot. +""" +import json +import pathlib +import plistlib +import shutil +import struct +import subprocess +import sys +import tempfile + +HERE = pathlib.Path(__file__).resolve().parent +ROOT_SECTORS = 6 * 1024 * 1024 * 2 # 6 GiB in 512-byte sectors +ALIGN = 8192 + + +def diskutil(*args): + return subprocess.run(["diskutil", *args], check=True, capture_output=True).stdout + + +def card_sectors(disk): + info = plistlib.loads(diskutil("info", "-plist", disk)) + if info.get("BusProtocol") != "Secure Digital" or not info.get("Removable", info.get("RemovableMedia")): + sys.exit(f"{disk} is not a removable SD card: {info.get('MediaName')} ({info.get('BusProtocol')})") + return info["TotalSize"] // 512 + + +def read_env(path): + env = {} + for line in pathlib.Path(path).read_text().splitlines(): + if line.strip() and not line.lstrip().startswith("#"): + key, _, value = line.partition("=") + env[key.strip()] = value.strip().strip('"\'') + for key in ("WIFI_SSID", "WIFI_PSK", "WIFI_COUNTRY", "LOGIN_PASSWORD"): + if not env.get(key): + sys.exit(f"{path}: {key} is missing") + return env + + +def write_partition_table(device, total): + with open(device, "r+b", buffering=0) as f: + mbr = bytearray(f.read(512)) + p2_start = struct.unpack_from("/dev/null || mkfs.ext4 -q -L ptdata "${disk}p3" + mkdir -p /data + mount LABEL=ptdata /data + mkdir -p /data/docker /data/bluetooth /data/journal /data/ptouch + cp -a /var/lib/bluetooth/. /data/bluetooth/ + cat >> /etc/fstab <<'EOF' + LABEL=ptdata /data ext4 defaults,noatime 0 2 + /data/bluetooth /var/lib/bluetooth none bind,x-systemd.requires-mounts-for=/data 0 0 + /data/journal /var/log/journal none bind,x-systemd.requires-mounts-for=/data 0 0 + EOF + systemctl daemon-reload + mount /var/lib/bluetooth + mount /var/log/journal + fi + +write_files: + - path: /etc/docker/daemon.json + content: | + {"data-root": "/data/docker"} + - path: /etc/systemd/journald.conf.d/ptouch.conf + content: | + [Journal] + Storage=persistent + SyncIntervalSec=10s + SystemMaxUse=2G + - path: /etc/rpi/swap.conf.d/ptouch.conf + content: | + [Main] + Mechanism=zram + - path: /etc/systemd/system/ptouch.service + content: | + [Unit] + Description=ptouch print server (PT-P710BT over Bluetooth) + Requires=docker.service + Wants=bluetooth.service network-online.target + After=docker.service bluetooth.service network-online.target + RequiresMountsFor=/data + + [Service] + EnvironmentFile=/data/ptouch/ptouch.env + ExecCondition=/bin/sh -c 'test -n "$PTOUCH_BT_ADDRESS" || { echo "PTOUCH_BT_ADDRESS is empty: run sudo ptouch-pair"; exit 1; }' + ExecStartPre=-/usr/bin/docker rm -f ptouch + ExecStart=/usr/bin/docker run --rm --name ptouch --network host \ + --ulimit nofile=65536:65536 --env-file /data/ptouch/ptouch.env \ + --log-driver journald --log-opt tag=ptouch ${PTOUCH_IMAGE} + ExecStop=/usr/bin/docker stop -t 20 ptouch + Restart=always + RestartSec=10 + + [Install] + WantedBy=multi-user.target + - path: /usr/local/bin/ptouch-update + permissions: "0755" + content: | + #!/bin/sh + # Usage: sudo ptouch-update [tag] (default edge; sha- pins or rolls back) + set -eu + image=ghcr.io/pfa230/ptouch-print-server:${1:-edge} + docker pull "$image" + sed -i "s|^PTOUCH_IMAGE=.*|PTOUCH_IMAGE=$image|" /data/ptouch/ptouch.env + systemctl restart ptouch + docker image prune -f >/dev/null + echo "ptouch now runs $image" + - path: /usr/local/bin/ptouch-pair + permissions: "0755" + content: | + #!/bin/sh + # Usage: sudo ptouch-pair [XX:XX:XX:XX:XX:XX] + # Without an address, scans for a switched-on PT-P710BT. + set -eu + addr=${1:-} + if [ -z "$addr" ]; then + echo "Scanning 20 s for PT-P710BT..." + bluetoothctl --timeout 20 scan on >/dev/null + addr=$(bluetoothctl devices | awk '/PT-P710BT/ {print $2; exit}') + [ -n "$addr" ] || { echo "No PT-P710BT found; is it on and in range?" >&2; exit 1; } + fi + if ! bluetoothctl info "$addr" | grep -q 'Paired: yes'; then + bluetoothctl --agent NoInputNoOutput pair "$addr" || + { echo "Pairing failed; pair interactively with bluetoothctl" >&2; exit 1; } + fi + bluetoothctl trust "$addr" + sed -i "s|^PTOUCH_BT_ADDRESS=.*|PTOUCH_BT_ADDRESS=$addr|" /data/ptouch/ptouch.env + systemctl restart ptouch + echo "ptouch now prints to $addr" + - path: /usr/local/bin/ptouch-logs + permissions: "0755" + content: | + #!/bin/sh + # Usage: ptouch-logs [jobs] [journalctl options, e.g. -f, -b -1, --since today] + if [ "${1:-}" = jobs ]; then + shift + exec journalctl -t ptouch --grep '\[Job [0-9]+\]' "$@" + fi + exec journalctl -t ptouch "$@" + - path: /usr/local/sbin/ptouch-provision + permissions: "0755" + content: | + #!/bin/sh + # Runs once, after packages are installed; reboots into read-only mode on success. + set -eux + mountpoint -q /data + docker load -i /boot/firmware/ptouch/ptouch-image.tar.gz + if [ ! -f /data/ptouch/ptouch.env ]; then + cat > /data/ptouch/ptouch.env <<'EOF' + PTOUCH_IMAGE=ghcr.io/pfa230/ptouch-print-server:edge + PTOUCH_MODEL=PT-P710BT + PTOUCH_BT_ADDRESS=@BT_ADDRESS@ + PTOUCH_IDLE_POLL_SECONDS=0 + EOF + fi + # The container runs its own Avahi on the host network. + systemctl disable avahi-daemon.socket avahi-daemon.service + # Daily apt runs would only fill the RAM overlay. + systemctl disable apt-daily.timer apt-daily-upgrade.timer man-db.timer + systemctl enable ptouch.service + # It fails remounting the overlay root on every boot; nothing else needs it. + systemctl mask systemd-remount-fs.service + # The image ships Bluetooth soft-blocked; unblock it and its saved rfkill state. + for r in /sys/class/rfkill/rfkill*; do + [ "$(cat "$r/type")" != bluetooth ] || echo 0 > "$r/soft" + done + for f in /var/lib/systemd/rfkill/*:bluetooth; do echo 0 > "$f"; done + raspi-config nonint do_overlayfs 0 + # Overlay only /; by default overlayroot also puts /data under the RAM overlay. + mount -o remount,rw /boot/firmware + sed -i 's/overlayroot=tmpfs /overlayroot=tmpfs:recurse=0 /' /boot/firmware/cmdline.txt + grep -q 'overlayroot=tmpfs:recurse=0 ' /boot/firmware/cmdline.txt + touch /etc/cloud/cloud-init.disabled + touch /run/ptouch-provisioned + +package_update: true +packages: + - docker.io + - overlayroot + +runcmd: + - [/usr/local/sbin/ptouch-provision] + +power_state: + mode: reboot + condition: test -f /run/ptouch-provisioned diff --git a/docs/PI-DEPLOYMENT.md b/docs/PI-DEPLOYMENT.md index 9d81aa5..2ad2f03 100644 --- a/docs/PI-DEPLOYMENT.md +++ b/docs/PI-DEPLOYMENT.md @@ -7,6 +7,40 @@ This guide runs the server on a wall-powered Raspberry Pi Zero 2 W and reaches a - Raspberry Pi Zero 2 W running **64-bit** Raspberry Pi OS Lite (Bookworm). The published image has `linux/arm64` and `linux/amd64` variants; a 32-bit OS cannot run it. - The PT-P710BT, charged, within about 10 m of the Pi. Walls shorten the range. +## Prebuilt SD card + +`deploy/pi/` builds a card that does everything below unattended and survives power loss. Use it instead of the manual steps. + +What the card does: + +- **Power loss:** `overlayroot` keeps `/` in RAM over a read-only root, and `/boot/firmware` is read-only. Everything that must persist lives on the ext4 partition labelled `ptdata`, mounted at `/data`: Docker (`/data/docker`), Bluetooth pairing (`/var/lib/bluetooth`), the journal (`/var/log/journal`) and the server configuration (`/data/ptouch/ptouch.env`). Pairing, updates and configuration changes therefore never need the overlay disabled. +- **Service:** `ptouch.service` runs the container as described below, reading `/data/ptouch/ptouch.env`. It skips itself until `PTOUCH_BT_ADDRESS` is set. +- **Logs:** the container logs to the persistent journal, synced every 10 seconds. `ptouch-logs` shows the server log and `ptouch-logs jobs` only its `[Job N]` lines; both accept `journalctl` options such as `-f` or `-b -1`. +- **Updates:** `sudo ptouch-update` pulls `:edge` and restarts; `sudo ptouch-update sha-` pins or rolls back. + +Build it on macOS with Homebrew OpenSSL and Docker: + +1. Download and decompress the 64-bit Raspberry Pi OS Lite (Trixie) image. +2. Build the arm64 server image and save it: `docker build --platform linux/arm64 -f Dockerfile.deploy -t ghcr.io/pfa230/ptouch-print-server:edge . && docker save ghcr.io/pfa230/ptouch-print-server:edge | gzip -1 > ptouch-image.tar.gz`. First boot loads it, so the card needs no registry pull. +3. Write a settings file with `WIFI_SSID=`, `WIFI_PSK=`, `WIFI_COUNTRY=`, `LOGIN_PASSWORD=` and optionally `BT_ADDRESS=`, and save the public key that should log in as `pfa`, for example `ssh-add -L | grep > login-key.txt`. +4. Insert the card, find it with `diskutil list`, and run `sudo deploy/pi/build-card.py diskN raspios.img ptouch-image.tar.gz settings.env login-key.txt`. It refuses any disk that is not a removable SD card, then erases it. + +First boot takes about 15 minutes and reboots once into read-only mode. Then log in as `pfa`, pair with the printer switched on, and print: + +```sh +sudo ptouch-pair # scans for PT-P710BT; or: sudo ptouch-pair XX:XX:XX:XX:XX:XX +``` + +The card accepts the password over SSH until you turn it off. Do that only after a key login works, and write it to the read-only root: + +```sh +sudo mount -o remount,rw /media/root-ro +echo 'PasswordAuthentication no' | sudo tee /media/root-ro/etc/ssh/sshd_config.d/50-cloud-init.conf +sudo mount -o remount,ro /media/root-ro && sudo reboot +``` + +Any other permanent change to the OS goes through `/media/root-ro` the same way, or `sudo raspi-config nonint do_overlayfs 1` followed by a reboot to make the root writable again. + ## 1. Install Docker ```sh diff --git a/openspec/changes/add-bluetooth-transport/tasks.md b/openspec/changes/add-bluetooth-transport/tasks.md index 1793df0..26881ce 100644 --- a/openspec/changes/add-bluetooth-transport/tasks.md +++ b/openspec/changes/add-bluetooth-transport/tasks.md @@ -23,6 +23,7 @@ - [x] 4.1 In `.github/workflows/deploy.yml`, set up QEMU, add a non-publishing `linux/arm64` build step beside the amd64 check build, and add `linux/arm64` to the publish step; verify on the pull request that the arm64 build passes all CTest suites under emulation, and record the duration. Done on PR #52, run 38634794603: 14/14 suites passed; the arm64 step took 14 min 50 s (amd64: 4 min 7 s). - [x] 4.2 Write the Pi deployment guide in `docs/` (64-bit Raspberry Pi OS Lite, Docker, `bluetoothctl` pair and trust, disabling host `avahi-daemon`, `--network host` with no USB mount, the three variables, recommended polling `0`, about 10 m range, mid-job link loss recovery); review each command against the design and the container's entrypoint. +- [x] 4.3 Add `deploy/pi/` to build an unattended, power-loss-tolerant SD card (read-only overlay root, persistent `/data` for Docker, pairing, journal and configuration; `ptouch-update`, `ptouch-pair`, `ptouch-logs`), and document it in the Pi guide; verify on hardware. Issue #56. Verified on a Pi Zero 2 W: first boot, `/data` persistence and `/` reset across reboot, pairing, Bluetooth and service up after reboot, one 12 mm label printed over Bluetooth. ## 5. Physical acceptance From 11b56be424682fed3be00a75222b52c550c2396e Mon Sep 17 00:00:00 2001 From: Fedir Date: Fri, 16 Oct 2026 00:30:18 -0400 Subject: [PATCH 10/14] test(printer): wait for both queued jobs before asserting PAPPL 1.4.11 starts the newest pending job first (compare_active_jobs sorts by descending job id, and _papplPrinterCheckJobsNoLock starts the first pending entry). The queued-trim case waited only for the follower, so it passed on amd64 only when the first job finished inside the 10 ms poll gap. On a Raspberry Pi Zero 2 W, and under QEMU arm64, the first job was still processing and the assertion failed every run, which also fails the Dockerfile.deploy build on arm64. Wait until both jobs reach a terminal state. Verified 3/3 on the Pi and 12/12 suites on amd64. --- tests/test_driver.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_driver.c b/tests/test_driver.c index 3869a9d..959a09b 100644 --- a/tests/test_driver.c +++ b/tests/test_driver.c @@ -1273,7 +1273,9 @@ int main(int argc, char **argv) reset_wire(); pthread_t queued_sink; assert(!pthread_create(&queued_sink, NULL, socket_sink, &sink_listener)); papplPrinterResume(capture); - for (int i = 0; i < 1000 && papplJobGetState(follower) < IPP_JSTATE_CANCELED; i++) { + /* PAPPL starts the newest pending job first, so either may finish last. */ + for (int i = 0; i < 1000 && (papplJobGetState(first) < IPP_JSTATE_CANCELED || + papplJobGetState(follower) < IPP_JSTATE_CANCELED); i++) { struct timespec delay = {0, 10000000L}; nanosleep(&delay, NULL); } assert(papplJobGetState(first) == IPP_JSTATE_COMPLETED); From 71c71bce30c65dbd009256720cd129688911df63 Mon Sep 17 00:00:00 2001 From: Fedir Date: Fri, 16 Oct 2026 00:36:11 -0400 Subject: [PATCH 11/14] docs: record the passed Bluetooth gate and its pairing fixes The gate passed on a Raspberry Pi Zero 2 W: all 8 steps, 9 labels on 12 mm tape, including the held chain end across an idle reconnect. Pairing without 'pairable on' pairs but does not bond, so every connection re-paired and the first status read took 9.5 s against the 10 s connect deadline; bonded, a read takes about 2.6 s. dbus-monitor needs sudo to record BlueZ signals. README now states the certified scope: 12 mm over Bluetooth, 9/18 mm software-only. --- README.md | 2 +- docs/PI-DEPLOYMENT.md | 9 ++++-- .../changes/add-bluetooth-transport/tasks.md | 4 +-- tests/BLUETOOTH-ACCEPTANCE.md | 28 +++++++++++++++++-- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5f42342..ac68f2c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The Brother raster protocol and device table are reused from explicitly below. Its 6/24 mm coverage is software-only. Other recognized models do not receive print output. -PT-P710BT can also be reached over Bluetooth instead of USB (`PTOUCH_BT_ADDRESS`), for example from a Raspberry Pi while the printer runs on battery. That path is software-tested only; physical acceptance is pending. See [docs/PI-DEPLOYMENT.md](docs/PI-DEPLOYMENT.md). +PT-P710BT can also be reached over Bluetooth instead of USB (`PTOUCH_BT_ADDRESS`), for example from a Raspberry Pi while the printer runs on battery. That path is hardware-certified on 12 mm tape from a Raspberry Pi Zero 2 W ([Bluetooth gate](tests/BLUETOOTH-ACCEPTANCE.md)); 9 and 18 mm over Bluetooth are software-only, sending the same bytes as the certified USB path. See [docs/PI-DEPLOYMENT.md](docs/PI-DEPLOYMENT.md). - Prints 1:1 within the tape's imageable width, honoring submitted page geometry; PNG length follows its image when a concrete media length is omitted. diff --git a/docs/PI-DEPLOYMENT.md b/docs/PI-DEPLOYMENT.md index 9d81aa5..40e27aa 100644 --- a/docs/PI-DEPLOYMENT.md +++ b/docs/PI-DEPLOYMENT.md @@ -1,10 +1,10 @@ # Raspberry Pi Bluetooth deployment -This guide runs the server on a wall-powered Raspberry Pi Zero 2 W and reaches a battery-powered PT-P710BT over Bluetooth, so the printer can move around the house. The Bluetooth path is software-tested; its physical acceptance is pending (see [README](../README.md#capabilities)). +This guide runs the server on a wall-powered Raspberry Pi Zero 2 W and reaches a battery-powered PT-P710BT over Bluetooth, so the printer can move around the house. The Bluetooth path passed its [hardware gate](../tests/BLUETOOTH-ACCEPTANCE.md) on a Pi Zero 2 W with 12 mm tape. ## What you need -- Raspberry Pi Zero 2 W running **64-bit** Raspberry Pi OS Lite (Bookworm). The published image has `linux/arm64` and `linux/amd64` variants; a 32-bit OS cannot run it. +- Raspberry Pi Zero 2 W running **64-bit** Raspberry Pi OS Lite (Bookworm or Trixie). The published image has `linux/arm64` and `linux/amd64` variants; a 32-bit OS cannot run it. - The PT-P710BT, charged, within about 10 m of the Pi. Walls shorten the range. ## 1. Install Docker @@ -21,14 +21,17 @@ Switch the printer on, then on the Pi: ```sh bluetoothctl power on +pairable on scan on # wait for PT-P710BT and note its address pair XX:XX:XX:XX:XX:XX trust XX:XX:XX:XX:XX:XX scan off +pairable off +info XX:XX:XX:XX:XX:XX # must show Bonded: yes quit ``` -If pairing asks for a PIN, try `0000`. BlueZ stores the pairing under `/var/lib/bluetooth` on the SD card, so it survives reboots. The container needs no BlueZ of its own. +Pairing needs no PIN. Without `pairable on` the Pi pairs but does not bond, so every connection pairs again and takes up to 10 seconds instead of about 2. BlueZ stores the pairing under `/var/lib/bluetooth` on the SD card, so it survives reboots. The container needs no BlueZ of its own. ## 3. Hand mDNS to the container diff --git a/openspec/changes/add-bluetooth-transport/tasks.md b/openspec/changes/add-bluetooth-transport/tasks.md index 1793df0..0fb685f 100644 --- a/openspec/changes/add-bluetooth-transport/tasks.md +++ b/openspec/changes/add-bluetooth-transport/tasks.md @@ -27,8 +27,8 @@ ## 5. Physical acceptance - [x] 5.1 Prepare a concrete bounded Bluetooth gate in `tests/` before any output: pairing check; a reset-free status reply on a fresh connection; one 12 mm default-trim label with receiving-ready time recorded; two jobs queued together sharing one connection, then closed when idle; a chain-only job followed after the queue went idle by a chain-and-final job; the measured connect failure time with the printer off; a job queued while the printer is off that prints after power-on; polling `0` showing no idle connects in the log; a USB-attached unit receiving nothing; and a Pi reboot followed by a label without re-pairing. State the label budget and stop conditions, and present the gate for approval. -- [ ] 5.2 After approval, run the gate on the Pi with the P710BT on battery; record captures, job logs and operator observations, stopping on the first failed criterion. If the held chain end is lost across the idle reconnect, stop and report it as a contract decision rather than adjusting behavior. -- [ ] 5.3 Update README capabilities and the acceptance record with the certified Bluetooth scope and anything left software-only. +- [x] 5.2 After approval, run the gate on the Pi with the P710BT on battery; record captures, job logs and operator observations, stopping on the first failed criterion. If the held chain end is lost across the idle reconnect, stop and report it as a contract decision rather than adjusting behavior. Passed 2026-10-16, all 8 steps and 9 labels on a Pi Zero 2 W; the held chain end survived the idle reconnect. Results and deviations are in `tests/BLUETOOTH-ACCEPTANCE.md`. +- [x] 5.3 Update README capabilities and the acceptance record with the certified Bluetooth scope and anything left software-only. ## 6. Final reconciliation diff --git a/tests/BLUETOOTH-ACCEPTANCE.md b/tests/BLUETOOTH-ACCEPTANCE.md index afa9bd6..e780b2f 100644 --- a/tests/BLUETOOTH-ACCEPTANCE.md +++ b/tests/BLUETOOTH-ACCEPTANCE.md @@ -1,6 +1,6 @@ # PT-P710BT Bluetooth acceptance gate -Status: **prepared, not run; awaiting approval.** Preparation used only socket-sink containers and printed nothing. The software evidence (fake-socket driver and adapter tests, byte identity with USB, both image smokes) does not replace these physical observations. +Status: **passed 2026-10-16**, all 8 steps, 9 labels on 12 mm tape. See [Results](#results). ## Bound @@ -26,7 +26,7 @@ times() { ipptool -tv "$U" $G/get-job-times.ipp | grep -E 'job-id|job-state|time On the Pi, in a second terminal, record every connect and disconnect for the whole gate. BlueZ signals each change of the device's `Connected` property, so no event is missed between samples: ```sh -dbus-monitor --system "type='signal',path='/org/bluez/hci0/dev_${ADDR//:/_}'" | tee connected.log +sudo dbus-monitor --system "type='signal',path='/org/bluez/hci0/dev_${ADDR//:/_}'" | tee connected.log ``` `grep -A1 '"Connected"' connected.log` lists the changes in order; each signal header line carries its time. @@ -50,3 +50,27 @@ The "USB unit receives nothing" requirement is covered by software only: the Blu ## Record Keep `connected.log`, `sudo docker logs ptouch` (before step 7's recreate and at the end), every `ipptool` output, the step 2 and step 5 timings, and photos or notes of each strip. Then update README capabilities with the certified Bluetooth scope. + +## Results + +Run 2026-10-16 04:01 to 04:17 UTC on a Raspberry Pi Zero 2 W (Debian 13, kernel 6.18, BlueZ 5.82, arm64) with a battery-powered PT-P710BT and 12 mm tape. Steps 1 to 7 ran the `ptouch-app` binary built from `e2c6758` with host networking; step 8 ran the Pi's systemd-managed `ghcr.io/pfa230/ptouch-print-server:edge` container, whose `ptouch-app` has the same sha256 (`729f7f22…`). Times are UTC. + +| Step | Result | Evidence | +|------|--------|----------| +| 1 | Pass | `none`, `om_12x100mm_12x100mm` from a live read; the connection lasted 2.6 s (04:05:00.9 to 04:05:03.5). No feed or cut reported. | +| 2 | Pass | Job 1 completed; `time-at-completed` minus `time-at-processing` = 11 s. Operator: intact outline, leader removed, final cut. | +| 3 | Pass | Jobs 2 and 3 waited (connect attempts failed at 04:06:48 and 04:06:58), then printed in order on one connection: connect 04:07:07.3, disconnect 04:07:28.3, none between. Operator: two separate cut labels. | +| 4 | Pass | Job 4 (`ptouch-chain`, 2 copies) connected 04:08:06 to 04:08:21; job 5 (`enum 60`, 2 copies) sent 04:08:39 on a new connection, 04:08:43 to 04:09:01. Operator: one leader (job 4's fresh run), then four cut labels; no new leader for job 5. | +| 5 | Pass | `offline` in 5.4 s. The radio page timeout (about 5.1 s) bounds it, not the 10 s connect deadline. | +| 6 | Pass | Job 6 queued 04:11:09 while off, retried about every 10 s without aborting, completed 04:12:16.9 after power-on. Operator: intact outline, final cut. | +| 7 | Pass | With `PTOUCH_IDLE_POLL_SECONDS=0`, five status queries 04:12:55 to 04:13:52 recorded no connect. | +| 8 | Pass | After `systemctl reboot` the service was up by 04:16:34 with the bond intact (`Bonded: yes`); job 1 completed 04:16:50.0 to 04:17:00.6 without pairing again. Operator: intact outline, final cut. | + +Deviations, none of which changed a printed step: + +- `dbus-monitor` as an ordinary user is denied monitoring and records nothing; the command above now uses `sudo`. +- The first step 1 queries ran against a container started with idle polling `0`, so they read no status. They printed nothing and are not counted. +- The Pi first paired without bonding (`Pairable: no`), so each connection paired again and step 1's first live read took 9.5 s to connect. The Pi was then bonded as [docs/PI-DEPLOYMENT.md](../docs/PI-DEPLOYMENT.md) now describes, and step 1 was repeated. +- `connected.log` lived on the Pi's tmpfs overlay and was lost at the step 8 reboot; the timings above were read from it before then. + +Software-only: 9 and 18 mm tape over Bluetooth (the driver sends the same bytes as USB, which is certified on 9/12/18 mm), and a USB-attached unit receiving nothing. From 231c7d0d677b5804525c667e0a2c84b4ac78b88b Mon Sep 17 00:00:00 2001 From: Fedir Date: Fri, 16 Oct 2026 01:21:38 -0400 Subject: [PATCH 12/14] fix(deploy): bond the printer in ptouch-pair The Pi adapter is not pairable by default, so 'bluetoothctl pair' paired without bonding and BlueZ dropped the pairing at disconnect. Every connection then paired again, which put the first status read at 9.5 s against the 10 s connect deadline. Turn pairable on around the pair, require 'Bonded: yes', and scan first when given the address of a device BlueZ does not know (pairing an unknown address fails). Verified on the Pi Zero 2 W: after 'bluetoothctl remove', the script given the address scanned, paired and left the device Bonded: yes with the adapter back at Pairable: no. --- deploy/pi/user-data | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/deploy/pi/user-data b/deploy/pi/user-data index b9c7471..117fb90 100644 --- a/deploy/pi/user-data +++ b/deploy/pi/user-data @@ -100,9 +100,19 @@ write_files: addr=$(bluetoothctl devices | awk '/PT-P710BT/ {print $2; exit}') [ -n "$addr" ] || { echo "No PT-P710BT found; is it on and in range?" >&2; exit 1; } fi - if ! bluetoothctl info "$addr" | grep -q 'Paired: yes'; then + if ! bluetoothctl info "$addr" | grep -q 'Bonded: yes'; then + # BlueZ pairs only a device it has seen in a scan. + if ! bluetoothctl info "$addr" >/dev/null 2>&1; then + echo "Scanning 20 s for $addr..." + bluetoothctl --timeout 20 scan on >/dev/null + fi + # Without pairable the Pi pairs but does not bond, and re-pairs on every connection. + bluetoothctl pairable on >/dev/null bluetoothctl --agent NoInputNoOutput pair "$addr" || - { echo "Pairing failed; pair interactively with bluetoothctl" >&2; exit 1; } + { bluetoothctl pairable off >/dev/null; echo "Pairing failed; pair interactively with bluetoothctl" >&2; exit 1; } + bluetoothctl pairable off >/dev/null + bluetoothctl info "$addr" | grep -q 'Bonded: yes' || + { echo "Paired but not bonded; pair interactively with bluetoothctl" >&2; exit 1; } fi bluetoothctl trust "$addr" sed -i "s|^PTOUCH_BT_ADDRESS=.*|PTOUCH_BT_ADDRESS=$addr|" /data/ptouch/ptouch.env From 5c94198f74e9970217cf5191486d0cbf7959a060 Mon Sep 17 00:00:00 2001 From: Fedir Date: Fri, 16 Oct 2026 01:25:23 -0400 Subject: [PATCH 13/14] docs: stop the Pi service on rollback; scope the power-loss claim On a prebuilt card ptouch.service has Restart=always, so removing the container only lets systemd recreate it; rollback there is 'systemctl stop ptouch'. The card is built to tolerate power loss, but only reboots were tested. --- docs/PI-DEPLOYMENT.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/PI-DEPLOYMENT.md b/docs/PI-DEPLOYMENT.md index 6a096cd..b21239f 100644 --- a/docs/PI-DEPLOYMENT.md +++ b/docs/PI-DEPLOYMENT.md @@ -9,7 +9,7 @@ This guide runs the server on a wall-powered Raspberry Pi Zero 2 W and reaches a ## Prebuilt SD card -`deploy/pi/` builds a card that does everything below unattended and survives power loss. Use it instead of the manual steps. +`deploy/pi/` builds a card that does everything below unattended, with a read-only root, built to tolerate power loss. Reboots are tested; pulling power is not. Use it instead of the manual steps. What the card does: @@ -109,4 +109,4 @@ The queue is reachable at `ipp://.local:8000/ipp/print/ptouch`. ## Roll back -Stop the Pi container (`sudo docker rm -f ptouch`), re-enable Avahi if the Pi needs it (`sudo systemctl enable --now avahi-daemon`), and return to the USB deployment. +Stop the Pi server: `sudo docker rm -f ptouch` for a manual install, or `sudo systemctl stop ptouch` on a prebuilt card, whose service otherwise recreates the container (to keep it off across reboots, disable it through `/media/root-ro` as above). Re-enable Avahi if the Pi needs it (`sudo systemctl enable --now avahi-daemon`), and return to the USB deployment. From ec1352756fa2c23ba330b261f83e87fab6913759 Mon Sep 17 00:00:00 2001 From: Fedir Date: Fri, 16 Oct 2026 01:27:26 -0400 Subject: [PATCH 14/14] openspec: sync add-bluetooth-transport specs and archive the change --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/printer-device/spec.md | 0 .../specs/server-architecture/spec.md | 0 .../tasks.md | 2 +- openspec/specs/printer-device/spec.md | 54 ++++++++++++++++++- openspec/specs/server-architecture/spec.md | 46 +++++++++++++++- 8 files changed, 98 insertions(+), 4 deletions(-) rename openspec/changes/{add-bluetooth-transport => archive/2026-10-16-add-bluetooth-transport}/.openspec.yaml (100%) rename openspec/changes/{add-bluetooth-transport => archive/2026-10-16-add-bluetooth-transport}/design.md (100%) rename openspec/changes/{add-bluetooth-transport => archive/2026-10-16-add-bluetooth-transport}/proposal.md (100%) rename openspec/changes/{add-bluetooth-transport => archive/2026-10-16-add-bluetooth-transport}/specs/printer-device/spec.md (100%) rename openspec/changes/{add-bluetooth-transport => archive/2026-10-16-add-bluetooth-transport}/specs/server-architecture/spec.md (100%) rename openspec/changes/{add-bluetooth-transport => archive/2026-10-16-add-bluetooth-transport}/tasks.md (95%) diff --git a/openspec/changes/add-bluetooth-transport/.openspec.yaml b/openspec/changes/archive/2026-10-16-add-bluetooth-transport/.openspec.yaml similarity index 100% rename from openspec/changes/add-bluetooth-transport/.openspec.yaml rename to openspec/changes/archive/2026-10-16-add-bluetooth-transport/.openspec.yaml diff --git a/openspec/changes/add-bluetooth-transport/design.md b/openspec/changes/archive/2026-10-16-add-bluetooth-transport/design.md similarity index 100% rename from openspec/changes/add-bluetooth-transport/design.md rename to openspec/changes/archive/2026-10-16-add-bluetooth-transport/design.md diff --git a/openspec/changes/add-bluetooth-transport/proposal.md b/openspec/changes/archive/2026-10-16-add-bluetooth-transport/proposal.md similarity index 100% rename from openspec/changes/add-bluetooth-transport/proposal.md rename to openspec/changes/archive/2026-10-16-add-bluetooth-transport/proposal.md diff --git a/openspec/changes/add-bluetooth-transport/specs/printer-device/spec.md b/openspec/changes/archive/2026-10-16-add-bluetooth-transport/specs/printer-device/spec.md similarity index 100% rename from openspec/changes/add-bluetooth-transport/specs/printer-device/spec.md rename to openspec/changes/archive/2026-10-16-add-bluetooth-transport/specs/printer-device/spec.md diff --git a/openspec/changes/add-bluetooth-transport/specs/server-architecture/spec.md b/openspec/changes/archive/2026-10-16-add-bluetooth-transport/specs/server-architecture/spec.md similarity index 100% rename from openspec/changes/add-bluetooth-transport/specs/server-architecture/spec.md rename to openspec/changes/archive/2026-10-16-add-bluetooth-transport/specs/server-architecture/spec.md diff --git a/openspec/changes/add-bluetooth-transport/tasks.md b/openspec/changes/archive/2026-10-16-add-bluetooth-transport/tasks.md similarity index 95% rename from openspec/changes/add-bluetooth-transport/tasks.md rename to openspec/changes/archive/2026-10-16-add-bluetooth-transport/tasks.md index 0e3a34d..53028a9 100644 --- a/openspec/changes/add-bluetooth-transport/tasks.md +++ b/openspec/changes/archive/2026-10-16-add-bluetooth-transport/tasks.md @@ -33,5 +33,5 @@ ## 6. Final reconciliation -- [ ] 6.1 Review implementation, tests, docs and evidence against both deltas, run strict OpenSpec validation, then sync specs and archive the change. +- [x] 6.1 Review implementation, tests, docs and evidence against both deltas, run strict OpenSpec validation, then sync specs and archive the change. Codex reviewed the changes since e2c6758 (merge of main, test fix, gate record, pairing script): no lost merge behavior or overengineering; its two doc findings were fixed. Strict validation passed; specs synced. - [ ] 6.2 Open the pull request, confirm CI builds and tests both architectures without publishing, and merge only after 6.1. diff --git a/openspec/specs/printer-device/spec.md b/openspec/specs/printer-device/spec.md index b66a3d9..3d66768 100644 --- a/openspec/specs/printer-device/spec.md +++ b/openspec/specs/printer-device/spec.md @@ -32,7 +32,7 @@ The server SHALL create operational queues only for implemented and verified mod ### Requirement: Device selection is unambiguous -The server SHALL expose one application queue and consistently select its configured supported model. Multiple matching physical devices SHALL be reported as ambiguous; discovery or opening SHALL NOT silently choose the first match. USB renumbering of a single matching device SHALL NOT require queue replacement. PT-2730 SHALL match USB identity 04f9:2041 and PT-P710BT SHALL match 04f9:20af; another attached model SHALL NOT substitute for the selected one. +The server SHALL expose one application queue and consistently select its configured supported model. Multiple matching physical devices SHALL be reported as ambiguous; discovery or opening SHALL NOT silently choose the first match. USB renumbering of a single matching device SHALL NOT require queue replacement. PT-2730 SHALL match USB identity 04f9:2041 and PT-P710BT SHALL match 04f9:20af; another attached model SHALL NOT substitute for the selected one. These USB selection rules SHALL apply when no Bluetooth address is configured. #### Scenario: Two matching devices - **WHEN** two units of the selected supported model match the configured queue @@ -121,3 +121,55 @@ The server SHALL send the configured model's implemented USB print protocol. PT- #### Scenario: P710BT status failure - **WHEN** the selected PT-P710BT reports an error or does not confirm receiving-ready after output - **THEN** the job fails without replay or invented finalization, and uncertain output remains blocked under the existing recovery contract + +### Requirement: PT-P710BT can use Bluetooth instead of USB + +When `PTOUCH_BT_ADDRESS` is set, the server SHALL reach the selected PT-P710BT only over Bluetooth at that address and SHALL NOT use or fall back to USB. It SHALL send the same print protocol as over USB, hold the connection only while jobs are queued or an idle status read runs, and treat an unreachable printer as offline. An address with another model, or a malformed address, SHALL fail startup. + +#### Scenario: Bluetooth printer in range +- **WHEN** PT-P710BT is selected with a Bluetooth address and the paired printer is on and in range +- **THEN** jobs print with the same command and raster bytes the USB path would send + +#### Scenario: Connection released when idle +- **WHEN** two jobs are queued together and the queue then becomes idle +- **THEN** both jobs use one connection, and the connection closes once no job remains + +#### Scenario: Printer unreachable +- **WHEN** a job is queued while the configured printer is off, out of range, or connected to another Bluetooth client +- **THEN** the queue reports the printer unavailable, the job waits without failing, and it prints after the printer becomes reachable and media is confirmed + +#### Scenario: USB printer also attached +- **WHEN** a Bluetooth address is configured and a PT-P710BT is also attached over USB +- **THEN** the server sends no status request or output over USB + +#### Scenario: Address with an unsupported model +- **WHEN** a Bluetooth address is configured and the selected model is PT-2730 or the address is malformed +- **THEN** startup fails with a configuration error naming the problem + +#### Scenario: Link lost during output +- **WHEN** the Bluetooth link fails after page raster has started, including during finalization +- **THEN** the existing uncertain-output recovery contract applies, cleanup sends nothing further, and a following job emits no raster until recovery + +### Requirement: Idle status polling is configurable + +`PTOUCH_IDLE_POLL_SECONDS` SHALL control idle status reads on every transport. Absent, idle reads SHALL follow PAPPL's status requests as before. `0` SHALL disable idle device access. A positive integer SHALL set the minimum seconds between idle device access attempts, successful or not. Skipped reads SHALL keep the last published status. Job status reads SHALL be unaffected. Any other value SHALL fail startup. + +#### Scenario: Polling unset +- **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is absent +- **THEN** idle status behavior is identical to the behavior before this setting existed + +#### Scenario: Polling disabled +- **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is `0` and clients query the idle printer +- **THEN** the server opens no device connection, and the next job still confirms live media before printing + +#### Scenario: Polling rate-limited +- **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is `300` and clients query the idle printer every few seconds +- **THEN** the server reads live status at most once per 300 seconds and reports the last published status between reads + +#### Scenario: Rate limit with the printer unreachable +- **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is `300`, the printer is unreachable, and clients query the idle printer every few seconds +- **THEN** the server attempts at most one connection per 300 seconds + +#### Scenario: Invalid polling value +- **WHEN** `PTOUCH_IDLE_POLL_SECONDS` is negative, fractional, or not a number +- **THEN** startup fails with a configuration error diff --git a/openspec/specs/server-architecture/spec.md b/openspec/specs/server-architecture/spec.md index ddab6fd..2dce803 100644 --- a/openspec/specs/server-architecture/spec.md +++ b/openspec/specs/server-architecture/spec.md @@ -29,7 +29,7 @@ Command builders, device/tape tables, raster packing, status parsing, and the tw ### Requirement: Supported and unambiguous hardware -Only the implemented PT-2730 and PT-P710BT paths SHALL receive print output. Missing or empty `PTOUCH_MODEL` SHALL select PT-2730; PT-P710BT SHALL require explicit selection. Unknown or recognized unsupported models SHALL fail configuration. The stable model URI SHALL resolve by the selected VID/PID, accepting exactly one matching device and blocking zero or multiple matches without selecting by enumeration order. +Only the implemented PT-2730 and PT-P710BT paths SHALL receive print output. Missing or empty `PTOUCH_MODEL` SHALL select PT-2730; PT-P710BT SHALL require explicit selection. Unknown or recognized unsupported models SHALL fail configuration. The stable model URI SHALL resolve by the selected VID/PID, accepting exactly one matching device and blocking zero or multiple matches without selecting by enumeration order. When a Bluetooth address is configured, the stable URI SHALL resolve by that address alone. #### Scenario: Duplicate supported printers @@ -46,6 +46,11 @@ Only the implemented PT-2730 and PT-P710BT paths SHALL receive print output. Mis - **WHEN** no model is configured - **THEN** the queue retains PT-2730 identity and behavior without automatically adopting an attached PT-P710BT +#### Scenario: Bluetooth address configured + +- **WHEN** PT-P710BT is selected with a Bluetooth address +- **THEN** the queue's device URI names that address, and USB enumeration plays no part in selection + ### Requirement: Private USB transport and fresh discovery The adapter SHALL use the internal `ptouch://` scheme with bounded libusb transfers and a fresh context per discovery/open operation. It SHALL avoid the built-in USB device-ID probe that hangs on PT-2730. Status readers SHALL validate the owning printer URI before accessing USB connection data; attached USB hardware SHALL NOT change another transport's type. @@ -147,7 +152,7 @@ Dependency changes SHALL stay pinned, narrow, and documented in [patches/README. ### Requirement: Container hotplug and addressing -Container deployment SHALL bind-mount `/dev/bus/usb` and allow USB character devices with `c 189:* rwm` so newly numbered nodes remain accessible. dbus/Avahi SHALL receive a nofile limit of 65536. Reverse proxies SHALL forward a Host accepted by PAPPL; shared-network clients MAY use a stable alias. Discovery, TLS termination, and network exposure SHALL remain deployment choices. +USB container deployment SHALL bind-mount `/dev/bus/usb` and allow USB character devices with `c 189:* rwm` so newly numbered nodes remain accessible. dbus/Avahi SHALL receive a nofile limit of 65536. Reverse proxies SHALL forward a Host accepted by PAPPL; shared-network clients MAY use a stable alias. Discovery, TLS termination, and network exposure SHALL remain deployment choices. #### Scenario: Hotplug behind a proxy @@ -162,3 +167,40 @@ The project SHALL retain GPL-3.0-only licensing and attribution for the Brother - **WHEN** the server's source or container is distributed - **THEN** its GPL license and upstream attribution remain available + +### Requirement: Private Bluetooth transport + +The adapter SHALL reach a Bluetooth printer through an internal scheme over a kernel RFCOMM socket, without a host serial bridge or in-server pairing. A connect attempt SHALL give up within 10 seconds. Status readers SHALL validate the owning printer URI before accessing Bluetooth connection data, as for USB. + +#### Scenario: Transport isolation + +- **WHEN** USB, Bluetooth, and socket test URIs are each evaluated by status processing +- **THEN** each reaches only its own transport's operations + +#### Scenario: Absent printer + +- **WHEN** the configured printer is off or out of range +- **THEN** each connect attempt fails within 10 seconds and the queue reports the printer offline + +### Requirement: Bluetooth container deployment + +A Bluetooth deployment SHALL run the container in the host network namespace, because the kernel refuses Bluetooth sockets elsewhere. It SHALL need no USB device mount. Pairing SHALL be a host step whose keys persist across host reboots. The container SHALL need no BlueZ and no added capability. Exactly one mDNS responder SHALL serve the host. + +#### Scenario: Pi reboot + +- **WHEN** the Pi hosting a paired Bluetooth deployment reboots +- **THEN** the container restarts and prints to the printer without re-pairing + +### Requirement: Multi-architecture runtime image + +Published image tags SHALL include `linux/amd64` and `linux/arm64` variants built from the same `Dockerfile.deploy`. Both architectures SHALL build and pass the build's CTest run on pull requests, before anything is published. + +#### Scenario: Pull request build + +- **WHEN** a pull request changes the image +- **THEN** CI builds both architectures and runs CTest in each without publishing + +#### Scenario: Pull on a Pi + +- **WHEN** a Raspberry Pi Zero 2 W running a 64-bit OS pulls a published tag +- **THEN** it receives the arm64 variant of the same build