Cross-platform Go print agent for Bluetooth ESC/POS receipt printers (58 mm). A hosted POS webpage submits structured, idempotent print jobs to a loopback HTTP API; the agent owns all printer logic — queues, reconnects, rendering, duplicate prevention — and keeps working with no browser open and no internet.
Current platform support: macOS and Linux.
All OS-specific code lives behind the platform.Driver interface in
internal/platform/ — one subfolder per OS. Adding a platform means filling
in one folder; the core never mentions an operating system. Windows remains a
compiling scaffold.
go run ./cmd/print-agent # dashboard at http://127.0.0.1:17432/admin
go run ./cmd/print-agent --port 17555 --data-dir /tmp/agent-dataData (SQLite DB, rolling logs) lives in the platform's user data directory by default. Structured application logs are available under System → System Logs and retained for two hours. Printer and Print Run events are available under Operations → Printer Logs and retained with the existing 48-hour Job history.
Run these before trusting a new printer model:
go run ./cmd/btprobe list # paired candidate printers
go run ./cmd/btprobe test /dev/cu.MyPrinter # macOS formatted test page
go run ./cmd/btprobe test ble://AA:BB:CC:DD:EE:FF # Linux BLE formatted test page
go run ./cmd/btprobe charset rfcomm://AA:BB:CC:DD:EE:FF # Linux SPP charset page
go run ./cmd/btprobe multi ble://AA:BB:CC:DD:EE:01 rfcomm://AA:BB:CC:DD:EE:02
go run ./cmd/btprobe status /dev/cu.MyPrinter # serial paths only; DLE EOT statustest, charset, and multi use the active platform driver, so Linux
rfcomm://<MAC> and ble://<MAC> endpoints connect through BlueZ rather than
being mistaken for filesystem serial ports. status needs bidirectional
access from the serial library and therefore supports serial paths only.
- BlueZ 5 with
bluetoothdrunning and its system D-Bus service available. - Kernel Bluetooth RFCOMM support for classic SPP printers (built in or the
rfcommmodule loaded); BLE printers use BlueZ GATT over D-Bus. - Find printers from the dashboard's Pair devices tab. Printers with a usable endpoint can be added immediately; classic SPP-only printers must be paired first.
- Permission for the agent's user/session to use the BlueZ D-Bus APIs.
Linux discovery returns either rfcomm://AA:BB:CC:DD:EE:FF for bonded classic
SPP printers or ble://AA:BB:CC:DD:EE:FF for printers exposing the common
BLE thermal-printer service 18f0 and writable characteristic 2af1. BLE
writes are scoped to that service and chunked to the negotiated ATT MTU.
Classic mode registers an SPP client profile and uses the RFCOMM socket BlueZ
supplies. Neither mode requires the deprecated rfcomm or sdptool tools.
Legacy Linux /dev/rfcommN endpoints are rejected; reconfigure them as
rfcomm://<MAC>. macOS /dev/cu.* endpoints remain supported.
For a hardware validation pass on Linux: discover and power on the printer,
pair it if the dashboard requires pairing, run btprobe list, then run test
and charset with the discovered endpoint.
Power-cycle the printer and repeat test to verify reconnection. If using
several printers, run multi with every endpoint and confirm each physical
printer receives only its own rounds.
- On Linux, dashboard → Pair devices → scan for printers. Select Add
printer when the device is ready. If a classic printer has no endpoint,
pair it first; blank PIN uses the common thermal-printer PIN
0000. Some inexpensive BLE printers remain unbonded and correctly appear as ready without pairing. On macOS, pair printers in the OS Bluetooth settings. - Dashboard → Printers → Add printer → pick the discovered endpoint,
assign an ID (
cashier,kitchen,bar), and choose an encoding (CP858 covers Spanish + €). - Test print.
All three printers may advertise the same Bluetooth name — printers are stored by endpoint + MAC address; label them physically.
- Dashboard → POS Pairing → set the exact allowed origin
(e.g.
https://pos.example.com) → Generate pairing code. - The POS page exchanges the code for a bearer token (store it per installation):
const { token } = await (await fetch("http://127.0.0.1:17432/api/v1/pair", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: userEnteredCode }),
})).json();- Submit a Job — one business/order ID, one template/data payload, and one or more original printers:
await fetch("http://127.0.0.1:17432/api/v1/jobs", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({
jobId: "store-001:order-1256",
template: "kitchen-ticket",
data: { orderNumber: "1256", items: [/*…*/] },
printerIds: ["kitchen-1", "kitchen-2"],
}),
});The idempotency identity is jobId + template:
- An identical retry returns the existing internal Job
uidand creates no new Print Runs. - Reusing the identity with different data or printers returns
409 Conflict. - A different template under the same
jobIdcreates a separate Job. - Changed order data must use a new
jobId. - Printer ordering does not affect equality.
Completed Job data is retained for 48 hours. After it is purged, the same
jobId + template can be accepted and printed again by design.
Read one Job with GET /api/v1/jobs/{uid} or find related Jobs with
GET /api/v1/jobs?jobId=order-1256. Print Runs use
GET /api/v1/print-runs/{uid}.
A selected or whole-Job reprint uses POST /api/v1/jobs/{uid}/reprint:
{
"reprintRequestId": "operator-action-789",
"printerIds": ["kitchen-1", "kitchen-2"],
"reason": "Tickets were damaged"
}The request ID is idempotent within that Job. Manual reprints target original
printers only and print *** REPRINT - RUN N ***.
Templates: customer-receipt, kitchen-ticket, bar-ticket, test-page.
There is deliberately no endpoint for raw ESC/POS bytes.
Chrome 142+ shows a Local Network Access permission prompt the first time
the (HTTPS) POS page calls the loopback API — the user must accept it once;
the grant also relaxes mixed-content blocking for the local request. The
agent answers CORS preflights (including the legacy
Access-Control-Allow-Private-Network header) for the configured origin only.
Safari blocks HTTPS→http://127.0.0.1 entirely; supporting Safari would
require the local-HTTPS mode (not yet implemented).
scripts/pos-sim/index.html is a minimal fake POS page for end-to-end
testing of pairing, CORS, the LNA prompt, and idempotent submission.
For local kitchen-ticket testing, dashboard → Dev → POS Simulator can submit editable tickets to one or more configured printers, inspect their Print Runs, verify job idempotency/conflicts, and trigger selected-target reprints. These actions use the real queue and can produce physical output.
queued → processing → transmitted — transmitted means all bytes reached
the OS/Bluetooth link, not that paper came out (cheap printers give no
acknowledgement). Failures classify as:
- A printer that is offline leaves its existing Run queued; connection attempts are not Print Runs.
- A confirmed zero-byte execution failure becomes
failed. After the printer reconnects, a distinctautomatic_retryPrint Run is created, up to three retries per execution chain. failed— deterministic rendering/validation failed and operator attention is required.uncertain— transmission had begun (or timed out mid-write); never auto-retried. An operator must explicitly choose It printed or request a marked reprint.
On startup, Print Runs stuck in processing become uncertain (the agent
may have died mid-write). Queued Jobs survive restarts — SQLite is the queue.
If a Print Run state write to SQLite fails, all workers pause new claims and
retry persistence indefinitely. /api/v1/status and the dashboard expose this
pause; printing resumes automatically after the database recovers.
Terminal/resolved Jobs and events are retained for 48 hours. Queued, processing, retry-pending, and unresolved failed/uncertain work is never purged.
The data directory and log directory are forced to owner-only mode (0700)
and the SQLite/log files to 0600. The queue contains receipt payloads and
should be treated as sensitive local data. Authentication protects the POS
browser boundary; management endpoints intentionally trust local machine
access and are not a defense against another process running as the user.
This project is pre-release. The Job/Print Run schema is a destructive rebuild
baseline; old pre-release databases are not migrated. Stop the agent, delete
or relocate its data directory, restart it, and configure printers and POS
pairing again. The agent never silently deletes an old database itself.
Applied migrations are checksummed and startup runs SQLite quick_check.
go test ./... # unit + integration (mock transports with scripted failures)
go vet ./...Architecture (one worker per printer with a process-wide transmission permit):
HTTP handler → JobService → SQLite tx → wake channel → printer worker
worker: acquire permit → claim → render ESC/POS
→ write + verify → persist → release
Connections and reconnects remain independent, but at most one ESC/POS print attempt is active across the agent at any time.