Snack Cart POS
Why an ESP32-S3
I picked the S3 because I wanted headroom — for driving the screen and for having enough processing power left over that I wouldn’t be fighting the chip the whole build. WiFi was non-negotiable too; I wanted an interface where the owner could log in, verify payments went through, and adjust prices and inventory without touching the device itself. The S3 is probably overkill in some areas, but at this scale the cost delta between “just enough” and “plenty of headroom” is a couple of dollars, so it was an easy call.
For the screen I just picked a cheap, big display. 3.5“ ended up being the sweet spot — it’s not an exceptionally crisp panel, but it does the job well for a few dollars.
Touch → buttons

I originally planned to drive the whole interface off the touchscreen. It was calibrated, wired, and working on the breadboard — and it felt bad. Tapping a resistive touchscreen is sluggish and imprecise in a way that’s fine in a demo and grating on the fiftieth snack of the week. For something meant to get used dozens of times a day by people grabbing food on their way past, “fine in a demo” wasn’t the bar.
So I ripped the touch driver out and replaced it with six physical tactile buttons — up, down, left, right, enter, back — and color-coded them so each one’s job is roughly readable at a glance. Every screen registers its own idea of what those six buttons do for whatever it’s showing. More wiring and more code, but it feels like an appliance now instead of a fussy toy.

The database moves to an SD card
The database — users, items, transaction history — first lived on the ESP32’s internal flash through LittleFS, with SQLite running in WAL (write-ahead log) mode because that’s the fast one. It worked right up until it didn’t: random crashes and hangs on write, unpredictable enough that I couldn’t trust a single thing it told me.
The mismatch is that WAL keeps a shared-memory index and a separate log file alongside the database and leans on file-locking behaviour that a small flash filesystem like LittleFS doesn’t really provide. It’s SQLite’s fastest journal mode assuming things about the filesystem underneath it that just aren’t true here.
The fix was two parts. Move the database onto a real SD card — a properly supported filesystem with room to spare, and since the display module already had an SD slot on the back, wiring it in was basically free. And drop WAL entirely in favour of the plain rollback journal: slower per write, but the whole device gets unplugged and carted around, so crash-safety mattered more than write speed. That move also forced a full rethink of the schema.
One row per scan was the wrong shape
The obvious first schema was one row per item scanned. Simple, and wrong the moment you try to reconcile payments against it. Venmo settles a whole checkout at once, never a single item — and two unrelated checkouts by the same person can land on the exact same dollar total on different days, so “match the payment to the transaction by amount” is ambiguous by design, not just in the odd edge case.
The fix was to group by checkout instead of by scan: one row per “Finish” press with its own transaction number, and a separate table for the individual items inside it. Nothing ever gets deleted, so “lifetime total spent” is just a SUM() over every checkout that person has ever made — correct by construction, instead of a running counter that can quietly drift out of step with reality.
The 16MB flash boot loop
Turning on the full 16MB flash size and a custom partition table sent the board into an immediate boot loop after every flash. The board definition silently assumes 8MB unless you tell it otherwise, so the upload tool was writing an 8MB-shaped bootloader next to a partition table whose offsets only make sense at 16MB — the two disagreed about where everything lived, and the bootloader refused to run.
The build was told about the 16MB; the upload step wasn’t. Setting the flash size explicitly on both, and then switching from my hand-rolled partition table to one of the built-in 16MB presets — which ship pre-paired with a bootloader that already matches — turned out to be far more reliable than getting the custom one exactly right.
Months of silence on the serial port
For a long stretch the firmware provably worked — screen lit, buttons responded, flashing succeeded every time — with zero serial output, ever. That’s a maddening bug to chase, because everything about it points at the monitor or the tooling rather than the code.
The cause: this board’s single USB port is wired straight to the ESP32-S3’s built-in USB-Serial peripheral, not through a separate USB-to-serial bridge chip like most dev boards use. A build flag that’s correct advice for boards with a bridge chip — ARDUINO_USB_CDC_ON_BOOT=0, which frees up the hardware UART pins — was actively wrong here. It was pointing Serial at a pair of physical pins with nothing connected to them. One flag, flipped, and months of silence ended.
Making the buttons feel right
Debounce and repeat timing for the physical buttons went through several rounds of me guessing a “reasonable” number and it still feeling like a double-tap machine. The thing that actually fixed it wasn’t a better guess — it was wiring up temporary logging to print real press and release timestamps over serial, then pressing the buttons like a normal person and measuring how long that actually takes. A deliberate press holds noticeably longer than the usual assumptions bake in, which is exactly why every value I’d tried still registered one press as two. Tuned against the real numbers, the buttons went from cheap-feeling to snappy.
The checkout screen, finally
For a long time the actual point-of-sale screen — the one thing the whole device exists to do — was a stub. Building it out revealed it wasn’t really four separate screens, it was one state machine: scan items onto a running receipt, then branch off into a logout/cancel confirmation, a manual add for anything without a barcode, or payment. Nothing touches the database until the cashier hits “Complete + Logout” — walk away or cancel before that and the whole cart just evaporates, on purpose, so a cancelled transaction never leaves a row that needs cleaning up later.
The payment screen shows a Venmo-scannable QR code with the total baked into the URL. Getting a QR on screen meant turning on LVGL’s QR widget, which turned out to quietly depend on its canvas widget too — a one-line addition, once the linker told me so.
My first pass at “add an item without scanning it” was a numeric keypad — key in a price digit by digit, like an old cash register’s price override. It compiled clean and was dead within the same session, before it ever touched real hardware. The actual need was simpler: just pick the item off the same catalog list the price-check screen already shows. Typing a price by hand didn’t buy anything a list picker doesn’t already do better, and it meant maintaining a second, parallel notion of “what does this line item even mean” in the database just to prop up a screen that was the wrong shape for the job. That same redesign killed another piece of scaffolding — a button that would “simulate a scan” by cycling through the catalog, back when the real scanner wasn’t wired. Once manual entry became a real item picker, it already was that. No reason to keep two fake-input paths when one of them does real work.
The other real lesson came from just watching someone else try to use it. The person actually running this cart day to day isn’t going to reverse-engineer a button layout from spatial intuition. The fix wasn’t smarter icons — it was saying it outright: a two-line legend at the bottom of the screen, which direction deletes an item, which direction looks one up, and “Pay”/“Cancel” rendered in the actual green and red the physical buttons are. The delete key also stopped silently removing whatever got scanned last and started moving a visible cursor through the list, so “delete” always means the row you’re looking at, not some invisible notion of “most recent.” Small changes, bigger deal than anything upstream of them once someone who isn’t the person who built the thing is standing at the cart.
The screen looked “off” the moment it lit up for real

