Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4ed7247
docs(openspec): propose Bluetooth transport and configurable idle pol…
pfa230 Oct 14, 2026
5f0b01b
docs(openspec): address review of Bluetooth transport change
pfa230 Oct 14, 2026
6f46acf
docs(openspec): assemble Bluetooth status in one read; record held-jo…
pfa230 Oct 14, 2026
4360763
feat(printer): reach PT-P710BT over Bluetooth and make idle polling c…
pfa230 Oct 14, 2026
55ebe4e
test(printer): add the Bluetooth gate's IPP request files
pfa230 Oct 14, 2026
e9f13bc
fix(printer): address implementation review of the Bluetooth transport
pfa230 Oct 14, 2026
fcb8c31
docs: note PTOUCH_BT_ADDRESS excludes PTOUCH_DEVICE_URI
pfa230 Oct 14, 2026
e2c6758
docs(openspec): record arm64 CI verification
pfa230 Oct 14, 2026
9971bdc
feat(deploy): build an unattended Pi Zero 2 W SD card for the Bluetoo…
pfa230 Oct 15, 2026
11b56be
test(printer): wait for both queued jobs before asserting
pfa230 Oct 16, 2026
a777508
Merge remote-tracking branch 'origin/main' into add-bluetooth-transport
pfa230 Oct 16, 2026
042da29
Merge remote-tracking branch 'origin/fix-queued-jobs-wait' into add-b…
pfa230 Oct 16, 2026
71c71bc
docs: record the passed Bluetooth gate and its pairing fixes
pfa230 Oct 16, 2026
9e9d675
Merge remote-tracking branch 'origin/add-bluetooth-transport' into ad…
pfa230 Oct 16, 2026
231c7d0
fix(deploy): bond the printer in ptouch-pair
pfa230 Oct 16, 2026
5c94198
docs: stop the Pi service on rollback; scope the power-loss claim
pfa230 Oct 16, 2026
ec13527
openspec: sync add-bluetooth-transport specs and archive the change
pfa230 Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
19 changes: 16 additions & 3 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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})
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.deploy
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
- Advertises **nominal** tape sizes with real margins (`om_24x100mm`), so clients
Expand Down Expand Up @@ -98,8 +100,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
Expand Down Expand Up @@ -250,6 +252,18 @@ protocol still follow `PTOUCH_MODEL`, and the printer connects on an actual prin
An explicit `ptouch://Brother/<model>` 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, 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:

| 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
Expand All @@ -265,7 +279,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

Expand All @@ -275,6 +289,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`,
Expand Down Expand Up @@ -342,6 +357,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.
Expand Down
102 changes: 102 additions & 0 deletions deploy/pi/build-card.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Write the ptouch Bluetooth print server SD card on macOS.

Usage: sudo python3 build-card.py <diskN> <raspios.img> <ptouch-image.tar.gz> <wifi.env> <ssh-pubkey-file>

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("<I", mbr, 0x1BE + 16 + 8)[0]
p3_start = -(-(p2_start + ROOT_SECTORS) // ALIGN) * ALIGN
struct.pack_into("<I", mbr, 0x1BE + 16 + 12, p3_start - p2_start)
# Partition 3: Linux (0x83), CHS fields unused (LBA only).
mbr[0x1BE + 32:0x1BE + 48] = struct.pack("<B3sB3sII", 0, b"\xfe\xff\xff", 0x83, b"\xfe\xff\xff",
p3_start, total - p3_start)
f.seek(0)
f.write(mbr)


def main():
disk, image, server_image, wifi_env, pubkey = sys.argv[1:6]
total = card_sectors(disk)
env = read_env(wifi_env)
key = pathlib.Path(pubkey).read_text().strip()
# Homebrew OpenSSL: macOS's LibreSSL /usr/bin/openssl has no SHA-512 crypt (-6).
password_hash = subprocess.run(["/opt/homebrew/bin/openssl", "passwd", "-6", "-stdin"], input=env["LOGIN_PASSWORD"],
text=True, check=True, capture_output=True).stdout.strip()

diskutil("unmountDisk", disk)
raw = f"/dev/r{disk}"
print(f"Writing {image} to {raw} ...", flush=True)
subprocess.run(["dd", f"if={image}", f"of={raw}", "bs=4m"], check=True)
diskutil("unmountDisk", disk)
write_partition_table(raw, total)

diskutil("unmountDisk", disk)
boot = pathlib.Path(tempfile.mkdtemp())
diskutil("mount", "-mountPoint", str(boot), f"{disk}s1")

user_data = (HERE / "user-data").read_text()
for name, value in {"SSH_KEY": key, "PASSWORD_HASH": password_hash, "WIFI_COUNTRY": env["WIFI_COUNTRY"],
"BT_ADDRESS": env.get("BT_ADDRESS", "")}.items():
user_data = user_data.replace(f"@{name}@", value)
(boot / "user-data").write_text(user_data)
(boot / "network-config").write_text(
"network:\n version: 2\n wifis:\n wlan0:\n dhcp4: true\n optional: false\n"
f" regulatory-domain: {json.dumps(env['WIFI_COUNTRY'])}\n"
f" access-points:\n {json.dumps(env['WIFI_SSID'])}:\n"
f" password: {json.dumps(env['WIFI_PSK'])}\n")
(boot / "ssh").touch()
cmdline = boot / "cmdline.txt"
cmdline.write_text(cmdline.read_text().replace(" resize", ""))
(boot / "ptouch").mkdir()
shutil.copy(server_image, boot / "ptouch/ptouch-image.tar.gz")

diskutil("unmount", str(boot))
diskutil("eject", disk)
print(f"Done: {disk} ejected.")


main()
Loading
Loading