NixOS activation scripts run with a minimal PATH that has coreutils but
not gnugrep or gnused. On sintra the snippet printed its success line and
then failed with "sed: command not found" (127) — the keys were never
renamed, the new app booted on fallback config, and switch-to-
configuration exited 2. Both binaries are now referenced by store path.
MACHINE_MODEL, FIAT_CODE, VALIDATOR_DEVICE, DISPENSER_DEVICE and CASSETTES
carried the old brand in their names. Renamed everywhere they are read
(device.ts, electron/main.ts), written (flake.nix, mkAtmApp.nix, live.nix,
provision-atm.sh, factory-reset-atm.sh) and documented (.env.example,
docs/device-configuration.md). No compatibility fallback in code: the
machine reads VITE_BITSPIRE_* and nothing else.
The deployed .env files are the one place the old names persist — sintra's
/var/lib/bitspire/.env holds all three keys today — and the machine reads
MACHINE_MODEL / FIAT_CODE / CASSETTES from that file on every boot. Renaming
the keys in code alone would boot a live machine on preset defaults (wrong
bays, wrong fiat) at the next nightly pull. So configuration.nix gains an
activation script, beside the existing lamassu→bitspire user migration,
that rewrites VITE_LAMASSU_* → VITE_BITSPIRE_* in that file. Idempotent;
runs before bitspire.service starts.
The cassettes table is a running total, so it can be re-derived: an
absolute truth point (a recount, or an empty) plus the refills and
dispenses since. A derived count that disagrees with the stored one is
evidence of something the ledger never saw.
Reconciliation deliberately refuses to start from a refill. A refill is
a delta, and applying deltas on top of a wrong number just carries the
error forward — which is how sintra's 20-EUR bay ran 10 notes high for
weeks while its 50-EUR bay, zeroed by an `empty` before refilling,
reconciled exactly. A bay with no baseline is reported as
unreconcilable rather than silently assumed good.
Also surfaces the two things that make a count untrustworthy: the
counts-uncertain flag, and any transaction still sitting in
dispense_error / partial.
The SQL uses scalar subqueries rather than joins on purpose — joining
transaction_bills to cassettes fans out across bays, and a LEFT JOIN
whose rows are all excluded by the baseline cutoff collapses to NULL
and poisons the arithmetic downstream (the first draft read "expected:
blank" for exactly that reason).
Exits non-zero on any gap or missing baseline so it can be run as a
check after a test session.
Refs #122
nixpkgs builds Mesa with 21 gallium drivers, the full Vulkan stack and the
VDPAU and VA state trackers, so one binary can serve every GPU and
cross-build case. This fleet is four Intel boards and a kiosk that never
asks for Vulkan.
mesa closure 974.5 -> 88.5 MiB
sintra 3940 -> 3201 MB tejo 3940 -> 3201 MB
batm3 3918 -> 3179 MB douro 3894 -> 3156 MB
Keep crocus, i915 and softpipe. softpipe earns its place: it is the
software rasterizer that does NOT use LLVM, so a board whose KMS driver
fails still brings up X slowly rather than dying headless somewhere
nobody can reach it.
IRIS IS OUT, and that is what makes the rest of this possible. Mesa's
meson puts with_gallium_iris in with_driver_using_cl and then
with_llvm.enable_if(with_clc, error_message : 'CLC requires LLVM')
so asking for iris drags in the OpenCL frontend and with it 540MB of
llvm-lib, and -Dllvm=disabled fails at configure. Nothing here needs iris.
The fleet was surveyed rather than assumed: sintra and tejo are Braswell
[8086:22b0], batm3 is Haswell GT2 [8086:0412], douro is Bay Trail. sintra
and batm3 were read off their running X logs and both say crocus.
That survey corrected an assumption an earlier draft of this commit was
built on. It claimed the UP Boards were the iris machines and crocus was
only for douro and batm3. sintra's Braswell is Gen8 and binds crocus
anyway. Had the list been trimmed to iris on that reasoning, which looked
like the tidier option, sintra would have dropped to software rendering or
lost its display outright.
With iris gone, LLVM goes: verified with patchelf, libgallium.so has no
libLLVM in its DT_NEEDED, not merely absent from the closure listing.
Dropping llvmpipe alone never achieved that.
THE COST IS FUTURE HARDWARE. A newer x86 board — a modern NUC, the "build
it from these parts" kiosk — will need iris, and re-adding it re-adds the
540MB. Until then such a board falls back to softpipe and renders in
software: it boots, it displays, it looks fine, and it is very slow. The
driver list carries that warning. Check `DRI driver:` in /var/log/X.0.log
on any new hardware rather than trusting the list still covers it.
Five secondary failures on the way here, each now a comment where it bites.
Two are nixpkgs' meson hook forcing auto_features=enabled, which turns
Mesa's soft driver guards into hard errors, so gallium-vdpau and gallium-va
must be disabled explicitly once the AMD and NVIDIA drivers are gone. One
is that outputs lists spirv2dxil and cross_tools unconditionally while only
d3d12, asahi and panfrost populate them; nix fails a build that leaves a
declared output unproduced, so they are created empty — and mesa sets
__structuredAttrs, so $outputs is a bash array and the obvious
`for o in $outputs` loop silently does nothing. The last two are the
asahi/panfrost cross tools and install-mesa-clc, which reference
prog_mesa_clc and so must go with LLVM.
Tested on sintra at the previous revision (with iris, 3744MB): X restarted
onto the pruned Mesa, glamor reported hardware acceleration on crocus, no
errors. This revision removes iris and LLVM and has NOT been on hardware
yet. douro and batm3 want their own nixos-rebuild test regardless; batm3
runs a different kernel and douro is the only Bay Trail.
hardware.enableRedistributableFirmware installed the entire linux-firmware
tree: 752MB compressed, 16% of the image and its single largest component.
The fleet is four fixed Intel boards. The rest is firmware for Qualcomm,
Mellanox, NVIDIA, Marvell, AMD and MediaTek parts that will never be in
one of these machines.
Keep i915 for the GPU, intel/iwlwifi, rtl_nic, rtw88, rtw89 and brcm for
whatever NIC a given box turns out to have. Turning the option off also
drops the extras it bundles (sof-firmware, libreelec-dvb, alsa-firmware,
intel2200BG, zd1211fw), none of which applies to a soundless kiosk on a
wired Intel board. The regulatory database is normally implied by that
same option so it is now requested explicitly; without it WiFi is pinned
to the most restrictive channel set.
Intel WiFi is 89MB and most of what survives. That is the deliberately
conservative half of the trade: losing the network on a fielded ATM is
not recoverable remotely, and 89MB is cheap next to a site visit.
sintra 4604 -> 3940 MB tejo 4604 -> 3940 MB
batm3 4582 -> 3918 MB douro 4544 -> 3894 MB
VERIFIED ON HARDWARE. sintra was switched to this and rebooted. It came
back with ethernet up (r8169, RTL8168g), the kiosk running, and no
firmware load failures. Before the reboot, for every module these boards
use, the firmware the kernel declares was confirmed present: i915 44 of
44, r8169 23 of 23, r8152 7 of 7. iwlwifi declares 67 and 28 are absent,
but all 28 are absent from the full upstream tree too, so the module
simply names more files than linux-firmware ships.
The reboot is what earned the intel/fw_sst_* entries. The first boot
after pruning logged
intel_sst_acpi: Direct firmware load for intel/fw_sst_22a8.bin failed
with error -2
the Intel Smart Sound DSP that Cherry Trail boards probe at startup. The
audio stack is already gone so nothing was functionally broken, but a
recurring error in a payment terminal's boot log is worth 420KB to
remove: an error people learn to ignore is one they will ignore when it
matters. No static check would have found this — the firmware a driver
requests at probe time is not what modinfo reports.
Two traps found while building it, both carrying comments where they bite:
The symlink loop originally ended in `[ -e ... ] && ln ...`, which makes
the loop's exit status depend on whether the LAST candidate matched. A
non-match returns 1 and set -e fails the build, so whether it worked was
a function of readdir order. It passed standalone and failed once spliced
in.
Kept directories contain symlinks pointing outside themselves: brcm's
blobs are links into cypress/. Left dangling they fail nixpkgs'
compression step, and deleting them would silently drop firmware a device
needs, so the targets get pulled in instead and anything still dangling
is a hard error.
The tree is left uncompressed because NixOS compresses each
hardware.firmware entry itself, zstd or xz depending on the kernel.
Confirmed: sintra gets -zstd, douro's 5.15 gets -xz.
system.forbiddenDependenciesRegexes rejects the upstream package by its
versioned name, so a nixpkgs bump or a stray module re-enabling the
option fails the build instead of quietly putting 750MB back.
Every entry here ships to each ATM and eats the eMMC headroom the
nightly nixos-rebuild needs, which is tight enough already that GC runs
at 03:30 purely to clear room for the 04:00 upgrade.
Out: git, at 70MB, since nixos-rebuild fetches the flake with its own
git-minimal that unit-nixos-upgrade.service keeps in the closure, so
auto-upgrade is unaffected. nodejs_22, at 94MB, which nothing runs: the
app is Electron and embeds its own node, and fund-atm references
pkgs-unstable.nodejs by store path. wget, which curl covers. And vim,
replaced by nano.
Keeping an editor at all is deliberate. Field edits to
/var/lib/bitspire/.env happen over ssh, and nano costs a few MB where
vim costs 43. minicom and screen stay for the same reason: the validator
and dispenser sit on ttyJ5 and ttyJ7, those two are how a serial fault
gets diagnosed, and they cost about 2MB between them.
With the two preceding commits the sintra-installed closure goes from
5144MB to 4604MB across 131 fewer store paths.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The block enabling pipewire was commented "for transaction sounds", but
no such sounds exist: nothing under apps/machine or packages/ constructs
an Audio element or ships an audio file. It has been dead weight for as
long as it has been there.
Removing our own `enable = true` is not enough. services.xserver pulls
in NixOS's graphical-desktop module, which mkDefault-enables pipewire
exactly as it does speechd, so the stack survived the first attempt at
this. That is why the line sits beside the speechd mkForce rather than
where the old block was.
Most of PipeWire's dependency chain is shared with the GStreamer that
Electron drags in, and that stays in the closure either way, so this
frees 21MB rather than the whole stack. The remainder comes out with
gtk4/gst, which wants a launch test on the sintra first.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#101 skipped wireguard-wg0 when no key is provisioned, but the module
emits one unit per peer alongside it, and a condition-skipped unit is
not a failed dependency — so the peer unit still ran and died on
'Unable to modify interface: No such device'. Same exit 4 from
switch-to-configuration, different unit, so the nightly auto-upgrade is
still marked failed on an unprovisioned machine (seen on sintra today).
Guard the peers on the same key. Unit names come from the module's own
peers.*.name option rather than re-deriving its escaping here, with the
-refresh suffix following nixpkgs' peerUnitServiceName (a peer's null
interval falls back to the interface's). Verified by evaluation that
every wireguard-* unit in the installed config now carries the
condition, that each is a real unit with an ExecStart, and that the live
image — which mkForce's the interfaces away — still gets none.
Refs #98
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
wg0.key is written per machine after flashing. Until it is, the unit's
`wg set … private-key` exits 1 with 'fopen: No such file or directory',
and one failed unit makes switch-to-configuration exit 4 — which marks
the whole nightly system.autoUpgrade run as failed even though the new
generation applied. sintra has reported a broken updater on that basis
alone; its tunnel was never provisioned and wg0 has never existed.
Skip the unit when there is no key rather than failing activation over
an interface that was never set up. A provisioned machine is unaffected.
Guarded on wg0 still being declared so the live image, which mkForce's
the interfaces away, doesn't inherit a unit with no ExecStart.
Refs #98
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
system.autoUpgrade fetches the flake over ssh as root. A machine whose
root has never connected by hand has no known_hosts entry, so the run
dies at 'Host key verification failed' before it even reaches
authentication. batm3 did exactly that, silently, from its 2026-08-06
install until 09-22: six weeks on its install generation while a unit
nobody was watching reported failure every night. sintra only ever
worked because a human had ssh'd as root once and accepted the key.
Declaring the key means a freshly flashed ATM updates from first boot
with no manual step. Verified against the key sintra's root already
trusts.
Refs #98
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The disk image was ~6.2 GiB of closure, largely desktop/multimedia baggage a
single-purpose Electron kiosk never uses. Cut the clearly-unused stacks:
- services.speechd off → drops speech-dispatcher's espeak-ng + mbrola voices
(~1 GB text-to-speech). An ATM does not talk.
- v4l-utils built withGUI=false → drops the entire Qt6 stack (~0.5 GB) that only
backed the qv4l2 GUI; the v4l2-ctl CLI we actually use for the camera stays.
- documentation off (man/info/NixOS manual) — nobody reads them on a kiosk.
Closure 6.2 → 5.0 GiB. The remaining bulk is electron's own runtime (gtk4/
gstreamer/pipewire, unavoidable), mesa+llvm (GPU), and linux-firmware — those
need heavier / riskier work to touch. Distribute the image as .img.zst.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Was: weekly, no persistence. Problem: 15GB eMMC + ~7GB closure means
cross-release upgrades are always tight. Weekly cadence stacked up to
a week of generations under the 04:00 auto-upgrade. Persistent=false
also meant a Sintra powered off at 03:30 skipped GC until next week.
Now runs daily at 03:30, 30 min before the 04:00 nixos-upgrade so each
upgrade attempt gets the freshest headroom. 7d retention unchanged.
This is a periodic-cleanup fix only — cross-release in-place upgrades
on 15GB eMMC will still hit the disk-full wall (caught 2026-05-26 on
24.05 → 24.11). Reflash remains the answer there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
System-level half of the lamassu → bitspire rebrand the rest of dev
already did at the path / service / package layers. Touches user/group
declarations, every systemd `User=` block, the udev rules filename, all
chown calls in flake.nix + live.nix, the displayManager autoLogin user,
the trusted-users nix entry, provision-atm.sh's ATM_USER, plus README +
CLAUDE.md doc references.
In-place migration for the Sintra dev unit (which auto-pulls dev at
04:00) lives in `system.activationScripts.bitspire-user-migration` and:
- copies `/home/lamassu/.ssh/authorized_keys` → `/home/bitspire/` once,
so SSH access survives the rename
- recursively chowns `/var/lib/bitspire` to the new bitspire UID on
every boot — cheap no-op once done, but covers the case where the
data dir was written by the now-removed lamassu UID
- leaves `/home/lamassu/` in place as evidence; operator can `rm -rf`
after confirming bitspire login works
Recovery path if the migration breaks SSH access: root key is still in
configuration.nix:142-144 (padreug@gizmo), so ssh root@<host> works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five-file coordinated rename to give the dev-branch deploy a clean
`bitspire` namespace at the NixOS level:
deploy/nixos/lamassu-atm.nix → deploy/nixos/bitspire-atm.nix
- `services.lamassu-atm` → `services.bitspire`
- `systemd.services.lamassu-atm` → `systemd.services.bitspire`
- `/var/lib/lamassu-atm` → `/var/lib/bitspire`
- `/opt/lamassu-atm` → `/opt/bitspire`
- `/etc/lamassu-atm/config.env` → `/etc/bitspire/config.env`
flake.nix
- module import path updated
- `nixosModules.lamassu-atm` → `nixosModules.bitspire`
- `system.activationScripts.lamassu-env` → `bitspire-env`
- all activation-script paths point at /var/lib/bitspire
deploy/nixos/configuration.nix
- `networking.hostName = "lamassu-atm"` → `"bitspire"`
deploy/nixos/live.nix
- module import path updated
- ISO name template: `lamassu-atm-<model>-live.iso` → `bitspire-<model>-live.iso`
- activation-script name updated
deploy/nixos/provision-atm.sh
- data-dir paths: /var/lib/lamassu-atm → /var/lib/bitspire
- systemctl + journalctl unit names updated
DELIBERATELY kept as `lamassu` (for now):
- The `lamassu` UNIX user and group account. Renaming would
require file-ownership migration scripts; the Sintra is a
fresh flash so no existing data, but the internal user
namespace inconsistency is acceptable.
- LP-specific bits in provision-atm.sh (admin token, the
`docker logs lamassu-lightning-pub` extractor) — those
get ripped out in 3c when the script switches to LNbits.
NO migration activation script added — the Sintra flash is fresh,
production batm3/douro stay on `main` and never see this branch.
A future dev→main cutover will need a separate migration story
(rename UNIX user, move /var/lib/lamassu-atm → /var/lib/bitspire,
SSH key relocation, etc.).
Verified:
nix eval .#nixosConfigurations.batm3-installed.config.systemd.services.bitspire.enable
→ true
nix eval .#nixosConfigurations.bitSpire-live-sintra.config.networking.hostName
→ "bitspire"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move WireGuard IP from shared configuration.nix to per-machine
hardware configs. douro = 10.0.0.4, batm3 = 10.0.0.5.
Previously both machines shared 10.0.0.4 which caused routing
conflicts on the VPS.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Allow key-only root login (PermitRootLogin prohibit-password) and add
authorized SSH key for remote administration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Configures wg0 interface (10.0.0.4/24) to VPS at 170.75.161.21:51820
for remote SSH access. Opens UDP 51820 in firewall and adds activation
script to ensure key directory permissions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cassette inventory was never initialized in SQLite, so
recordTransaction's UPDATE decrements were no-ops against an empty
table. Now the Electron main process seeds cassettes from
VITE_LAMASSU_CASSETTES or the model preset on first boot.
Also adds an atm-transactions CLI script (with --summary, --inventory,
--type, --today, --last, --since filters) and sqlite to the NixOS
system packages.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The option was renamed from services.xserver.displayManager.autoLogin
to services.displayManager.autoLogin.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add NixOS configuration to build a bootable live USB ISO that runs the
ATM Electron app in kiosk mode on physical hardware (UpBoard). The ISO
boots from squashfs, auto-starts X11/openbox, and launches Electron in
production mode.
Key changes:
- deploy/nixos/live.nix: Live USB module (squashfs+tmpfs, no disk install)
- deploy/nixos/flake.nix: Nix flake with ISO build output
- deploy/nixos/provision-atm.sh: Auto-provision LP credentials via API
- deploy/nixos/build-iso.sh: End-to-end build workflow script
- apps/machine: Fix Electron production mode (ELECTRON_FORCE_PROD),
Vue Router hash mode for file:// protocol, relative asset paths
Build: cd deploy/nixos && bash build-iso.sh
Test: qemu-system-x86_64 -enable-kvm -m 2G -cdrom result/iso/*.iso
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>