Every colour in this build was picked and checked on a laptop, against a design mockup, months before any of it touched the actual display. The first time it came up on real hardware, something was subtly wrong: the “black” background read as blue-black, the green looked more like pastel mint than money, and the red for “cancel” was closer to salmon than a stop sign.
Both problems had the same root cause — the colours were never actually neutral or saturated to begin with, they’d just never been looked at outside a code editor. The “black” was #1E1E2E, which genuinely has more blue in it than red or green; invisible as three hex pairs, obvious as a wall of icons on a real panel. The green and red were both a flavour of Material Design accent colour — light, low-saturation, meant for subtle UI trim — doing a job that wanted bold and unambiguous. Pay is green, cancel is red, no room for “that’s more of a salmon, I think.” Swapped the background to genuinely neutral grey and the green/red to a plain saturated traffic-light pair. Every colour decision after that got made the same way: build it, then actually look at it before calling it done.
What the first real database write turned up
Everything up to this point had been tested by reading the code and trusting the logic. The moment real hardware started writing to a real SQLite file on a real SD card, two bugs showed up that no amount of code review would have caught — because the code was correct, just not correct for this specific SQLite build.
The first was a phantom file. Every time the database rebuilt itself, a small leftover journal file stuck around afterward — the kind that’s supposed to vanish the instant a write finishes cleanly. “Something crashed mid-write” was the obvious read, except it kept happening after totally clean boots with correct data on the other end. Pulling the card and looking at the file’s raw bytes settled it: the header was all zeros, which is exactly what SQLite leaves on purpose in one of its less-common journal modes — a reusable, permanently inert placeholder, not a crash artifact. Switching to that mode explicitly turned an alarming mystery into a non-issue.
The second was quieter and meaner: a whole category of writes was silently doing nothing. A step meant to add a couple of catalog items and a payment handle reported success every time, and the data just never appeared. Running the exact same SQL against the exact same file — once on the device, once on a laptop — was the tell: identical text, one worked, one didn’t. The device’s SQLite library predates “insert this, or update it if it already exists” as a single statement, which has been standard everywhere else for years. The fix wasn’t clever, just older-fashioned: check whether the row exists, then insert or update as two boring separate steps. The lesson that stuck: when something that touches storage “does nothing” instead of visibly failing, stop debugging the code and go look at what’s actually on the card.
Keeping real names out of the firmware
Once real people had to exist in this system — the cart owner, badge numbers, a Venmo handle — the easy path was typing their names straight into the source. It’s a personal project, there’s no team, it would’ve worked immediately.
It’s also the wrong call, and worth spelling out why even at this scale. A name and a badge number baked into firmware ships inside every compiled binary, sits in whatever history the project accumulates, and comes back every single time the device gets reflashed, regardless of what’s changed in the real world since. The database already lives entirely on the SD card, so provisioning people into it lives there too: a plain text file the firmware reads once at boot, one line per person, that never touches the source. Onboarding someone later is editing that file and rebooting. The only names in the actual codebase are fake ones, kept there to document the file’s format.
The QR code Venmo’s own app wouldn’t scan

