Three protocol corrections from Pyramid's spec (RS_232 Rev G), all of which
this driver had guessed at because it was written clean-room without it.
**Escrow was never enabled.** BYTE 1 bit 4 is an enable, and the driver left
it clear on every poll. With it clear the acceptor does not stop at escrow,
so the host is never offered the stack-or-return decision and notes are
banked before anything has validated them. The entire FSM here is built
around that decision point, so this was not a missing nicety — the driver's
central flow could not have happened. It is now asserted on every poll.
**Return used the wrong mechanism.** The driver expressed "give the note
back" by zeroing the enable mask mid-escrow, under an in-code assumption
that "Apex has no distinct return opcode". It has one: BYTE 1 bit 6. The old
approach was flagged in a comment as needing hardware verification; the spec
settles it instead. Note the spec also distinguishes Returning (host refused
a valid note) from Rejected (acceptor judged it invalid), which is the
distinction this bit exists to express.
**The checksum range was hardcoded to the host frame.** computeChecksum
always XORed bytes 1..5, which is right for the 8-byte poll and wrong for
the 11-byte reply, where it should span 1..8. So every reply failed
validation. That was masked by the check being non-fatal "pending hardware
verification", which logged a warning and parsed anyway.
The range is now derived from the frame length, and verified against the two
reset frames the spec spells out literally with their checksums — the only
ground truth available without hardware. Those same frames are now a test.
With the range confirmed, a mismatch becomes a hard drop rather than a
warning. A corrupt frame carries a denomination field, and crediting a note
from a frame known to be damaged is the one outcome worth refusing. The raw
bytes are logged so a systematic framing error stays diagnosable.
Also records two operational facts from the spec that were not written down:
the interface is Mars/MEI GL5-compatible (hence the resemblance to the EBDS
driver), and polls must not fall more than 5s apart or the acceptor may dump
an escrowed note and stop accepting until the host resumes. Our 100ms cadence
is comfortably inside that.
The credit channel is a fixed protocol constant, not an index into whichever
notes a given unit has enabled. Pyramid's RS-232 spec (Rev G, BYTE 2 bits
3-5) fixes it:
001=$1 010=$2 011=$5 100=$10 101=$20 110=$50 111=$100
This table omitted $2, with a comment calling it "rarely enabled". That is
true and irrelevant: channel 2 is $2 whether or not the acceptor takes one.
Dropping it shifted every larger note down a slot, so the machine would have
credited:
$5 as $10
$10 as $20
$20 as $50
$50 as $100
$100 as nothing at all (channel 7 ran off the end and read as unmapped,
which the driver treats as an invalid note and returns)
Every error is in the customer's favour and none of them is visible — the
value never appears on the wire, only the channel, so there is nothing to
reconcile against. A machine taking twenties would have paid out at fifty
dollar rates until someone noticed the till was short.
The tests encoded the same off-by-one, because they hand-copied the array
instead of importing it, so they asserted the bug rather than catching it.
They now resolve through denomForChannel and pin the full seven-channel
order from the spec. Reverting the table alone fails three of them.
Non-USD is unverified. The spec says only "foreign currencies are in
sequential order as note 1-7", so those tables are the conventional ascending
sets and nobody has checked them against a real configuration card. Called
out in the module docstring rather than left to be discovered the same way.
The Apex returned every bill inserted. Cause is not wiring or DIP
switches: the driver had no fiat code, so no credit channel could resolve
to a value.
ApexValidator initialises `fiatCode` to null and only assigns it in
setFiatCode(). NOTHING in this repo calls setFiatCode() — not
hal-service, not the store, nothing. It is declared on the BillValidator
interface and implemented three times, and it is dead code.
So the resolver installed in run():
this.rs232.setDenomResolver((ch) => denomForChannel(this.fiatCode, ch))
is always called with null, and denomForChannel bails on its first line
(`if (!fiatCode || channel < 1) return null`). Every note is read,
resolves to null denomination, hits the `!bill.denomination` branch, and
is handed straight back.
id003 is unaffected because it never relies on the field: run() threads
`config.fiatCode` into the rs232 config, so the value reaches the layer
that needs it regardless. Apex and EBDS both read `this.fiatCode`
instead, so both are broken the same way. EBDS is fixed here too — it has
the identical dead-field dependency in _denominations() and would fail
identically the first time it met hardware.
Both constructors now seed from `config.fiatCode ?? config.rs232.fiatCode`,
which is what the callers have been passing all along. setFiatCode() stays
as a later override rather than the only path in.
Also makes the failure loud, because the old log line is what sent us
looking at the acceptor instead of the driver. "Bill rejected:
unsupported/unmapped channel" reads as a dataset/hardware mismatch and
gives no hint that the driver simply has no currency. It now names which
of the two causes it is, and run() logs an error up front when there is no
fiat code at all, since in that state every note is guaranteed to be
returned.
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
Cash-in stalled on the batm3: a note reached escrow and was read, but the
acceptor never stacked or returned it, and the customer was never
credited. Root cause: EBDS carries the stack/return decision as bits in
the omnibus *poll* command, but the driver sent stack()/reject() as a
single one-shot frame while a free-running 100ms poller kept sending
plain polls. The lone stack frame races/collides with the poller (or its
ack desyncs), gets dropped, and the device holds the note in escrow
indefinitely.
- ebds-rs232: latch the escrow decision (`pendingAction`) into the poll
command byte and re-assert it on every poll until the device leaves
escrow (cleared in _process when `!escrowed`). A dropped frame is now
simply retried on the next poll.
- hal-service: return an escrowed note on disableValidator() — disable
alone does not release it on EBDS, so an inactivity timeout / cancel
previously stranded the bill in the transport (observed on the batm3).
- atm store: stringify the `[ATM] Sending event` / `[ATM] State` logs —
they were printing `[object Object]`, which blinded the cash-in trace.
Verified: hal builds, machine app typechecks. Hardware behaviour to be
confirmed on the batm3 (no unit tests exist for this serial driver).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
If MEI reports `escrowed` AND `powerup` on the first message after
service start with no error flags (jammed/stalled/failure/
transportOpen/stackerFull), fire a reject to clear stale state
before the FSM emits spurious billsAccepted/billsRead upstream.
Guards exclude every scenario where reject's motor sequence could
do harm: physical jams (`jammed`/`stalled`/`failure`/`transportOpen`)
are left alone for hands-on diagnosis, and a real customer bill in
escrow with `stackerFull` is preserved rather than returned.
Diagnosed on Austin batm3 2026-06-01: unit had been stuck in this
state since 2026-05-25 (six days, three boots) requiring manual
intervention to clear. Self-heals now on next service restart.
Surface MEI SCR's full status-flag set in the journal — diagnostic
for stuck-bill / jam scenarios where the raw flag combination
(jammed, stalled, transportOpen, escrowed, etc.) reveals what state
the validator actually believes it's in. Dedup'd on change so 100ms
polling doesn't flood logs; firmware model + revision logged once.
Time each bill's stay in the `billsRead` (escrow) status. Anything that
exits escrow within ESCROW_WATCHDOG_WARN_MS=500ms gets logged at info
level; anything longer gets a warning naming the elapsed milliseconds.
500ms is 5× our 100ms poll cadence and well below MEI's ~5s grace
window. A warning means we're drifting toward the autonomous-return
failure mode that tears bills on the BATM3 — useful field signal for
verifying the poll-interval fix is sufficient under real load.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
parseStatus previously collapsed all in-motion bits into a single derived
status, hiding when the MEI was moving a bill between escrow and the
stacker/mouth. Field journals showed nothing in the window where the
BATM3 tore bills.
Surface the three transient bits between the terminal states and
escrow/standby, route them through EbdsFsm with a Date.now() timestamp,
and emit them as diagnostic events on the validator. No state machine
consumes them; they're for journalctl.
Also: warn when `returning` is observed straight out of escrow with no
host reject() — that signature is the autonomous-return failure mode we
just polled around. Surfacing it gives us field confirmation the 100ms
fix is sufficient (or evidence it isn't).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EBDS is a host-polled protocol: the MEI validator only speaks when
polled, and its internal escrow grace expires after ~5 seconds. With
POLLING_INTERVAL=10_000 the host learned about an escrowed bill *after*
the MEI had already autonomously begun returning it, which on BATM3
hardware can shear the bill between the transport rollers and the
mouth (see torn $20 reported by operator).
Drop the interval to 100ms to match id003's cadence — so we see
escrow and issue stack/reject inside the validator's grace window.
This is the production-critical fix; observability + escrow watchdog
follow in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port MEI CashFlow SC / BNR Advance EBDS protocol from lamassu-machine
to TypeScript HAL. Adds 'batm3' machine model preset (EBDS validator +
F56 dispenser). Fixes hardcoded 'id003' validator type in device config
overrides so model presets correctly propagate their validator type.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The ID003 FSM was stuck in the PowerUp state because leaving the
Connected state never emitted 'ready'. This is the trigger for the
denomination/reset initialization chain.
Added onLeaveState() method that emits 'ready' when leaving Connected,
matching the original lamassu-machine behavior (onleaveConnected).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Align dispenser error handling with lamassu-machine's proven approach:
close port on error, set error name, let caller decide when to re-init.
Also configure live ISO for Douro/GTQ with serialport native modules.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- App.vue detects Electron and calls initializeForProduction() to start
real hardware drivers instead of Lightning-only mode
- Auto-send CASH_DISPENSED when HAL is active since dispenseCash already
waits for bills to be removed before resolving
- Add GTQ (Guatemalan Quetzal) bill lengths to F56 and Puloon dispensers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Port the Puloon RS232 dispenser protocol from lamassu-machine to
the lamassu-next HAL package. Adds Douro machine preset with Puloon
dispenser on /dev/ttyS1 and ID003 validator on /dev/ttyS0.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add dev.sh script for managing regtest development environment
- Implement cmd_fund to fund ATM app owner via Lightning.Pub API
- Add --fund flag to cmd_up for automatic funding on startup
- Update setup_atm_app to write VITE_APP_ID to machine .env
- Fix Electron IPC to pass appId and extensionApiUrl to renderer
- Restructure repo from nested lamassu-next/ to root
The dev.sh script now supports:
- ./dev.sh up --fund # Start regtest and auto-fund ATM
- ./dev.sh fund # Fund existing ATM app
- ./dev.sh status # Show environment status
- ./dev.sh reset # Clean restart
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>