From 16d235079f6d0a43325cfc7a65ce24492713bbd1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 16 Aug 2026 21:22:47 +0200 Subject: [PATCH 1/4] feat(hal): add Pyramid Apex RS-232 bill-validator driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `apex` validator for Pyramid Technologies Apex-series acceptors (Apex 5000/7000/7600) on their RS-232 interface, for the Raspberry Pi 5 build. Implemented from Pyramid's PUBLIC protocol facts (RS-232 Serial Interface Specification + their published integrator samples) — not ported from lamassu-machine or any licensed source, so it stays inside this repo's provenance boundary and AGPL. - apex-rs232.ts: 8-byte poll frame (STX/len/ctrl+ACK-toggle/enable/cmd/ rsvd/ETX/XOR-checksum), reply parsing (state+event+credit bytes), escrow stack/return latching re-asserted until the note leaves escrow. - apex-fsm.ts: status tracker → BillValidator events (mirrors the EBDS tracker; dedupes continuous polling; escrow-latency watchdog). - denominations.ts: per-fiat channel→value table (channel order must match the acceptor's programmed dataset — verify on the unit). - index.ts: ApexValidator implementing BillValidator; wired into the createValidator factory as ValidatorType 'apex'. - 13 unit tests for checksum, frame build, status priority, denom map. Bench-verify on real hardware before trusting: the reply checksum range (parsed leniently for now) and return-by-disable escrow behaviour are flagged in-code as needing confirmation on the 7600. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW --- .../apex/__tests__/apex-rs232.test.ts | 146 +++++++ packages/hal/src/validators/apex/apex-fsm.ts | 105 +++++ .../hal/src/validators/apex/apex-rs232.ts | 363 ++++++++++++++++++ .../hal/src/validators/apex/denominations.ts | 30 ++ packages/hal/src/validators/apex/index.ts | 155 ++++++++ packages/hal/src/validators/index.ts | 8 +- 6 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts create mode 100644 packages/hal/src/validators/apex/apex-fsm.ts create mode 100644 packages/hal/src/validators/apex/apex-rs232.ts create mode 100644 packages/hal/src/validators/apex/denominations.ts create mode 100644 packages/hal/src/validators/apex/index.ts diff --git a/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts b/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts new file mode 100644 index 0000000..95c1607 --- /dev/null +++ b/packages/hal/src/validators/apex/__tests__/apex-rs232.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest' +import { + computeChecksum, + buildFrame, + creditChannel, + parseStatus, + parseResponse, +} from '../apex-rs232.js' + +// Cassette-present bit; OR it into event bytes so parseStatus doesn't short to +// 'stackerOpen'. +const PRESENT = 0x10 + +describe('Apex RS-232 protocol', () => { + describe('computeChecksum', () => { + it('XORs bytes 1..5 (matches the Pyramid reference poll frame)', () => { + // 02 08 10 7F 00 00 03 -> checksum 0x67 + const frame = [0x02, 0x08, 0x10, 0x7f, 0x00, 0x00, 0x03, 0x00] + expect(computeChecksum(frame)).toBe(0x67) + }) + }) + + describe('buildFrame', () => { + it('lays out the 8-byte poll frame with the ACK bit and checksum', () => { + const f = buildFrame(0, 0x7f, 0x00) + expect([...f]).toEqual([0x02, 0x08, 0x10, 0x7f, 0x00, 0x00, 0x03, 0x67]) + }) + + it('sets the ACK bit in the control byte and recomputes the checksum', () => { + const f = buildFrame(1, 0x7f, 0x00) + expect(f[2]).toBe(0x11) + expect(f[7]).toBe(0x66) + }) + + it('sets the stack command bit (0x20) in the command byte', () => { + const f = buildFrame(0, 0x7f, 0x20) + expect(f[4]).toBe(0x20) + expect(f[7]).toBe(computeChecksum([...f])) + }) + + it('masks the enable byte to a single note channel', () => { + const f = buildFrame(0, 0x01, 0x00) + expect(f[3]).toBe(0x01) + }) + }) + + describe('creditChannel', () => { + it('extracts the 1-based channel from bits 3..5', () => { + expect(creditChannel(0x00)).toBe(0) + expect(creditChannel(0x08)).toBe(1) + expect(creditChannel(0x28)).toBe(5) + expect(creditChannel(0x38)).toBe(7) + }) + }) + + describe('parseStatus', () => { + it('maps each state bit to its status', () => { + expect(parseStatus(0x01, PRESENT)).toBe('standby') + expect(parseStatus(0x02, PRESENT)).toBe('accepting') + expect(parseStatus(0x04, PRESENT)).toBe('billsRead') + expect(parseStatus(0x08, PRESENT)).toBe('stacking') + expect(parseStatus(0x10, PRESENT)).toBe('billsValid') + expect(parseStatus(0x20, PRESENT)).toBe('returning') + expect(parseStatus(0x40, PRESENT)).toBe('billsRejected') + }) + + it('reports stackerOpen when the cassette-present bit is clear', () => { + expect(parseStatus(0x01, 0x00)).toBe('stackerOpen') + expect(parseStatus(0x10, 0x00)).toBe('stackerOpen') // even mid-stack + }) + + it('prioritises jam and reject events over motion', () => { + expect(parseStatus(0x08, PRESENT | 0x04)).toBe('jam') + expect(parseStatus(0x04, PRESENT | 0x02)).toBe('billsRejected') // rejected + expect(parseStatus(0x04, PRESENT | 0x01)).toBe('billsRejected') // cheated + }) + + it('prefers a terminal stacked state over a combined idling bit', () => { + // 0x11 = stacked | idling + expect(parseStatus(0x11, PRESENT)).toBe('billsValid') + }) + }) + + describe('parseResponse', () => { + const usd = [1, 5, 10, 20, 50, 100] + const resolve = (ch: number) => usd[ch - 1] ?? null + + it('resolves the escrowed note denomination from the credit channel', () => { + // state=escrowed, event=present, credit=channel 4 ($20) + const frame = Buffer.from([ + 0x02, + 0x0b, + 0x10, + 0x04, + PRESENT, + 0x20, + 0x00, + 0x00, + 0x00, + 0x03, + 0x00, + ]) + const r = parseResponse(frame, resolve) + expect(r.status).toBe('billsRead') + expect(r.bill?.denomination).toBe(20) + }) + + it('returns no bill when no channel is credited', () => { + const frame = Buffer.from([ + 0x02, + 0x0b, + 0x10, + 0x01, + PRESENT, + 0x00, + 0x00, + 0x00, + 0x00, + 0x03, + 0x00, + ]) + const r = parseResponse(frame, resolve) + expect(r.status).toBe('standby') + expect(r.bill).toBeUndefined() + }) + + it('yields a null denomination for an unmapped channel', () => { + // channel 7 not present in the 6-entry USD table + const frame = Buffer.from([ + 0x02, + 0x0b, + 0x10, + 0x10, + PRESENT, + 0x38, + 0x00, + 0x00, + 0x00, + 0x03, + 0x00, + ]) + const r = parseResponse(frame, resolve) + expect(r.bill?.denomination).toBeNull() + }) + }) +}) diff --git a/packages/hal/src/validators/apex/apex-fsm.ts b/packages/hal/src/validators/apex/apex-fsm.ts new file mode 100644 index 0000000..250030b --- /dev/null +++ b/packages/hal/src/validators/apex/apex-fsm.ts @@ -0,0 +1,105 @@ +/** + * Apex Status Tracker + * + * Like EBDS, the Apex RS-232 protocol reports status directly via bits in each + * reply, so there's no complex command/response state machine — just dedupe + * status changes and translate them into the BillValidator event interface. + * + * Status flow: + * standby → accepting → billsRead(escrow) → billsValid(stacked) + * → billsRejected(returned) + */ + +import { EventEmitter } from 'node:events' +import type { ApexParseResult } from './apex-rs232.js' + +// A note shouldn't sit in escrow long; warn if the host's stack/return +// decision lags, which on most acceptors risks an autonomous timeout-return. +const ESCROW_WATCHDOG_WARN_MS = 500 + +export class ApexFsm extends EventEmitter { + private currentStatus: string | null = null + private escrowOpenedAt: number | null = null + + static factory(): ApexFsm { + return new ApexFsm() + } + + /** Process a parsed Apex reply and emit status-change events. */ + process(result: ApexParseResult): void { + const { status, bill } = result + if (!status) return + if (this.currentStatus === status) return // dedupe continuous polling + + const prev = this.currentStatus + this.currentStatus = status + console.log('[APEX] %d status: %s → %s', Date.now(), prev ?? '(init)', status) + + if (prev === 'billsRead' && this.escrowOpenedAt !== null) { + const elapsed = Date.now() - this.escrowOpenedAt + this.escrowOpenedAt = null + if (elapsed > ESCROW_WATCHDOG_WARN_MS) { + console.warn( + '[APEX] escrow held %dms before %s — host decision latency high', + elapsed, + status + ) + } + } + + switch (status) { + case 'accepting': + this.emit('accepting') + break + + case 'billsRead': + // Bill in escrow. Apex doesn't report currency, so there's no + // per-note currency check here (the enable mask already gates which + // channels the acceptor will escrow). + this.escrowOpenedAt = Date.now() + this.emit('billsAccepted') + // Match EBDS: emit billsRead on the next tick. + process.nextTick(() => this.emit('billsRead', bill ?? { denomination: null, code: '' })) + break + + case 'stacking': + this.emit('stacking') + break + + case 'returning': + if (prev === 'billsRead') { + console.warn( + '[APEX] returning straight from escrow with no host reject — watch for autonomous return' + ) + } + this.emit('returning') + break + + case 'billsValid': + if (bill && !bill.denomination) return // stacked with no denom (e.g. cashbox reinsert) + this.emit('billsValid') + break + + case 'billsRejected': + this.emit('billsRejected') + break + + case 'jam': + this.emit('error', new Error('Bill validator jam')) + break + + case 'stackerOpen': + this.emit('stackerOpen') + break + + case 'standby': + this.emit('standby') + break + } + } + + reset(): void { + this.currentStatus = null + this.escrowOpenedAt = null + } +} diff --git a/packages/hal/src/validators/apex/apex-rs232.ts b/packages/hal/src/validators/apex/apex-rs232.ts new file mode 100644 index 0000000..d8bcb7e --- /dev/null +++ b/packages/hal/src/validators/apex/apex-rs232.ts @@ -0,0 +1,363 @@ +/** + * Pyramid Apex RS-232 Protocol Layer + * + * Serial communication for Pyramid Technologies Apex-series bill acceptors + * (Apex 5000 / 7000 / 7600) running their RS-232 interface. + * + * Implemented from Pyramid's PUBLIC protocol facts only — the wire format, + * bit masks and serial parameters documented in Pyramid's "RS-232 Serial + * Interface Specification" (https://pyramidacceptors.com/pdf/RS_232.pdf) and + * mirrored by their published integrator samples. No third-party (or + * lamassu-machine) source is copied; the byte layout below is a functional + * spec, re-expressed for bitSpire under AGPL. + * + * Frame (host → acceptor), fixed 8 bytes: + * [0] STX 0x02 + * [1] LEN 0x08 + * [2] CTRL 0x10 | ack (ack toggles 0↔1 every message) + * [3] ENA denomination enable bitmask (0x7F = all, 0x00 = none) + * [4] CMD 0x00 base; | 0x20 stacks the escrowed note + * [5] RSVD 0x00 + * [6] ETX 0x03 + * [7] CHK XOR of bytes [1..5] + * + * Frame (acceptor → host), length-prefixed like the host frame; the fields + * this driver consumes: + * [3] STATE bits 1=idling 2=accepting 4=escrowed 8=stacking + * 16=stacked 32=returning 64=returned + * [4] EVENT bits 0x01=cheated 0x02=rejected 0x04=jammed + * 0x08=stacker-full 0x10=cassette-present + * [5] CREDIT denomination channel = (byte & 0x38) >> 3 (1..7, 0=none) + * + * Serial: 9600 baud, 7 data bits, even parity, 1 stop bit. + */ + +import { EventEmitter } from 'node:events' +import { SerialPort } from 'serialport' + +const STX = 0x02 +const ETX = 0x03 +const HOST_FRAME_LEN = 0x08 + +// Host command byte (frame[4]) +const CMD_STACK = 0x20 + +// Response STATE byte (frame[3]) bit masks +const STATE_IDLING = 0x01 +const STATE_ACCEPTING = 0x02 +const STATE_ESCROWED = 0x04 +const STATE_STACKING = 0x08 +const STATE_STACKED = 0x10 +const STATE_RETURNING = 0x20 +const STATE_RETURNED = 0x40 + +// Response EVENT byte (frame[4]) bit masks +const EVENT_CHEATED = 0x01 +const EVENT_REJECTED = 0x02 +const EVENT_JAMMED = 0x04 +const EVENT_STACKER_FULL = 0x08 +const EVENT_CASSETTE_PRESENT = 0x10 + +// Plausibility bounds for the response length byte, used only to resync a +// desynced stream — a real reply is short (≈8–16 bytes). +const RESP_LEN_MIN = 6 +const RESP_LEN_MAX = 32 + +export type ApexStatus = + | 'standby' + | 'accepting' + | 'billsRead' + | 'stacking' + | 'returning' + | 'billsValid' + | 'billsRejected' + | 'jam' + | 'stackerOpen' + +export interface ApexParseResult { + status: ApexStatus | null + /** Populated when a denomination channel is present (escrow / stacked). */ + bill?: { denomination: number | null; code: string } + /** Truthy status flags, for on-change diagnostic logging. */ + flags: string +} + +export interface ApexRs232Config { + device: string | string[] +} + +// --------------------------------------------------------------------------- +// Pure functions — checksum, frame building, response parsing +// --------------------------------------------------------------------------- + +/** XOR checksum over bytes [1..5] (LEN through RSVD), matching the host frame. */ +export function computeChecksum(frame: number[] | Buffer): number { + let cs = 0x00 + for (let i = 1; i <= 5; i++) cs ^= frame[i] ?? 0 + return cs +} + +/** + * Build the 8-byte host poll/command frame. + * @param ack current ACK bit (0 or 1) + * @param enableByte denomination enable bitmask + * @param cmdByte command bits (e.g. CMD_STACK) + */ +export function buildFrame(ack: number, enableByte: number, cmdByte: number): Buffer { + const frame = [ + STX, + HOST_FRAME_LEN, + 0x10 | (ack & 0x01), + enableByte & 0xff, + cmdByte & 0xff, + 0x00, + ETX, + 0x00, + ] + frame[7] = computeChecksum(frame) + return Buffer.from(frame) +} + +/** Channel index (1..7, 0 = none) of the credited note in the CREDIT byte. */ +export function creditChannel(creditByte: number): number { + return (creditByte & 0x38) >> 3 +} + +/** + * Derive a high-level status from the STATE + EVENT bytes. Terminal outcomes + * (stacked / returned / cheated / rejected / jam / stacker-open) win over the + * transient in-motion states so a late-arriving reply can't mask a result. + */ +export function parseStatus(state: number, event: number): ApexStatus | null { + // Cassette (stacker box) removed — surfaces before anything transactional. + if (!(event & EVENT_CASSETTE_PRESENT)) return 'stackerOpen' + if (event & EVENT_JAMMED) return 'jam' + // Terminal resting states + if (state & STATE_STACKED) return 'billsValid' + if (state & STATE_RETURNED || event & (EVENT_CHEATED | EVENT_REJECTED)) return 'billsRejected' + // Transient — bill in motion. Surface before `escrowed`. + if (state & STATE_STACKING) return 'stacking' + if (state & STATE_RETURNING) return 'returning' + if (state & STATE_ESCROWED) return 'billsRead' + if (state & STATE_ACCEPTING) return 'accepting' + if (state & STATE_IDLING) return 'standby' + return null +} + +function summarizeFlags(state: number, event: number, credit: number): string { + const f: string[] = [] + if (state & STATE_IDLING) f.push('idling') + if (state & STATE_ACCEPTING) f.push('accepting') + if (state & STATE_ESCROWED) f.push('escrowed') + if (state & STATE_STACKING) f.push('stacking') + if (state & STATE_STACKED) f.push('stacked') + if (state & STATE_RETURNING) f.push('returning') + if (state & STATE_RETURNED) f.push('returned') + if (event & EVENT_CHEATED) f.push('cheated') + if (event & EVENT_REJECTED) f.push('rejected') + if (event & EVENT_JAMMED) f.push('jammed') + if (event & EVENT_STACKER_FULL) f.push('stackerFull') + if (!(event & EVENT_CASSETTE_PRESENT)) f.push('cassetteMissing') + const ch = creditChannel(credit) + if (ch) f.push(`channel=${ch}`) + return f.join(', ') || '(none)' +} + +/** + * Parse a complete acceptor frame. `denomForChannel` maps a 1-based credit + * channel to a fiat value (null if the channel isn't configured). + */ +export function parseResponse( + frame: Buffer, + denomForChannel: (channel: number) => number | null +): ApexParseResult { + const state = frame[3] ?? 0 + const event = frame[4] ?? 0 + const credit = frame[5] ?? 0 + const status = parseStatus(state, event) + const flags = summarizeFlags(state, event, credit) + + const channel = creditChannel(credit) + if (channel > 0) { + return { status, bill: { denomination: denomForChannel(channel), code: '' }, flags } + } + return { status, flags } +} + +// --------------------------------------------------------------------------- +// ApexRs232 class +// --------------------------------------------------------------------------- + +export class ApexRs232 extends EventEmitter { + private buf: Buffer = Buffer.alloc(0) + private config: ApexRs232Config + private serial: SerialPort | null = null + private ack = 0x0 + private enabledMask = 0x00 + // Latched escrow decision. Like EBDS, the stack/return choice isn't a + // one-shot: the note sits in escrow until a poll asserts it, so we re-assert + // every poll until the device leaves escrow (cleared in _process). A single + // dropped frame then can't strand the note. + private pendingAction: 'none' | 'stack' | 'return' = 'none' + private lastFlags: string | null = null + private denomForChannel: (channel: number) => number | null = () => null + + constructor(config: ApexRs232Config) { + super() + this.config = config + } + + static factory(config: ApexRs232Config): ApexRs232 { + return new ApexRs232(config) + } + + /** Provide the channel→denomination resolver (fiat-dependent). */ + setDenomResolver(fn: (channel: number) => number | null): void { + this.denomForChannel = fn + } + + // -- Serial connection --------------------------------------------------- + + private async _open(device: string): Promise { + return new Promise((resolve, reject) => { + const serial = new SerialPort({ + path: device, + baudRate: 9600, + parity: 'even' as const, + dataBits: 7 as const, + stopBits: 1 as const, + autoOpen: false, + rtscts: false, + }) + this.serial = serial + + serial.on('error', (err) => this.emit('error', err)) + serial.on('open', (err?: Error | null) => { + if (err) return reject(err) + serial.on('readable', () => { + const data = serial.read() as Buffer | null + if (data) this._process(data) + }) + serial.on('close', () => this.emit('disconnected')) + this.emit('connected') + resolve() + }) + + serial.open() + }) + } + + async open(cb: (err?: Error) => void): Promise { + const devices = this.config.device + if (!devices) { + this.emit('error', new Error('No configured devices.')) + return + } + const list = typeof devices === 'string' ? [devices] : devices + for (const device of list) { + try { + await this._open(device) + cb() + return + } catch { + continue + } + } + cb(new Error('No configured devices available.')) + } + + close(cb: (err?: Error | null) => void): void { + this.serial?.close(cb) + } + + // -- Enable / commands --------------------------------------------------- + + setEnabledDenominations(mask: number): void { + this.enabledMask = mask & 0xff + } + + /** Send one poll, carrying the current mask + latched escrow action. */ + poll(): void { + // 'return' is expressed by disabling all channels while a note is escrowed, + // which makes the acceptor hand the note back (Apex has no distinct return + // opcode). 'stack' asserts CMD_STACK. Both are re-asserted until the device + // leaves escrow. NOTE: verify the return-by-disable behaviour on the 7600 + // during bench bring-up; some firmware returns only on escrow timeout. + let enableByte = this.enabledMask + let cmdByte = 0x00 + if (this.pendingAction === 'stack') cmdByte = CMD_STACK + else if (this.pendingAction === 'return') enableByte = 0x00 + + this.ack ^= 0x01 + this.serial?.write(buildFrame(this.ack, enableByte, cmdByte)) + } + + stack(): void { + this.pendingAction = 'stack' + this.poll() + } + + reject(): void { + this.pendingAction = 'return' + this.poll() + } + + /** Disable all denominations and clear any latched escrow action. */ + reset(): void { + this.pendingAction = 'none' + this.enabledMask = 0x00 + this.poll() + } + + // -- Receive / parse ----------------------------------------------------- + + private _process(data: Buffer): void { + this.buf = this._acquireSync(Buffer.concat([this.buf, data])) + if (this.buf.length < 2) return + + const len = this.buf[1] ?? 0 + if (len < RESP_LEN_MIN || len > RESP_LEN_MAX) { + // Implausible length byte — drop the STX we synced on and resync. + this.buf = this._acquireSync(this.buf.subarray(1)) + return + } + if (this.buf.length < len) return // wait for the whole frame + + const frame = this.buf.subarray(0, len) + this.buf = this.buf.subarray(len) + + if (frame[len - 2] !== ETX) { + this.emit('badFrame') + this.poll() + return + } + // Checksum is validated leniently: a mismatch is logged once but the frame + // is still parsed. Pyramid's published host samples don't verify the reply + // checksum, and the exact XOR range for the *reply* isn't confirmable from + // the (scanned) spec — so we don't want a wrong assumption to blackhole + // every otherwise-valid frame. Tighten to a hard drop once verified on hw. + if (frame[len - 1] !== computeChecksum(frame)) { + console.warn('[APEX] reply checksum mismatch (parsing anyway pending hw verification)') + } + + const result = parseResponse(frame, this.denomForChannel) + + // Clear a latched stack/return once the note has left escrow, so it can't + // leak onto the next note. + const escrowed = (frame[3] ?? 0) & STATE_ESCROWED + if (!escrowed) this.pendingAction = 'none' + + if (result.flags !== this.lastFlags) { + console.log(`[APEX] status: ${this.lastFlags ?? '(initial)'} → ${result.flags}`) + this.lastFlags = result.flags + } + this.emit('message', result) + } + + private _acquireSync(data: Buffer): Buffer { + for (let i = 0; i < data.length; i++) { + if (data[i] === STX) return data.subarray(i) + } + return Buffer.alloc(0) + } +} diff --git a/packages/hal/src/validators/apex/denominations.ts b/packages/hal/src/validators/apex/denominations.ts new file mode 100644 index 0000000..9874da4 --- /dev/null +++ b/packages/hal/src/validators/apex/denominations.ts @@ -0,0 +1,30 @@ +/** + * Pyramid Apex Denomination Tables + * + * The Apex RS-232 reply reports a 1-based CREDIT CHANNEL (1..7), not a value — + * the channel→value mapping is fixed by the bill dataset programmed into the + * acceptor's firmware for its country. The arrays below are ascending value + * lists indexed by (channel - 1); they MUST match the dataset flashed on your + * specific Apex 7600, or a credited note will be booked at the wrong value. + * Verify against the unit's configuration card during bring-up. + * + * Defaults follow Pyramid's standard datasets (US = $1/$5/$10/$20/$50/$100; + * $2 channel omitted as it's rarely enabled). + */ + +export const denominations: Record = { + USD: [1, 5, 10, 20, 50, 100], + EUR: [5, 10, 20, 50, 100, 200, 500], + GBP: [5, 10, 20, 50], + CAD: [5, 10, 20, 50, 100], + AUD: [5, 10, 20, 50, 100], + MXN: [20, 50, 100, 200, 500], + GTQ: [1, 5, 10, 20, 50, 100, 200], +} + +/** Resolve a 1-based credit channel to a fiat value, or null if unmapped. */ +export function denomForChannel(fiatCode: string | null, channel: number): number | null { + if (!fiatCode || channel < 1) return null + const table = denominations[fiatCode] + return table?.[channel - 1] ?? null +} diff --git a/packages/hal/src/validators/apex/index.ts b/packages/hal/src/validators/apex/index.ts new file mode 100644 index 0000000..463ee06 --- /dev/null +++ b/packages/hal/src/validators/apex/index.ts @@ -0,0 +1,155 @@ +/** + * Pyramid Apex Bill Validator Driver + * + * Supports Pyramid Technologies Apex-series acceptors (Apex 5000 / 7000 / 7600) + * on their RS-232 interface. Set the acceptor to RS-232 mode via its DIP / + * configuration card. + * + * Protocol: RS-232, 9600 baud, 7 data bits, even parity, 1 stop bit. + * + * Written from Pyramid's public RS-232 protocol facts (see apex-rs232.ts) — + * not ported from any licensed source. + */ + +import { EventEmitter } from 'node:events' +import { throttle } from 'lodash-es' +import { ApexRs232 } from './apex-rs232.js' +import { ApexFsm } from './apex-fsm.js' +import { denominations as denominationsTable, denomForChannel } from './denominations.js' +import type { BillValidator, ValidatorConfig, BillData } from '../../types.js' + +// Apex is host-polled; match the id003/ebds 100ms cadence so we observe escrow +// well inside the acceptor's grace window. +const POLLING_INTERVAL = 100 + +// All 7 credit channels enabled. Per-channel gating is handled upstream by the +// state machine (via lowest/highestBill), so the driver enables the full mask +// and relies on the acceptor's dataset for which notes exist. +const ALL_CHANNELS = 0x7f + +interface BNLike { + lte: (n: number) => boolean + gte: (n: number) => boolean + toNumber: () => number +} + +function BN(n: number): BNLike { + return { lte: (o: number) => n <= o, gte: (o: number) => n >= o, toNumber: () => n } +} + +export class ApexValidator extends EventEmitter implements BillValidator { + private config: ValidatorConfig + private fiatCode: string | null = null + private rs232: ApexRs232 | null = null + private fsm: ApexFsm | null = null + private poller: ReturnType | null = null + private _throttledError: (err: Error) => void + + constructor(config: ValidatorConfig) { + super() + this.config = config + this._throttledError = throttle((err: Error) => this.emit('error', err), 2000) + } + + static factory(config: ValidatorConfig): ApexValidator { + return new ApexValidator(config) + } + + setFiatCode(fiatCode: string): void { + this.fiatCode = fiatCode + } + + // Apex has no host-controllable insertion light. + lightOn(): void {} + lightOff(): void {} + + run(cb: (err?: Error) => void): void { + this.fsm = ApexFsm.factory() + this.rs232 = ApexRs232.factory({ device: this.config.rs232.device }) + this.rs232.setDenomResolver((channel) => denomForChannel(this.fiatCode, channel)) + + this.rs232.on('message', (result) => this.fsm?.process(result)) + this.rs232.on('error', (err: Error) => this._throttledError(err)) + this.rs232.on('badFrame', () => this.rs232?.poll()) + this.rs232.on('disconnected', () => this.emit('disconnected')) + + this.fsm.on('billsAccepted', () => this.emit('billsAccepted')) + this.fsm.on('billsRead', (bill: { denomination: number | null; code: string }) => { + if (!bill.denomination) { + console.log('[APEX] Bill rejected: unsupported/unmapped channel') + this.rs232?.reject() + return + } + const billData: BillData = { denomination: bill.denomination, code: 0 } + this.emit('billsRead', billData) + }) + this.fsm.on('billsValid', () => this.emit('billsValid')) + this.fsm.on('billsRejected', () => this.emit('billsRejected')) + this.fsm.on('accepting', () => this.emit('accepting')) + this.fsm.on('stacking', () => this.emit('stacking')) + this.fsm.on('returning', () => this.emit('returning')) + this.fsm.on('stackerOpen', () => this.emit('stackerOpen')) + this.fsm.on('standby', () => this.emit('standby')) + this.fsm.on('error', (err: Error) => this.emit('error', err)) + + this.rs232.open((err) => { + if (err) return cb(err) + this.rs232!.reset() // start disabled + this.poller = setInterval(() => this.rs232?.poll(), POLLING_INTERVAL) + cb() + }) + } + + close(cb: (err?: Error) => void): void { + if (this.poller) { + clearInterval(this.poller) + this.poller = null + } + this.rs232?.close((err) => cb(err ?? undefined)) + } + + enable(): void { + if (!this.rs232) return + this.rs232.setEnabledDenominations(ALL_CHANNELS) + this.rs232.poll() + } + + disable(): void { + if (!this.rs232) return + this.rs232.setEnabledDenominations(0x00) + this.rs232.poll() + } + + stack(): void { + this.rs232?.stack() + } + + reject(): void { + this.rs232?.reject() + } + + lowestBill(fiat: BNLike): BNLike { + const bills = this._denominations() + if (!bills) return BN(0) + const filtered = bills.filter((b) => fiat.lte(b)) + if (filtered.length === 0) return BN(Math.min(...bills)) + return BN(Math.min(...filtered)) + } + + highestBill(fiat: BNLike): BNLike { + const bills = this._denominations() + if (!bills) return BN(-Infinity) + const filtered = bills.filter((b) => fiat.gte(b)) + if (filtered.length === 0) return BN(-Infinity) + return BN(Math.max(...filtered)) + } + + hasDenominations(): boolean { + return this._denominations() !== null + } + + private _denominations(): number[] | null { + if (!this.fiatCode) return null + return denominationsTable[this.fiatCode] ?? null + } +} diff --git a/packages/hal/src/validators/index.ts b/packages/hal/src/validators/index.ts index a56be61..7b868a8 100644 --- a/packages/hal/src/validators/index.ts +++ b/packages/hal/src/validators/index.ts @@ -6,17 +6,19 @@ export { Id003 } from './id003/index.js' export { EbdsValidator } from './ebds/index.js' +export { ApexValidator } from './apex/index.js' export type { ValidatorConfig, BillValidator, BillData } from '../types.js' import { Id003 } from './id003/index.js' import { EbdsValidator } from './ebds/index.js' +import { ApexValidator } from './apex/index.js' import type { ValidatorConfig, BillValidator } from '../types.js' -export type ValidatorType = 'id003' | 'ebds' +export type ValidatorType = 'id003' | 'ebds' | 'apex' /** * Create a bill validator instance - * @param type Validator type (e.g., 'id003', 'ebds') + * @param type Validator type (e.g., 'id003', 'ebds', 'apex') * @param config Validator configuration */ export function createValidator(type: ValidatorType, config: ValidatorConfig): BillValidator { @@ -25,6 +27,8 @@ export function createValidator(type: ValidatorType, config: ValidatorConfig): B return Id003.factory(config) case 'ebds': return EbdsValidator.factory(config) + case 'apex': + return ApexValidator.factory(config) default: throw new Error(`Unknown validator type: ${type}`) } -- 2.55.0 From a77bae50eeb39867fe747f8694deaf01a80799c6 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 16 Aug 2026 21:30:53 +0200 Subject: [PATCH 2/4] feat(deploy): add aarch64 Raspberry Pi 5 target (sd-image) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proper aarch64 NixOS target for the DIY Pi 5 build, wired additively so the x86 fleet path is untouched (both the new Pi sd-image and the existing sintra-installed still evaluate cleanly): - nixos-hardware input (raspberry-pi-5 module) for Pi kernel/firmware/GPU. - aarch64 pkgs + pkgs-unstable + mkAtmApp instances (parallel to x86). - mkPiConfig: aarch64 nixosSystem reusing the shared configuration.nix + bitspire-atm service, replicating the installed-config runtime (bitspire service, first-boot env seed, electron service override, swap). Drops the x86 fleet machinery for a first bring-up: no determinate/autoUpgrade (not yet fleet-managed) and no atm-tui (needs an aarch64 package). - raspberry-pi-5.nix hardware module: extlinux boot, vc4/v3d KMS for X, primary UART free for a GPIO-wired validator, no-suspend, and stable /dev/ttyValidator* udev symlinks for USB-serial validator adapters (Apex 7600 RS-232 via adapter, NV10 USB+). - nixosConfigurations.rpi5-installed + packages.aarch64-linux.sd-image-rpi5. BUILD NOTE: the app closure (aarch64 electron/native addons) needs an aarch64 builder — a native Pi/arm box or `boot.binfmt` qemu emulation on an x86 host. Config evaluates on x86; it just can't build there. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW --- deploy/nixos/hardware/raspberry-pi-5.nix | 63 ++++++++++++ flake.lock | 34 ++++++- flake.nix | 122 ++++++++++++++++++++++- 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 deploy/nixos/hardware/raspberry-pi-5.nix diff --git a/deploy/nixos/hardware/raspberry-pi-5.nix b/deploy/nixos/hardware/raspberry-pi-5.nix new file mode 100644 index 0000000..6e56ff1 --- /dev/null +++ b/deploy/nixos/hardware/raspberry-pi-5.nix @@ -0,0 +1,63 @@ +# Raspberry Pi 5 hardware module (aarch64). +# +# The bitSpire equivalent of upboard.nix, but for a Pi 5 instead of the x86 +# UP Board. Kernel, firmware, GPU and bootloader come from the nixos-hardware +# `raspberry-pi-5` module (added alongside this one in flake.nix); here we set +# only the bitSpire-specific hardware glue: serial for the bill validators, +# the kiosk display driver, and no-suspend. +# +# Wiring the parts (see docs) to a Pi 5: +# - Bill validators (Apex 7600 RS-232, NV10 USB+): easiest via one USB-serial +# adapter each → stable /dev/ttyValidator* symlinks below. A GPIO-UART wire +# is also supported (primary UART enabled, console kept off it). +# - QR scanner: USB HID, no config. +# - 7" touchscreen: DSI or HDMI; the vc4/v3d KMS driver (from nixos-hardware) +# backs X. +# - Boot/root: USB-SATA SSD or the SD card. +{ config, lib, pkgs, ... }: + +{ + # aarch64 target. (The flake instantiates this config with aarch64 pkgs; this + # line documents/asserts it.) + nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux"; + + # Bootloader: the aarch64 sd-image uses the extlinux-compatible generator; + # nixos-hardware's rpi5 module wires the firmware/u-boot. No systemd-boot + # (that's x86/UEFI, as on the UP Board). + boot.loader.grub.enable = lib.mkDefault false; + boot.loader.generic-extlinux-compatible.enable = lib.mkDefault true; + + # Primary UART (GPIO 14/15) available for a GPIO-wired validator. Keep the + # serial console OFF it so the validator owns the line — mirrors upboard.nix + # keeping ttyS4 free for the dispenser. USB-serial adapters are unaffected. + boot.kernelParams = lib.mkDefault [ "console=tty0" ]; + + # X uses the Pi GPU's kernel modesetting driver (vc4/v3d KMS from + # nixos-hardware). Electron renders through it as on the UP Board. + services.xserver.videoDrivers = lib.mkDefault [ "modesetting" ]; + + hardware.enableRedistributableFirmware = true; + + # Kiosk: never sleep. + systemd.targets = { + sleep.enable = false; + suspend.enable = false; + hibernate.enable = false; + hybrid-sleep.enable = false; + }; + + # Stable device symlinks for USB-serial bill-validator adapters, so the ATM + # config can point at /dev/ttyValidator0 regardless of enumeration order. + # Covers the common bridges (FTDI, Silicon Labs CP210x, WCH CH340). If two + # adapters of the SAME chip are used, disambiguate by KERNELS/serial instead — + # tune during bring-up. The NV10 USB+ presents its own USB CDC serial; add its + # idVendor/idProduct here once known. + services.udev.extraRules = lib.mkAfter '' + # FTDI (e.g. FT232R) → ttyValidator0 + SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="ttyValidator0" + # Silicon Labs CP210x → ttyValidator1 + SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="ttyValidator1" + # WCH CH340 → ttyValidator2 + SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="ttyValidator2" + ''; +} diff --git a/flake.lock b/flake.lock index 7ea214a..27a1879 100644 --- a/flake.lock +++ b/flake.lock @@ -405,6 +405,24 @@ "type": "github" } }, + "nixos-hardware": { + "inputs": { + "nixpkgs": "nixpkgs_3" + }, + "locked": { + "lastModified": 1786867632, + "narHash": "sha256-ez+ubZlA1RtdjCB18a6zJ9M4u8qoPDy08EcnsW5M3Xw=", + "owner": "NixOS", + "repo": "nixos-hardware", + "rev": "ff17823245ab9ff7bcae6acf950bd89cba82c38c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixos-hardware", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1773222311, @@ -482,6 +500,19 @@ } }, "nixpkgs_3": { + "locked": { + "lastModified": 1767892417, + "narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=", + "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "type": "tarball", + "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz" + }, + "original": { + "type": "tarball", + "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" + } + }, + "nixpkgs_4": { "locked": { "lastModified": 1779467186, "narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=", @@ -503,7 +534,8 @@ "determinate": "determinate", "devenv": "devenv", "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs_3", + "nixos-hardware": "nixos-hardware", + "nixpkgs": "nixpkgs_4", "nixpkgs-unstable": "nixpkgs-unstable", "rust-overlay": "rust-overlay" } diff --git a/flake.nix b/flake.nix index 29f44b1..158dfac 100644 --- a/flake.nix +++ b/flake.nix @@ -36,9 +36,12 @@ url = "git+ssh://forgejo@git.atitlan.io/aiolabs/atm-tui.git"; inputs.nixpkgs.follows = "nixpkgs-unstable"; }; + + # Raspberry Pi 5 (aarch64) hardware support for the DIY Pi build. + nixos-hardware.url = "github:NixOS/nixos-hardware"; }; - outputs = { self, nixpkgs, nixpkgs-unstable, flake-utils, rust-overlay, devenv, determinate, atm-tui }: + outputs = { self, nixpkgs, nixpkgs-unstable, flake-utils, rust-overlay, devenv, determinate, atm-tui, nixos-hardware }: let system = "x86_64-linux"; @@ -59,6 +62,24 @@ src = self; }; + # aarch64 (Raspberry Pi 5) toolchain — a parallel set of pkgs + app + # builder for the DIY Pi build. Kept fully separate from the x86 fleet + # path so nothing above changes. + pkgsAarch64 = import nixpkgs { + system = "aarch64-linux"; + config.allowUnfree = true; + }; + pkgsUnstableAarch64 = import nixpkgs-unstable { + system = "aarch64-linux"; + config.allowUnfree = true; + overlays = [ (import rust-overlay) ]; + }; + mkAtmAppAarch64 = import ./nix/mkAtmApp.nix { + pkgs = pkgsAarch64; + pkgs-unstable = pkgsUnstableAarch64; + src = self; + }; + # Fiat code per machine model fiatCodeForModel = { douro = "GTQ"; @@ -261,6 +282,92 @@ }) ]; }; + + # Raspberry Pi 5 (aarch64) installed config — the DIY Pi build. Mirrors + # mkInstalledConfig's runtime (bitspire service, env activation, electron + # service override, swap) on aarch64 + Pi hardware, but deliberately drops + # the x86 fleet machinery for a first bring-up: no determinate/autoUpgrade + # (not yet a managed fleet member) and no atm-tui (add once it publishes an + # aarch64 package). Builds an SD image; needs an aarch64 builder (native + # Pi / arm box / binfmt emulation) — the app closure won't build on x86. + mkPiConfig = machineModel: + let + atm-app = mkAtmAppAarch64 { + model = machineModel; + fiatCode = fiatCodeForModel.${machineModel} or "USD"; + }; + fiatCode = fiatCodeForModel.${machineModel} or "USD"; + in + nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + specialArgs = { + pkgs-unstable = pkgsUnstableAarch64; + inherit atm-app; + }; + modules = [ + nixos-hardware.nixosModules.raspberry-pi-5 + (nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix") + ./deploy/nixos/configuration.nix + ./deploy/nixos/bitspire-atm.nix + ./deploy/nixos/hardware/raspberry-pi-5.nix + ({ config, lib, pkgs, pkgs-unstable, ... }: { + services.bitspire = { + enable = true; + appDir = "${atm-app}"; + }; + + environment.systemPackages = [ + (pkgs.writeShellScriptBin "fund-atm" '' + exec ${pkgs-unstable.nodejs}/bin/node ${atm-app}/dist-electron/fund-atm.bundle.cjs "$@" + '') + ]; + environment.variables.ATM_DB_PATH = "/var/lib/bitspire/state.db"; + + boot.kernel.sysctl."kernel.unprivileged_userns_clone" = 1; + security.sudo.wheelNeedsPassword = false; + + # Same first-boot env seed as the x86 installed configs. + system.activationScripts.bitspire-env = '' + mkdir -p /var/lib/bitspire + if [ ! -f /var/lib/bitspire/.env ]; then + cp ${pkgs.writeText "bitspire-env-default" ('' + VITE_LAMASSU_MACHINE_MODEL=${machineModel} + VITE_LAMASSU_FIAT_CODE=${fiatCode} + VITE_SPIRE_SEED= + ELECTRON_FORCE_PROD=1 + DISPLAY=:0 + '' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") '' + VITE_RELAY_URL=${config.services.bitspire.relayUrl} + '' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") '' + VITE_LNBITS_SERVER_PUBKEY=${config.services.bitspire.lnbitsServerPubkey} + '')} /var/lib/bitspire/.env + chmod 600 /var/lib/bitspire/.env + chown bitspire:bitspire /var/lib/bitspire/.env + fi + ''; + + # Electron runtime override (same flags as the x86 fleet, aarch64 + # electron). No eDP display-reset here — that's UP-Board-specific; + # the Pi drives HDMI/DSI directly. + systemd.services.bitspire.serviceConfig = { + EnvironmentFile = lib.mkForce "/var/lib/bitspire/.env"; + Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib"; + ExecStart = lib.mkForce "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-software-rasterizer --enable-logging ${atm-app}"; + MemoryMax = lib.mkForce "2G"; + NoNewPrivileges = lib.mkForce false; + ProtectSystem = lib.mkForce false; + ProtectHome = lib.mkForce false; + PrivateTmp = lib.mkForce false; + DevicePolicy = lib.mkForce "auto"; + DeviceAllow = lib.mkForce [ "char-* rw" ]; + }; + + swapDevices = [{ device = "/var/swapfile"; size = 2048; }]; + boot.tmp.cleanOnBoot = true; + services.openssh.settings.PasswordAuthentication = lib.mkForce true; + }) + ]; + }; in { # ── NixOS Configurations (top-level, not per-system) ────────── @@ -293,6 +400,10 @@ sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix; batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix; + # Raspberry Pi 5 (aarch64) DIY build — Apex 7600 / NV10 over USB-serial. + # Build the SD image via packages.aarch64-linux.sd-image-rpi5. + rpi5-installed = mkPiConfig "rpi5"; + # USB-bootable variant of batm3-installed. This is the config the # flashed USB stick actually runs — distinct fs labels so stage-1 can't # latch the internal drive, nofail /boot, no growPartition, autoUpgrade @@ -506,6 +617,15 @@ # Backwards compat iso = self.nixosConfigurations.douro.config.system.build.isoImage; }; + + # ── Packages (aarch64-linux — Raspberry Pi 5 build) ─────────── + # Flashable SD image for the Pi 5. Build on an aarch64 builder (native Pi + # / arm box / `boot.binfmt` emulation on this x86 host): + # nix build .#packages.aarch64-linux.sd-image-rpi5 + packages.aarch64-linux = { + sd-image-rpi5 = self.nixosConfigurations.rpi5-installed.config.system.build.sdImage; + atm-app-rpi5 = mkAtmAppAarch64 { model = "rpi5"; fiatCode = "USD"; }; + }; } // # ── Dev shells (per-system via flake-utils) ─────────────────── -- 2.55.0 From d8680f67ae6596a9f8dc2b2a4e5f519b7433b765 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 17 Aug 2026 08:41:52 +0200 Subject: [PATCH 3/4] feat(deploy): split rpi5 target into installed + image variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rpi5-installed` previously bundled the sd-image-aarch64 module, so it was only good for building a flashable image — a `nixos-rebuild switch` against it (local or the git+ssh remote form) would drag in the image builder and its image-specific fs wiring. Split it, mirroring the x86 fleet's mkLiveConfig/mkInstalledConfig separation: - rpi5-installed → in-place rebuild target. Declares the flashed media's own root fs (NIXOS_SD / FIRMWARE labels), nothing image-specific. This is what sudo nixos-rebuild switch --flake \ "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=#rpi5-installed" targets, the aarch64 equivalent of the sintra/douro deploy ritual. - rpi5-image → same shared runtime + the sd-image builder. Its system.build.sdImage is the flashable artifact; packages.aarch64-linux .sd-image-rpi5 now points here. Shared runtime extracted into piBaseModules/mkPiRuntime; folded the aiolabs cachix substituter + trusted key into the Pi's nix.settings so a remote rebuild substitutes the heavy aarch64 closure instead of building it on the Pi (no max-jobs/timeout watchdog — the Pi 5 can build locally if it must). Verified: rpi5-installed evaluates to a valid system toplevel (root fs present), rpi5-image/sd-image-rpi5 to the .img.zst builder, and x86 sintra-installed is byte-identically unaffected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SGUJJjBDuYwRaWSkFdK3bm --- flake.nix | 225 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 146 insertions(+), 79 deletions(-) diff --git a/flake.nix b/flake.nix index 158dfac..705fd4c 100644 --- a/flake.nix +++ b/flake.nix @@ -283,89 +283,151 @@ ]; }; - # Raspberry Pi 5 (aarch64) installed config — the DIY Pi build. Mirrors - # mkInstalledConfig's runtime (bitspire service, env activation, electron - # service override, swap) on aarch64 + Pi hardware, but deliberately drops - # the x86 fleet machinery for a first bring-up: no determinate/autoUpgrade - # (not yet a managed fleet member) and no atm-tui (add once it publishes an - # aarch64 package). Builds an SD image; needs an aarch64 builder (native - # Pi / arm box / binfmt emulation) — the app closure won't build on x86. - mkPiConfig = machineModel: - let - atm-app = mkAtmAppAarch64 { - model = machineModel; - fiatCode = fiatCodeForModel.${machineModel} or "USD"; - }; + # Raspberry Pi 5 (aarch64) DIY build. Two products from one shared runtime: + # - mkPiInstalled: the in-place rebuild target. `nixos-rebuild switch + # --flake .#rpi5-installed` (or the git+ssh remote form) targets this. + # Declares the flashed media's own root fs (NIXOS_SD / FIRMWARE) and + # NOTHING image-specific, so a switch on a running Pi never trips over + # the sd-image builder. + # - mkPiImage: the same runtime + the aarch64 sd-image module, whose + # system.build.sdImage is the flashable artifact. The module supplies + # its OWN NIXOS_SD/FIRMWARE fileSystems + u-boot firmware, so we must + # not re-declare the root fs here (double definition = eval conflict). + # + # The runtime mirrors mkInstalledConfig (bitspire service, env activation, + # electron override, cache substituters) on aarch64 + Pi hardware, but + # deliberately drops the x86 fleet machinery for first bring-up: no + # determinate, no atm-tui (add once it ships an aarch64 package). Any Pi + # build needs an aarch64 builder (native Pi / arm box / binfmt emulation) — + # the app closure won't build on x86. + mkPiRuntime = machineModel: { + atm-app = mkAtmAppAarch64 { + model = machineModel; fiatCode = fiatCodeForModel.${machineModel} or "USD"; - in + }; + fiatCode = fiatCodeForModel.${machineModel} or "USD"; + }; + + # Shared module list (everything EXCEPT the root fs and the sd-image + # builder). atm-app/fiatCode are threaded in so both products share one + # evaluated app closure. + piBaseModules = { machineModel, atm-app, fiatCode }: [ + nixos-hardware.nixosModules.raspberry-pi-5 + ./deploy/nixos/configuration.nix + ./deploy/nixos/bitspire-atm.nix + ./deploy/nixos/hardware/raspberry-pi-5.nix + ({ config, lib, pkgs, pkgs-unstable, ... }: { + services.bitspire = { + enable = true; + appDir = "${atm-app}"; + }; + + environment.systemPackages = [ + (pkgs.writeShellScriptBin "fund-atm" '' + exec ${pkgs-unstable.nodejs}/bin/node ${atm-app}/dist-electron/fund-atm.bundle.cjs "$@" + '') + ]; + environment.variables.ATM_DB_PATH = "/var/lib/bitspire/state.db"; + + boot.kernel.sysctl."kernel.unprivileged_userns_clone" = 1; + security.sudo.wheelNeedsPassword = false; + + # Pull from the aiolabs binary cache so a `nixos-rebuild switch` + # (local or the git+ssh remote form) substitutes the heavy aarch64 + # closure instead of compiling on the Pi. Mirrors the x86 fleet's + # nix.settings, minus max-jobs/timeout — the Pi 5 can actually build + # locally if it must, so we don't want the 60s watchdog killing a + # legitimate first build. + nix.settings = { + trusted-users = [ "root" "bitspire" ]; + substituters = [ "https://cache.nixos.org" "https://aiolabs.cachix.org" ]; + trusted-public-keys = [ + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + "aiolabs.cachix.org-1:PrAjsGU9PE77tFKP2+iO+mgR88c4xv3utM9JmpTblUQ=" + ]; + }; + + # Same first-boot env seed as the x86 installed configs. + system.activationScripts.bitspire-env = '' + mkdir -p /var/lib/bitspire + if [ ! -f /var/lib/bitspire/.env ]; then + cp ${pkgs.writeText "bitspire-env-default" ('' + VITE_LAMASSU_MACHINE_MODEL=${machineModel} + VITE_LAMASSU_FIAT_CODE=${fiatCode} + VITE_SPIRE_SEED= + ELECTRON_FORCE_PROD=1 + DISPLAY=:0 + '' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") '' + VITE_RELAY_URL=${config.services.bitspire.relayUrl} + '' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") '' + VITE_LNBITS_SERVER_PUBKEY=${config.services.bitspire.lnbitsServerPubkey} + '')} /var/lib/bitspire/.env + chmod 600 /var/lib/bitspire/.env + chown bitspire:bitspire /var/lib/bitspire/.env + fi + ''; + + # Electron runtime override (same flags as the x86 fleet, aarch64 + # electron). No eDP display-reset here — that's UP-Board-specific; + # the Pi drives HDMI/DSI directly. + systemd.services.bitspire.serviceConfig = { + EnvironmentFile = lib.mkForce "/var/lib/bitspire/.env"; + Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib"; + ExecStart = lib.mkForce "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-software-rasterizer --enable-logging ${atm-app}"; + MemoryMax = lib.mkForce "2G"; + NoNewPrivileges = lib.mkForce false; + ProtectSystem = lib.mkForce false; + ProtectHome = lib.mkForce false; + PrivateTmp = lib.mkForce false; + DevicePolicy = lib.mkForce "auto"; + DeviceAllow = lib.mkForce [ "char-* rw" ]; + }; + + swapDevices = [{ device = "/var/swapfile"; size = 2048; }]; + boot.tmp.cleanOnBoot = true; + services.openssh.settings.PasswordAuthentication = lib.mkForce true; + }) + ]; + + # In-place rebuild target — declares the flashed media's own filesystems + # (the labels mkPiImage's sd-image module writes), no image builder. + mkPiInstalled = machineModel: + let rt = mkPiRuntime machineModel; in nixpkgs.lib.nixosSystem { system = "aarch64-linux"; specialArgs = { pkgs-unstable = pkgsUnstableAarch64; - inherit atm-app; + inherit (rt) atm-app; }; - modules = [ - nixos-hardware.nixosModules.raspberry-pi-5 + modules = (piBaseModules { inherit machineModel; inherit (rt) atm-app fiatCode; }) ++ [ + { + fileSystems."/" = { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + }; + fileSystems."/boot/firmware" = { + device = "/dev/disk/by-label/FIRMWARE"; + fsType = "vfat"; + options = [ "nofail" "noauto" ]; + }; + } + ]; + }; + + # Flashable SD/USB image — same runtime + the aarch64 sd-image builder, + # which brings its own NIXOS_SD/FIRMWARE fileSystems and the u-boot + # firmware. Its system.build.sdImage is exposed as + # packages.aarch64-linux.sd-image-rpi5. + mkPiImage = machineModel: + let rt = mkPiRuntime machineModel; in + nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + specialArgs = { + pkgs-unstable = pkgsUnstableAarch64; + inherit (rt) atm-app; + }; + modules = (piBaseModules { inherit machineModel; inherit (rt) atm-app fiatCode; }) ++ [ (nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix") - ./deploy/nixos/configuration.nix - ./deploy/nixos/bitspire-atm.nix - ./deploy/nixos/hardware/raspberry-pi-5.nix - ({ config, lib, pkgs, pkgs-unstable, ... }: { - services.bitspire = { - enable = true; - appDir = "${atm-app}"; - }; - - environment.systemPackages = [ - (pkgs.writeShellScriptBin "fund-atm" '' - exec ${pkgs-unstable.nodejs}/bin/node ${atm-app}/dist-electron/fund-atm.bundle.cjs "$@" - '') - ]; - environment.variables.ATM_DB_PATH = "/var/lib/bitspire/state.db"; - - boot.kernel.sysctl."kernel.unprivileged_userns_clone" = 1; - security.sudo.wheelNeedsPassword = false; - - # Same first-boot env seed as the x86 installed configs. - system.activationScripts.bitspire-env = '' - mkdir -p /var/lib/bitspire - if [ ! -f /var/lib/bitspire/.env ]; then - cp ${pkgs.writeText "bitspire-env-default" ('' - VITE_LAMASSU_MACHINE_MODEL=${machineModel} - VITE_LAMASSU_FIAT_CODE=${fiatCode} - VITE_SPIRE_SEED= - ELECTRON_FORCE_PROD=1 - DISPLAY=:0 - '' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") '' - VITE_RELAY_URL=${config.services.bitspire.relayUrl} - '' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") '' - VITE_LNBITS_SERVER_PUBKEY=${config.services.bitspire.lnbitsServerPubkey} - '')} /var/lib/bitspire/.env - chmod 600 /var/lib/bitspire/.env - chown bitspire:bitspire /var/lib/bitspire/.env - fi - ''; - - # Electron runtime override (same flags as the x86 fleet, aarch64 - # electron). No eDP display-reset here — that's UP-Board-specific; - # the Pi drives HDMI/DSI directly. - systemd.services.bitspire.serviceConfig = { - EnvironmentFile = lib.mkForce "/var/lib/bitspire/.env"; - Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib"; - ExecStart = lib.mkForce "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-software-rasterizer --enable-logging ${atm-app}"; - MemoryMax = lib.mkForce "2G"; - NoNewPrivileges = lib.mkForce false; - ProtectSystem = lib.mkForce false; - ProtectHome = lib.mkForce false; - PrivateTmp = lib.mkForce false; - DevicePolicy = lib.mkForce "auto"; - DeviceAllow = lib.mkForce [ "char-* rw" ]; - }; - - swapDevices = [{ device = "/var/swapfile"; size = 2048; }]; - boot.tmp.cleanOnBoot = true; - services.openssh.settings.PasswordAuthentication = lib.mkForce true; - }) ]; }; in @@ -401,8 +463,13 @@ batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix; # Raspberry Pi 5 (aarch64) DIY build — Apex 7600 / NV10 over USB-serial. - # Build the SD image via packages.aarch64-linux.sd-image-rpi5. - rpi5-installed = mkPiConfig "rpi5"; + # rpi5-installed → in-place rebuild target: + # sudo nixos-rebuild switch --flake \ + # "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=#rpi5-installed" + # rpi5-image → source of the flashable image + # (packages.aarch64-linux.sd-image-rpi5). + rpi5-installed = mkPiInstalled "rpi5"; + rpi5-image = mkPiImage "rpi5"; # USB-bootable variant of batm3-installed. This is the config the # flashed USB stick actually runs — distinct fs labels so stage-1 can't @@ -623,7 +690,7 @@ # / arm box / `boot.binfmt` emulation on this x86 host): # nix build .#packages.aarch64-linux.sd-image-rpi5 packages.aarch64-linux = { - sd-image-rpi5 = self.nixosConfigurations.rpi5-installed.config.system.build.sdImage; + sd-image-rpi5 = self.nixosConfigurations.rpi5-image.config.system.build.sdImage; atm-app-rpi5 = mkAtmAppAarch64 { model = "rpi5"; fiatCode = "USD"; }; }; } -- 2.55.0 From 082f738ffa4ac49104fe051f3c5339f4af95351f Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 20 Sep 2026 11:25:43 +0200 Subject: [PATCH 4/4] fix(deploy): console=tty0 was being dropped on the Pi 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit raspberry-pi-5.nix set `boot.kernelParams = lib.mkDefault [ "console=tty0" ]` to keep the serial console off the GPIO UART so a validator can own it. It never took effect: kernelParams is list-merged, and only definitions at the highest priority survive — nixpkgs defines loglevel/lsm at normal priority, so the mkDefault list was discarded wholesale. Effective params on rpi5-installed were `[ "loglevel=4" "lsm=landlock,yama,bpf" ]`, no console= at all, which makes the kernel fall back to the device tree's stdout-path: that same UART. Drop the mkDefault so the entry merges. Verified by evaluating config.boot.kernelParams before/after. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4 --- deploy/nixos/hardware/raspberry-pi-5.nix | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/deploy/nixos/hardware/raspberry-pi-5.nix b/deploy/nixos/hardware/raspberry-pi-5.nix index 6e56ff1..f0f4558 100644 --- a/deploy/nixos/hardware/raspberry-pi-5.nix +++ b/deploy/nixos/hardware/raspberry-pi-5.nix @@ -30,7 +30,12 @@ # Primary UART (GPIO 14/15) available for a GPIO-wired validator. Keep the # serial console OFF it so the validator owns the line — mirrors upboard.nix # keeping ttyS4 free for the dispenser. USB-serial adapters are unaffected. - boot.kernelParams = lib.mkDefault [ "console=tty0" ]; + # + # Not mkDefault: kernelParams is list-merged, and only definitions at the + # highest priority survive. nixpkgs sets loglevel/lsm at normal priority, so + # a mkDefault list here is dropped entirely — and with no console= at all + # the kernel falls back to the device tree's stdout-path, i.e. this UART. + boot.kernelParams = [ "console=tty0" ]; # X uses the Pi GPU's kernel modesetting driver (vc4/v3d KMS from # nixos-hardware). Electron renders through it as on the UP Board. -- 2.55.0