The first real end-to-end payment test hit a genuinely funny snag: the payment screen’s QR code, scanned with Venmo’s own built-in scanner, wasn’t recognized at all. The underlying link was completely correct — a real, working Venmo payment URL with the right handle and amount — which made the failure more confusing, not less.
Turned out Venmo’s in-app scanner is only built to recognize another Venmo user’s own QR code — the one generated from their profile page — not just any QR that happens to contain a valid venmo.com link. A plain phone camera doesn’t have that restriction: it reads any QR as a link and hands it to whatever app claims that URL, which for venmo.com is Venmo itself. Scanned with the regular camera app instead of opening Venmo first, and it worked immediately — straight into a pre-filled payment screen, correct amount, correct recipient. Added a small on-screen reminder under the QR code so nobody else has to rediscover that the hard way.

Wiring the scanner, and a better use for the USB port
Getting the barcode scanner electrically hooked up should have been the easy part — the display and buttons were long since solved, this was four more wires. The plan was two separate 5V inputs: one for the board’s own USB power and flashing, one for the scanner. Reconsidering it turned up an unrelated but genuinely useful trick: the microcontroller’s native USB data pins are broken out on this board too, so routing those out to an external connector instead of a second power feed lets one USB cable do double duty — power the whole device and still reflash it later without ever opening the enclosure. One fewer wire run, one fewer thing to work loose inside a finished case.
The scanner itself came up clean on the first real test: a scanned badge correctly reported “not enrolled.” That’s exactly the failure you want to see first — it proved the whole chain worked, scan to decode to lookup to answer, before a single real user touched it.
Reverse-engineering the scanner one wrong guess at a time

