GMSR STUDIO← Limonchito docs

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)

2. What's new in rev B (relative to the file layout before this pass)

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)

  1. Fixture test (factory.c's command tree, driven by firmware/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.
  2. Provision (fixture.py provision): allocates the next serial (sequential from manifest.csv, starting at 1000001) and a fresh random 16-byte key, sends factory provision <serial> <key32hex> <server> <port>, records {serial, key, imei, iccid, fw, timestamp} in manifest.csv, and appends a [devices."<serial>"] block to backend/relay.toml -- with the IP left as a TODO placeholder, 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).
  3. Pair (fixture.py pair <serial_a> <serial_b>): writes both directions of the mapping into backend/pairing.json. Deliberately separate from step 2's crypto identity, so replacing one lemon of a pair later is a pairing.json edit, not a re-provision.
  4. Lock (fixture.py lock, or the last stage of fixture.py line): sends factory lock, which sets g_cfg.locked=1, persists it, and reboots. From then on lemon_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 though CONFIG_LEMON_FACTORY_SHELL code is still in the image.
  5. APPROTECT (not automated by anything in this repo): a separate SWD-side step (J-Link/ nrfjprog) against the built overlay-prod.conf image, meant to run after step 4 succeeds -- see the ordering warning in overlay-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:

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)