Limonchito working document, published as-is. Prices and lead times are estimates from September 2026 and change often.
Lemon firmware + backend — rev B
Status as of 2026-09-17. Scope: firmware/ (nRF Connect SDK / Zephyr app for the nRF9151) and
backend/ (the UDP relay + FOTA file server). Written from the current state of both trees after
finishing an interrupted rev B pass; nothing here was verified on real hardware or a real build
this session (no toolchain, no devices) -- every claim that depends on that is flagged below and
inline in the files themselves as HW-VERIFY / UNVERIFIED, matching this repo's existing
convention (see NETLIST.md, COMPLIANCE.md).
1. Architecture
UDP 5683 (app protocol, AES-128-CCM per device) HTTP 8080 (FOTA, plain)
Lemon A <----------------------------------------------------------> relay.py <---- fota/*.bin
(nRF9151, (1 VPS, (MCUboot-
LTE-M, similarly, Lemon B <-----------------------/ on the 1NCE signed
1NCE SIM) private VPN) images)
- Firmware (
firmware/src/): one Zephyr app, one event queue (K_MSGQ), one state machine inmain.c. Squeeze detection, haptics (DRV2605L), the missed-hug LED, the accelerometer (wake-on-motion), the network layer (LTE-M attach + the 20-byte encrypted datagram protocol), FOTA, and the factory/dev shells are separate translation units, wired together throughlemon.h's function table andstruct lemon_ev. - Backend (
backend/relay.py): one small Python process on a VPS with an OpenVPN client into the 1NCE private network. It is not a store-and-forward server with accounts -- it is a authenticate-and-forward relay plus a static file server for signed firmware images, plus a little bit of state (replay windows, pending-delivery retries, per-day squeeze counts, the pairing table). - No cloud, no database server, no TLS: trust comes from the AES-CCM tag per datagram and the
MCUboot signature per firmware image, not from the transport. This matches the "why UDP over the
1NCE VPN" reasoning already in
backend/README.md-- a session protocol would need keepalives, which cost radio time the eDRX-based design is built to avoid.
2. What's new in rev B (relative to the file layout before this pass)
firmware/src/led.c,accel.c,factory.c,fota.cexisted as source but were not inCMakeLists.txt-- added.firmware/lemon.overlayhad no devicetree nodes for the LED or the accelerometer, and noled-hug/accelaliases thatled.c/accel.crequire to even compile (DT_ALIAS(...)is a compile-time lookup) -- added, see §6 "pins assumed".firmware/Kconfigdid not exist: everyCONFIG_LEMON_*symbol referenced from C (windows, timeouts, the dev/factory shell toggles, the FOTA port) was undeclared -- added.firmware/prj.confwas missing the sensor stack, MCUboot/FOTA Kconfig, andCONFIG_REBOOT-- added.firmware/overlay-prod.conf,firmware/sysbuild.conf,firmware/sysbuild/mcuboot.conf, andfirmware/VERSIONdid not exist at all -- added (see §5, §7).firmware/src/lemon_shell.ccompiled itslemon ...dev command tree unconditionally, despite its own header comment saying it must never ship in production -- wrapped in#if defined(CONFIG_LEMON_DEV_SHELL).backend/relay.py,relay.toml,README.md,lemon-relay.servicewere still rev A: 16-byte datagrams, message typesPULSE/ACK/HEARTBEAT/STATUS, devices identified by a literal name ("A"/"B") with a hand-edited two-entry TOML, no squeeze-back, no pairing registry, no per-day logging, no FOTA serving. Rewritten to match protocol v2 (§3) and the four backend requirements (per-device auth, squeeze-back relay, per-day squeeze-count logging, pairing registry) -- see §3 andbackend/README.md.firmware/tools/fixture.pydid not exist (referenced byfactory.c's own header comment) -- added.
3. Message protocol (v2)
20 bytes, little-endian, identical on the wire in firmware/src/lemon.h and backend/relay.py:
| Bytes | Field | Notes |
|---|---|---|
| 0..3 | serial |
uint32, assigned at the factory fixture. Cleartext, but authenticated (AAD). |
| 4..7 | seq |
uint32, monotonic per direction. Cleartext, authenticated (AAD). Replay window is a 32-bit sliding bitmap (RFC 4303 style), independently for each direction. |
| 8..11 | body |
AES-128-CCM ciphertext of { type: u8, flags: u8, arg: u16 }. |
| 12..19 | tag |
8-byte CCM tag over AAD + body. |
Nonce (13 bytes) = dir(1) (0x01 device→server, 0x02 server→device) ‖ serial(4, LE) ‖
0x00000000 ‖ seq(4, LE). Key: one 128-bit AES key per device, no shared secret anywhere.
Message types (device→server are "up", server→device are "down"; "acked" means the relay answers
with a matching MSG_ACK, echoing seq & 0xffff in arg):
| Type | Value | Dir | arg | Acked | Meaning |
|---|---|---|---|---|---|
MSG_HUG |
0x01 | up | 0 | yes | a fresh squeeze |
MSG_HUG_BACK |
0x07 | up | hug id being replied to | yes | squeeze within the 60 s back window |
MSG_STATUS |
0x04 | up | vbat_mV/16 |
no | battery + charging flag, every 6 h |
MSG_FELT |
0x05 | up | hug id | no | an inbound HUG/HUG_BACK was played -- stops the relay's retries |
MSG_SEEN |
0x09 | up | minutes pending | no | a missed hug was cleared, by squeeze or pick-up |
MSG_HELLO |
0x0B | up | packed fw version | yes | boot; ack triggers lemon_fota_confirm() |
MSG_ACK |
0x02 | down | seq & 0xffff of the acked message |
-- | flags: FLAG_ACK_STALE (partner not heard from in 7h, hug still queued), FLAG_ACK_RATELIMIT (dropped, nothing forwarded) |
MSG_HUG_IN |
0x03 | down | hug id (relay-assigned, currently seq & 0xffff of the triggering HUG) |
-- | partner squeezed you |
MSG_HUG_BACK_IN |
0x08 | down | hug id (of this specific back-squeeze) | -- | partner squeezed back |
MSG_DELIVERED |
0x06 | down | hug id | -- | not sent by relay.py in this pass -- see §7 |
MSG_FOTA |
0x0A | down | packed target version | -- | not sent by relay.py in this pass -- see §7 |
FLAG_LOWBATT/FLAG_CHARGING are STATUS flags; FLAG_SEEN_SQUEEZE marks a SEEN cleared by
squeeze rather than motion. Packed version = major<<11 | minor<<6 | patch (5/5/6 bits).
Squeeze-back and hug ids. The firmware never invents a hug id for its own HUG; the relay
assigns one when it forwards (currently: the low 16 bits of the uplink message's own seq), and
the recipient's MSG_FELT/next MSG_HUG_BACK echoes that id back so the relay knows what it's
being told about. main.c's recent[6] ring buffer dedupes retried deliveries by that id.
Delivery retries. lemon_net.c's own comment says it plainly: "same seq: the relay's window
drops dupes, re-acks". relay.py's DeviceState caches the exact raw ACK bytes for the
last-accepted seq and resends that cached ACK (without reprocessing) on an exact repeat -- this
was written to match that comment and is exercised by a loopback test harness (§8), not by real
retries over a real link. Separately, the relay retries an undelivered HUG_IN/HUG_BACK_IN
every RETRY_PERIOD_S (25 s, chosen to exceed one 20.48 s eDRX cycle) until a matching MSG_FELT
arrives or RETRY_GIVE_UP_S (6 h) passes.
4. State machine (main.c)
One event queue (K_MSGQ, struct lemon_ev), one for(;;) dispatch loop, five event types
(EV_SQUEEZE, EV_RX, EV_MOTION, EV_TIMER, EV_STATUS_DUE). Referenced from main.c's own
header comment as "diagram in production/FIRMWARE_BACKEND.md §4" -- this is that diagram:
EV_SQUEEZE ---> on_squeeze()
|-- both domes within 60ms? no -> ignore
|-- local click (haptic_click)
|-- inside back-window or a hug pending? --------> send HUG_BACK (arg=hug_id)
|-- else ------------------------------------------> send HUG
`-- acked, not rate-limited -> haptic_tick() ("server has it")
EV_RX (HUG_IN / HUG_BACK_IN) ---> on_rx()
|-- dup (seen_before(id))? -> just re-send FELT, stop
|-- HUG_IN -> haptic_heartbeat(); HUG_BACK_IN -> haptic_felt_back()
|-- always send FELT (arg=id) to stop the relay's retries
|-- HUG_BACK_IN: no further state change (a reply needs no reply)
`-- HUG_IN only, if not already pending:
pending=true, since=now, accel_arm(true),
start notice_timer(CONFIG_LEMON_NOTICE_WINDOW_S)
EV_TIMER(T_NOTICE), still pending & not missed ---> missed=true, led_breathe(true),
start missed_timer(CONFIG_LEMON_MISSED_MAX_H)
EV_TIMER(T_MISSED_MAX), still pending ---> led_breathe(false), accel_arm(false), pending=missed=false
EV_MOTION (accelerometer wake-on-motion, only fires while armed) ---> notice(by_squeeze=false)
(a squeeze also calls notice(true) via on_squeeze(), before deciding HUG vs HUG_BACK)
notice(by_squeeze) ---> if pending:
stop both timers, accel_arm(false)
if missed: led_breathe(false), replay heartbeat if not by_squeeze,
send SEEN (arg=minutes pending, flags=SEEN_SQUEEZE iff by_squeeze)
pending=missed=false
back_until = now + BACK_WINDOW_S (noticing opens the squeeze-back window too)
EV_RX (DELIVERED) ---> haptic_delivered() ("it buzzed in their lemon") -- not sent by relay.py yet, §7
EV_RX (FOTA) ---> lemon_fota_request(arg) if no hug currently pending
EV_STATUS_DUE ---> battery_check(); below SHUTDOWN_MV and not charging -> shutdown_lowbatt()
(sys_poweroff(), wakes on a squeeze edge or the charger being plugged in)
5. Provisioning flow (500 units)
- Fixture test (
factory.c's command tree, driven byfirmware/tools/fixture.py test): identity (IMEI/ICCID/fw), both domes, LRA (auto-cal + an objective vibration check sampled off the accelerometer itself), the missed-hug LED (operator/photodiode judged, no automated verdict), the accelerometer (gravity magnitude + trigger registration), battery voltage, modem attach + signal + IP. - Provision (
fixture.py provision): allocates the next serial (sequential frommanifest.csv, starting at 1000001) and a fresh random 16-byte key, sendsfactory provision <serial> <key32hex> <server> <port>, records{serial, key, imei, iccid, fw, timestamp}inmanifest.csv, and appends a[devices."<serial>"]block tobackend/relay.toml-- with the IP left as aTODOplaceholder, because the SIM's static private IP only comes from the 1NCE portal by ICCID and this script never talks to that portal (see §8, untested). - Pair (
fixture.py pair <serial_a> <serial_b>): writes both directions of the mapping intobackend/pairing.json. Deliberately separate from step 2's crypto identity, so replacing one lemon of a pair later is apairing.jsonedit, not a re-provision. - Lock (
fixture.py lock, or the last stage offixture.py line): sendsfactory lock, which setsg_cfg.locked=1, persists it, and reboots. From then onlemon_console_off()runs at boot and suspends the UART console device -- the dev shell (already compiled out on a production image,overlay-prod.conf) and the factory shell are both physically unreachable over that UART, even thoughCONFIG_LEMON_FACTORY_SHELLcode is still in the image. - APPROTECT (not automated by anything in this repo): a separate SWD-side step (J-Link/
nrfjprog) against the built
overlay-prod.confimage, meant to run after step 4 succeeds -- see the ordering warning inoverlay-prod.conf's own header. This is the biggest gap in the provisioning story right now (§7).
fixture.py's docstring and --help output are the source of truth for exact flags; this section
is the narrative version.
6. Devicetree / pins (confirmed by the electronics stream mid-pass)
This section was drafted twice. The first pass (early in this session) guessed P0.15/LED and
P0.16/accel with a plain gpio-leds LED, because neither NETLIST.md nor BOM.md had an
accelerometer or LED entry yet. Partway through, the electronics stream landed
production/ELECTRONICS_REVB.md, which is now the authoritative pin table (its own header says
so explicitly) and reverses which pin is which versus that first guess, and specifies a
PWM-driven LED rather than a plain GPIO one. lemon.overlay, bench.overlay, and prj.conf were
all updated to match before this pass finished; what follows is the as-shipped (confirmed) state,
not a guess:
- P0.15 -> missed-hug LED, via PWM0 channel 0 remapped onto this pin with an app-level
&pinctrloverride (seelemon.overlay) -- a true dimmed breath, usingled.c'spwm-ledspath rather than the plain-blinkgpio-ledsfallback. - P0.16 -> accelerometer INT1 (LIS2DW12, push-pull, active high).
- Accelerometer part confirmed: ST LIS2DW12TR, I2C address 0x19 (SA0 strapped to 3V3 --
do not restrap it to GND, it wastes ~170 µA through the part's internal pull-up), sharing the
existing I2C2 bus with the DRV2605L.
accel.c's driver calls remain part-agnostic (any Zephyr sensor exposingSENSOR_TRIG_MOTION/DELTA/THRESHOLD, with a polling fallback if none), so this was a devicetree/Kconfig-only change, nothing insrc/needed touching. CHG_STAT's meaning changed, not just its pin: rev A read the MCP73831 STAT pin directly, which turns out to be a 5 V push-pull output when charge completes -- over the nRF9151's 3.6 V GPIO limit, a real bug the electronics stream caught. Rev B reads the BQ51013BCHGpin instead (open drain, still active low, still wired throughchg-stat/charging()unchanged), but it now means "sitting on a working Qi charger" rather than "still filling" -- it stays low after the cell is full. Nothing inmain.c/fota.ccurrently distinguishes "charging" from "full", so no logic depends on the difference yet, but don't assumecharging() == falseimplies "battery not full" if that ever matters (e.g. a future "fully charged" haptic/LED cue would need the separate optionalCHG_DONEpin below).- P0.17
CHG_DONE("battery full") exists on the netlist but its resistor divider is DNP by default and nothing infirmware/reads it. Leave it alone unless R15/R16 are actually populated on a given board revision.
No further action needed here unless the electronics stream revises this table again.
7. Security (EN 18031 / COMPLIANCE.md §6.3)
| Requirement | Status |
|---|---|
| No shared secret, per-device keys | Done in the protocol itself (§3) and in relay.toml (per-serial key). Keys live in the nRF's PSA/TF-M internal trusted storage (LEMON_KEY_ID, non-exportable) on-device, and in relay.toml/manifest.csv on the server side (plain files on the VPS disk -- protecting that disk is an operational control, not something the code does). |
| Authenticated server messages | Every datagram either direction is AES-128-CCM authenticated (8-byte tag) with a replay window; verify() in lemon_net.c and verify_uplink() in relay.py both reject a bad tag or an old/repeated seq before anything is acted on. |
| Signed FOTA | fota.c relies entirely on MCUboot's signature check before swapping -- the HTTP transport itself is plain, deliberately (see backend/README.md): a wrong/corrupt/unsigned image simply fails to boot and MCUboot reverts. This requires the sysbuild/MCUboot wiring in §2, which is not build-tested (no toolchain this session). |
| MCUboot, secure boot | CONFIG_BOOTLOADER_MCUBOOT=y (prj.conf) + sysbuild.conf/sysbuild/mcuboot.conf (ECDSA-P256). Untested build. |
| APPROTECT in production | overlay-prod.conf sets CONFIG_NRF_APPROTECT_LOCK/CONFIG_NRF_SECURE_APPROTECT_LOCK. The exact Kconfig symbol names are an HW-VERIFY guess -- confirm against the pinned NCS version's nrf/soc/nrf91 Kconfig, and confirm the effect with a read-back (nrfjprog --readregs or similar) on a real locked unit. Engaging it is also a manual, separate SWD step (§5 step 5) that nothing in this repo automates yet. |
| Dev shell compiled out in production | CONFIG_LEMON_DEV_SHELL=n in overlay-prod.conf; the factory shell stays (needed for the fixture) but its UART is suspended after factory lock (lemon_console_off()), independent of APPROTECT (APPROTECT blocks SWD, not UART). |
| Factory reset / re-pairing wipes keys | Not implemented this pass. factory provision overwrites the key and resets serial/seq/rx state (see cmd_provision in factory.c), which covers re-provisioning at a workshop, but there is no end-user-facing "forget everything" path (e.g. hold both domes for 10 s) for resale. Flagging as a gap, not just untested. |
| Data minimisation | The relay stores: per-device replay counters (RAM), a per-day squeeze count keyed by serial (usage.sqlite3 -- the usage-pilot requirement), and the pairing map. No location, no message history, no per-squeeze timestamps beyond the daily bucket. Log lines (journalctl) do contain serials and IPs at INFO level; rotate/retain per GDPR §COMPLIANCE.md as an operational matter. |
| Rate limiting / fail-safe | relay.py rate-limits accepted HUG/HUG_BACK per device (RATE_WINDOW_S, currently 2 s) and flags the sender via FLAG_ACK_RATELIMIT rather than silently dropping. Firmware fails safe already (per-existing code): unreachable server -> local click still plays, hug just isn't acked. |
Not implemented this pass, called out explicitly: MSG_DELIVERED and MSG_FOTA are defined
in the protocol and handled by main.c (haptic_delivered(), lemon_fota_request()) but
relay.py never sends either -- there is no "your partner's lemon actually played it" signal back
to the sender, and no automatic "a newer firmware exists, go get it" push. Both are natural
follow-ups once real devices exist to test the retry/version-diff logic against.
8. What's untested (collected)
- Everything hardware-facing. No nRF9151, no LIS2DW12/DRV2605L/LED, no SIM, no toolchain were
available this session. Every
HW-VERIFY/UNVERIFIEDcomment infirmware/reflects that. - The devicetree/Kconfig additions in §6 have not been through
west buildeven once. - MCUboot/sysbuild wiring (
sysbuild.conf,sysbuild/mcuboot.conf,CONFIG_FOTA_DOWNLOAD/CONFIG_DOWNLOADERnaming) -- symbol names only cross-checked against memory of recent NCS releases, not the pinned version. - APPROTECT Kconfig symbol names and the fixture-ordering assumption (lock before APPROTECT).
backend/relay.py's protocol logic (seal/verify, ACK, forward, FELT-clears-retry, exact-seq re-ack without double-counting, per-day usage logging) was exercised end-to-end this session with a Python-only loopback harness standing in for both lemons -- real crypto, real UDP sockets, two real client sockets acting as device A/B, no mocked network layer. That gives reasonable confidence in the relay's own logic, but it is not the same as testing against real firmware over a real eDRX cycle with real jitter/loss.- The FOTA HTTP file server (
http.server.ThreadingHTTPServer) has not been exercised againstfota_download()on real hardware. firmware/tools/fixture.py's serial-port I/O (theFixtureclass) was only smoke-tested with a fake stand-in forserial.Serial, never against a real UART/fixture -- the command/parsing logic (parse_result, manifest/relay.toml writing, pairing.json writing) was exercised for real, the pyserial transport was not.- The retry cadence (
RETRY_PERIOD_S,RETRY_GIVE_UP_S,STALE_S,RATE_WINDOW_S) are first guesses, not tuned against measured eDRX/attach timing. - The PWM0/pinctrl remap onto P0.15 added to
lemon.overlayfor the LED (electronics stream's own fragment, pasted in verbatim) has not been throughwest build-- confirm thepwm0_lemon/pwm0_lemon_sleeppinctrl states compile against the pinned Zephyr/NCSnrf9151pinctrl binding, and that low-power-enable on the sleep state does what's intended between breaths.