The scanner speaks its own small binary protocol over the same wires it uses to report a decoded badge, and there was no usable manual to start from — the first copy I found was a scan of a printed page with no real text under it, useless to search. So the starting point was someone else’s open-source Arduino library: close enough to get real settings changing, wrong often enough to be a hazard.
The buzzer was the first tell. Turning it on and off worked — backwards. The setting the library’s own comment called “on” was silent; “off” was the one that beeped. Trusting the sound over the label fixed that fast. A bigger mystery took longer: a setting would read back correctly right after I changed it, then quietly revert to factory default sometime later with no obvious trigger. A firmware reflash looked like it preserved settings — until leaving the whole cart genuinely unplugged for a couple of hours proved otherwise. Resetting the microcontroller doesn’t cut power to the scanner sitting next to it, so a reflash had been lying to me about what a real power loss would do.
The breakthrough came from an odd place: a tiny QR code silkscreened onto the scanner’s own circuit board, printed there for the factory’s tracking, not for anyone downstream. It didn’t scan into anything useful by itself, but it pointed at the real manufacturer — whose actual manual, once tracked down and read properly instead of guessed at, explained everything the borrowed library had backwards or missing, including a “save to permanent memory” command the library never implemented at all. Every scanner setting since gets changed with bytes verified against that real manual.
The scanner light that fired at everyone walking past
Once the scanner was wired it had an obvious new problem: it’s always watching, which means its illumination light and aiming laser fire at anyone walking past the cart while it’s just sitting idle. The instinct was a small hardware switch to physically cut the scanner’s power during the screensaver — more wiring, another component, one more thing to fail.
Before reaching for the soldering iron, a documented “deep sleep” command looked like the same result in pure software. It seemed to work, right up until the obvious hands-on test — waving a hand in front of the module while it was supposedly asleep — snapped the light straight back on. It wasn’t sleeping at all, just doing its normal “nothing nearby” idling with a more promising name.
The real fix was simpler than either: one of the settings I was already using to configure the scanner also controls whether it’s actively watching for anything at all. Flip that off when the screensaver starts, back on when it wakes, and the light stops completely — confirmed with the same hand-in-front-of-it test that had just debunked the sleep command. No new parts, nothing to solder.
While I was in there, one more scanner bug: an early listening test had compared the factory-default beep against a few alternates and the default seemed to win. It was wrong. The setting reader I used only checked one register at a time, and it looked identical whether the beep was actually working or not — the real difference was in a register it never read. Widening the diagnostic to pull a whole block at once surfaced it immediately: the “default beep” wasn’t a beep, it was a bare click I’d mistaken for a quiet tone. Locked in the setting that actually beeps, and kept the wider reader around.
Making the admin menu safe to walk away from
Scanning an admin’s badge used to jump straight into the admin tools, every time, automatically — which sounds convenient until you notice an admin could then never just scan their badge to buy a snack. The device always read that badge as “I’m here to do admin work,” with no way to say otherwise. Fixed by making admin access its own deliberate step: press a button, then scan. The pleasant side effect was that a plain scan now sends anyone, admin or not, straight to their own cart — exactly the behaviour that had been missing the whole time without anyone noticing.
Walking away from the admin screen also actually logs out now. It never really had a way to do that before, because until there were real badges to test with, there’d never been a reason to leave any other way.
Giving the screensaver fish somewhere to swim

The screensaver has a little fish that “dances” — it flips between facing left and facing right on a fixed sixteen-beat pattern, which reads as endearingly awkward, like it can’t decide which way to go. The catch was that it did all of this rooted to one spot in the middle of the screen. The dance was charming; the stillness underneath it wasn’t.
The fix kept the dance and layered a wander on top. Every beat the fish flips a coin on nudging a few pixels forward — “forward” meaning whichever way it’s facing — and separately rolls to drift up, hold, or drift down, all inside an invisible rectangle set a comfortable margin in from the edges. Pressed against a wall, a “step forward” roll just does nothing that beat rather than shoving the fish out of frame.
Testing turned up one oddity: work the fish into the bottom-right corner and a thin grey bar appeared along the bottom of the screen. It was a scrollbar. The fish trails bubbles from its nose, and down in that corner a bubble would spawn a pixel or two past the screen boundary — and the graphics library, seeing content just off-screen, helpfully added a scrollbar so you could reach it. Nothing there was ever meant to scroll, so the fix was to say so, once, when the screen is built.
The admin screens grow into real sections

With the machine working end to end, most of the rough edges left were on the admin-facing screens — the ones the cart owner uses to add products, restock, and manage the catalog. None of it was broken, it just hadn’t been sanded down.
The lists were first. Every list of products showed items in whatever order they got entered into the database, which is fine at ten items and useless at a hundred. They sort alphabetically now, on the device and in the web portal. The list of people sorts by first name rather than last — the owner knows every nurse on the unit personally and thinks of them by first name, so that’s the name to sort by. Scanning an unknown barcode used to drop you on a little fork-in-the-road screen with no way to back out and a “cancel”-coloured button that didn’t cancel; that screen is gone, and an unknown barcode now takes you straight to the catalog list to attach it or build a new item, with red meaning back, everywhere, no exceptions. And three or four screens had each grown their own slightly-different way of drawing the button legend along the bottom — those all collapsed into one shared piece, so they finally match.
Then the menu itself outgrew a flat list. Editing a user needed its own screen, the web portal needed an on-device home, clock-setting needed somewhere to live, marking balances paid was overdue. Bolting all that onto one list meant either an endless scroll or shrinking the text back to unreadable — the trap the lists had just climbed out of. So it became a shorter list of categories instead: Balances, Inventory, Users, Web Portal, Settings, Advanced Tools — the first two of those just folders. One extra button press to reach any single screen, in exchange for a menu that reads at a glance.
Editing a user came with a snag worth remembering. The plan was one screen to edit a person’s name, balance, admin flag, and locked status. Three of those are real stored fields. Balance isn’t — a person’s balance has never been a number in a column, it’s the sum of whatever transactions haven’t been marked paid yet. Editing it directly would mean either a second, parallel “balance” that can drift out of sync with the transaction history, or a whole adjustment-ledger mechanism to justify it. Neither is worth it here. Balance shows on the screen, calculated fresh every time, and stays what it’s always been: something you clear a transaction at a time, not a number you type over.
Turning the WiFi radio off until it’s needed

The web portal — the browser-based admin dashboard — had been broadcasting its own WiFi network continuously since boot, whether or not anyone was using it. Harmless on a workbench, but not something that should ship: a network sitting there all day with a weak placeholder password is exactly the kind of thing that’s easy to forget and easy to poke at.
The fix was nearly free. The ESP32’s radio stays off until something in the code explicitly asks for it, and the only thing asking was a single line at startup. Moving that line out of startup and into “the admin just opened the Web Portal screen” — with its mirror image on leaving — was the whole change. The portal now gets its own landing screen on the device: the network name and password to join, and under them a QR code for the page itself, so connecting is a scan-and-scan instead of typing on a phone keyboard. Walk away and the radio goes silent again within moments. In practice the delay in it coming back up lands under the time it already takes a phone to open its WiFi settings, so it doesn’t feel like a wait.
A stopgap clock

I considered skipping a hardware clock entirely, then thought about the owner going weeks between admin logins with the device losing power somewhere in there, and decided timestamp accuracy actually matters here. I grabbed a DS3231 I had on hand — which turned out to be the wide-body SOP16 package, and I didn’t have an adapter for it and didn’t want to spin a custom board for a couple of chips. So it’s on a SOP14 adapter with a couple of the no-connect pins overhanging the end. Ugly, works.
That’s as far as it’s got. The pins are reserved and the chip is soldered to its adapter, but the wiring itself is queued behind other work. Until it lands, every timestamp falls back to counting seconds since the device last powered on, which keeps events in order but is useless for reading an actual date off the screen.
A manual “Set Clock” screen closes that gap: dial in the year, month, day, hour, and minute by hand, confirm, and every timestamp reads correctly from there. It’s built on the same underlying mechanism the real chip will use once it’s wired, so nothing about how the rest of the device reads “the current time” has to change later — only what sets it. The one thing it can’t do, by design: without a battery behind it, the clock forgets everything the moment the device loses power. It’s a bridge for testing today, not a substitute for the real thing.
Where it stands now

The full loop — scan or select items, review the running total, pay with a QR code that deep-links straight into Venmo — has been run start to finish on real hardware, with a real payment that actually landed. Real people are enrolled, the owner and me, both with working badges, neither hardcoded. The web admin shows real data pulled from a completed purchase instead of an honest “$0.00, nothing’s happened yet.”
- Barcode scanner — fully wired, settings locked against a real manufacturer manual, confirmed reading real badges and products. It goes quiet on its own during the screensaver and comes back cleanly on wake, no physical power switch needed.
- Inventory tracks itself — every sale quietly decrements the on-hand count for that item, so restocking doesn’t mean physically counting the cart.
- Users can be paused — turned away at the cart with a polite message, without deleting them or touching their history, for cases like settling an outstanding balance first. That toggle, plus editing a name and admin status, is reachable from the device now, not just the web portal.
- Admin is a deliberate step — a real sectioned menu instead of the old everything-in-one-list dev scaffolding, a separate login before admin tools are reachable at all, and a start screen with its own colour-coded controls.
- WiFi only broadcasts while its screen is open — confirmed clean on real hardware, on and back off, repeatedly.
- A manual clock screen fills in for the not-yet-wired RTC chip.
Still open:
- Real-time clock wiring — chip is on its adapter; pull-ups, decoupling cap, and battery wiring still need finishing, plus the software side (sync on boot, NTP correction).
- Web admin auth — currently gated only by the WiFi AP password, no login yet.
- Untested on hardware — the restock and inventory-count tools, first-time web portal login, the new user-editing screen, and marking a balance paid from the device. All written and compiling, none put through their paces. Balance-clearing specifically is waiting on pulling the SD card to seed it with example transactions.
- Physical enclosure — the plywood + PETG build hasn’t started, and there’s a final pass to do on exactly how loud the beep needs to be once it’s sealed inside one.
More of the day-to-day detail — pin assignments, gotchas, the full schema — lives alongside the project’s source code for anyone who wants to dig in.