Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCy5VHMVLkAmftYM6swWmx
341 lines
19 KiB
Markdown
341 lines
19 KiB
Markdown
---
|
||
title: EinarBoard v6 — Phase 1 — NTRIP Client in the STM32 — Bench Spec & Execution Prompt
|
||
tags: [project-drogheda, firmware, v6, ntrip, a7670sa, 4g, rtcm, bench, design-brief, prompt]
|
||
project: Project Drogheda
|
||
author: Helgen CTO + Claude
|
||
date: 2026-08-24
|
||
status: Active — THE spec for the Phase 1 bench spike. Execute before any v6 layout exists.
|
||
authority: implements Phase 1 of `2026-08-24_…EinarBoard-v6_GNSS-4G-Integration_Design-Brief_v1.0.md`.
|
||
Firmware baseline = `04-Source-Code/firmware/blackpillrtkcode_v3` (v3.0.3). Where this
|
||
conflicts with the v6 brief on Phase 1 mechanics, THIS file wins.
|
||
---
|
||
|
||
# Phase 1 — NTRIP client in the STM32 (bench spike)
|
||
|
||
!!! warning "Designators from before 22-Oct (T43)"
|
||
This document keeps the designators the board had on its date. Since 22-Oct (T43) the V5.6, V5.7 and v6 carry new ones, and several old names now belong to another part: on the v6, U6 is now the A7670SA modem (was U19) and the LTC4040 is U17 (was U6). To read it against today's board, use the [old → new table](https://drogheda.helgen.online/hardware/designadores-t43-22oct/#8-tablas-viejo-nuevo) (in Spanish); parts already gone by 22-Oct are not in it.
|
||
|
||
> **Why this exists.** The Ardusimple 4G NTRIP Master runs the NTRIP client *on the module*.
|
||
> A bare A7670SA does not. v6 therefore requires a piece of firmware Helgen has never
|
||
> shipped, sitting directly on the correction path — the exact path the June RCA already
|
||
> proved you cannot see into. **This spec retires that risk on a $40 bench rig, before a
|
||
> single v6 trace is drawn.**
|
||
|
||
---
|
||
|
||
## 0. ⚠ Correction to the v6 brief §10
|
||
|
||
The brief said to run this spike **on a v5.5 board through its XBee socket**. After extracting
|
||
the actual v5 netlist, **that is wrong on two counts** and the plan is changed:
|
||
|
||
1. **The socket cannot power the modem.** `J7.1 = /3V3_MODEM`, generated by **U7 (MP1584)
|
||
→ L7 (10 µH/2 A) → 3V3_MODEM**, C41 22 µF + C43 100 µF. The A7670SA needs
|
||
**VBAT 3.4–4.2 V at up to 2.8 A**. Wrong voltage, wrong current, wrong rail. Feeding it
|
||
from there would brown out `SW_5V` and reproduce the 10-Ago boot loop on the bench.
|
||
2. **The UART nets collide.** `/USART1_RX` (PA10) carries **three** loads: `U16.43`,
|
||
`K1.1` (relay NC) and `MOD1.23` (the F9P's TX2). There is no free RX pin on that net for
|
||
the modem, and with K1 de-energised the NC contact ties `/USART1_RX` to `/DIN_4G`
|
||
(modem RX) — driving the modem's TX into that net creates a **self-loop**.
|
||
|
||
**Phase 1 therefore runs on a standalone bench rig** (§1) that mirrors the v6 topology exactly,
|
||
using pins that are free in v6. Nothing is soldered to the 15 boards in fab. An optional
|
||
confirmation run on a real v5.5 board is described in §9 — **after** G1 passes.
|
||
|
||
---
|
||
|
||
## 1. Bench rig
|
||
|
||
### 1.1 Bill of materials (~US$45, all obtainable in MX in days)
|
||
|
||
| Item | Note |
|
||
|---|---|
|
||
| **STM32F411CEU6 "Blackpill"** | already in stock. Same family as the F411RET6 — USART6 lives on PB6/PB7 in the 48-pin package (see §1.3) |
|
||
| **A7670SA core board** with USB, nano-SIM holder, UART header, u.FL/SMA | ~US$12–18 (MercadoLibre / AliExpress). **⚠ Confirm the variant is `-SA`.** An `-E`/`-C` board has no B4/B66 and will never attach on Telcel |
|
||
| *(fallback)* LILYGO **T-A7670SA** | ~US$33; has its own LiPo + 5 V regulation. Requires flashing the on-board ESP32 with a transparent UART bridge, or tapping the modem pins directly |
|
||
| **simpleRTK2B Pro** + multiband antenna | already owned |
|
||
| **LTE antenna** + pigtail | ~US$5 |
|
||
| **M2M SIM**, Telcel or AT&T MX, real data plan | ⚠ **not** a flat-rate sensor SIM — see v6 brief §6.4 |
|
||
| Bench PSU capable of **4.0 V / 3 A**, or a 1S LiPo | powers the modem *only* |
|
||
| **SN74LVC2T45DCUR** ×2 (or a TXS0108E breakout) | only if the core board exposes raw **1.8 V** I/O |
|
||
|
||
### 1.2 Power — the rule that keeps this honest
|
||
|
||
> **The modem gets its own supply. Never off the Blackpill's 3V3 or a USB 5 V pin.**
|
||
> 4.0 V, ≥3 A, with **≥470 µF of low-ESR bulk within 20 mm of the module's VBAT pins**, and
|
||
> **all three VBAT pins tied**. Grounds common, single star point at the modem.
|
||
>
|
||
> Instrument this: put a scope on VBAT during the first LTE attach and **record the droop**.
|
||
> **>300 mV means the v6 rail is undersized** and you have learned it for $0. This measurement
|
||
> is a Phase 1 deliverable, not an optional extra — it is the empirical check on the
|
||
> `AP64350`-class buck chosen in v6 Block P.
|
||
|
||
### 1.3 Wiring — mirrors v6 exactly
|
||
|
||
| Function | v6 net | v6 MCU pin | Bench (Blackpill F411CE) |
|
||
|---|---|---|---|
|
||
| MCU → modem UART (AT + GGA + `$PHLG`) | `/USART6_TX` | **PC6** | **PB6** (USART1_TX, AF7) |
|
||
| modem → MCU UART (responses + RTCM tap) | `/USART6_RX` | **PC7** | **PB7** (USART1_RX, AF7) |
|
||
| modem UART TX → GNSS UART2 RX (RTCM, hardware path) | `/DOUT_4G` | — | direct wire to the Pro's **RX2** |
|
||
| GNSS UART1 ↔ MCU | `/GNSS_TX`, `/GNSS_RX` | PA2 / PA3 | PA2 / PA3 (unchanged, `HardwareSerial GNSS(PA3, PA2)`) |
|
||
| GNSS UART2 TX (uplink NMEA tap) | `/USART1_RX` | PA10 | **not wired on the bench** — see the note below |
|
||
| modem PWRKEY | `/MODEM_PWRKEY` | **PC8** | **PB8**, via 2N7002 open-drain |
|
||
| modem STATUS | `/MODEM_STATUS` | **PC9** | **PB9**, level-shifted |
|
||
| modem RESET | `/MODEM_RST` | **PC11** | **PB5**, via 2N7002 open-drain |
|
||
| modem rail enable | `/MODEM_PWR_EN` | **PC10** | **PB4** → bench PSU inhibit / load-switch |
|
||
|
||
> ⚠ **The 48-pin Blackpill has only two usable UARTs, and this matters.** On the F411**CE**
|
||
> (UFQFPN48) there is no PC6/PC7, so USART6 is only available on PA11/PA12 — **the USB pins**.
|
||
> That leaves USART2 (PA2/PA3, the receiver) and USART1 (PA9/PA10 **or** PB6/PB7, the modem).
|
||
> Remapping USART1 to PB6/PB7 for the modem means **PA10 cannot simultaneously be
|
||
> USART1_RX**, so the UART2 uplink tap is not available on the bench rig.
|
||
>
|
||
> **This costs nothing.** The MCU already receives the full NMEA stream — GGA included — from
|
||
> the receiver's UART1 on USART2. Phase 1 sources the caster GGA from there. The PA10 tap
|
||
> exists on v6 only to relay the receiver's UART2 output byte-for-byte for Modo B, which is a
|
||
> v6 concern, not a Phase 1 one. **The F411RET6 on v6 has PC6/PC7 and keeps all three.**
|
||
|
||
> **Level shifting.** A7670SA I/O is **1.8 V**. `MODEM_TX` needs 3.3 → 1.8; `MODEM_RX` and
|
||
> `MODEM_STATUS` need 1.8 → 3.3 (the STM32's V_IH ≈ 2.31 V — **1.8 V will not read as HIGH**).
|
||
> `PWRKEY` and `RESET` have internal 50 kΩ pull-ups to VBAT: drive them **open-drain** with a
|
||
> 2N7002, never push-pull, so an absent MCU leaves them unasserted.
|
||
> Many A7670SA core boards already carry a shifter — **verify with a meter before connecting
|
||
> anything**, and never drive 3.3 V into a raw 1.8 V pin.
|
||
|
||
### 1.4 Topology note — the fail-safe survives
|
||
|
||
On v6 the modem's UART TX fans out to **both** the GNSS UART2 RX (hardware RTCM path) **and**
|
||
the MCU. K1 keeps its role: **NC = F9P TX2 → modem RX** (MCU dead ⇒ the receiver's own GGA
|
||
still reaches the caster ⇒ corrections keep flowing), **NO = PC6** (MCU in line, forwarding GGA
|
||
and injecting `$PHLG`). The only schematic change is that **K1 pin 10 moves from PA9 to PC6**
|
||
(v6 Block T). Replicate this on the bench with a jumper: for Phase 1, run permanently in the
|
||
"MCU in line" position.
|
||
|
||
**Consequence, stated plainly: v6 makes Modo B the normal operating mode, not a bench special.**
|
||
That is consistent with the EPCT decision already on record (`$PHLG` in-band via Modo B).
|
||
|
||
---
|
||
|
||
## 2. Firmware architecture
|
||
|
||
### 2.1 Placement and guards
|
||
|
||
New block in `blackpillrtkcode_v3.ino` (Phase 1) → promoted to `blackpillrtkcode_v4/` for v6.
|
||
Follow the file's existing idiom exactly:
|
||
|
||
```c
|
||
#define ENABLE_NTRIP_CLIENT 0 // 0 = fleet (modem owns NTRIP) · 1 = MCU owns NTRIP [v6]
|
||
#define ENABLE_NTRIP_DEBUG 1 // NTRIP/AT trace lines on the USB STREAM log
|
||
```
|
||
|
||
`#ifndef`-guarded so `-DENABLE_NTRIP_CLIENT=1` works as an `arduino-cli` build flag without
|
||
editing the file — same pattern as `ENABLE_MODOB_BENCH`. FW version string becomes
|
||
**`"3.1.0-ntrip"`** so a stray flash is detectable in `$PHLG` field 1.
|
||
|
||
### 2.2 Hard rules
|
||
|
||
1. **No `delay()`. No blocking reads.** Everything is a millis()-timed state machine serviced
|
||
from `loop()`, like `chargeGovernor()` and `modoBService()`. The IWDG is on
|
||
(`ENABLE_IWDG 1`) — a blocking AT wait will reset the board.
|
||
2. **No `String`, no heap.** Fixed buffers, `snprintf`, exactly as `buildPHLG()` does.
|
||
3. **The NTRIP client never gates the safety loop.** Power, charge governor, thermal and fan
|
||
services keep running at their existing cadence regardless of modem state.
|
||
4. **Secrets never enter git** (invariant #6). APN and caster credentials come from
|
||
`config/ntrip.local.h` (git-ignored) or `-D` build flags. Ship
|
||
`config/ntrip.example.h` alongside the existing `4G_NTRIP_HelgenSRV-NMEA.example.txt`.
|
||
|
||
### 2.3 State machine
|
||
|
||
```
|
||
┌──────────────────────────── beginBackoff(reason) ────────────────────┐
|
||
│ │
|
||
MODEM_OFF ─► RAIL_UP ─► PWRKEY_PULSE ─► WAIT_READY ─► CONFIG ─► ATTACH ─► │
|
||
▲ │ │
|
||
│ ▼ │
|
||
│ SOCKET_OPEN ─► NTRIP_GET ──────┤
|
||
│ │ │
|
||
│ ▼ │
|
||
└──────────────── HARD_CYCLE ◄── BACKOFF ◄──────────────── STREAMING ──┘
|
||
```
|
||
|
||
| State | Action | Timeout → next |
|
||
|---|---|---|
|
||
| `MODEM_OFF` | `MODEM_PWR_EN` low; all modem GPIO released | — |
|
||
| `RAIL_UP` | `MODEM_PWR_EN` high | 100 ms → `PWRKEY_PULSE` |
|
||
| `PWRKEY_PULSE` | PWRKEY low **≥ 50 ms** *(SIMCom; **never** copy Quectel's ≥2 s)*, release | → `WAIT_READY` |
|
||
| `WAIT_READY` | wait for `RDY`/`+CPIN: READY`; UART ready ≈ 8 s, STATUS asserts ≈ 11.2 s | **20 s** → `BACKOFF` |
|
||
| `CONFIG` | `ATE0` · `AT+CMEE=2` · `AT+CPIN?` · `AT+CGDCONT=1,"IP","<APN>"` · `AT+CSQ` · `AT+CREG?` | 10 s → `BACKOFF` |
|
||
| `ATTACH` | `AT+CGATT?` poll until `1`; then `AT+NETOPEN` | **90 s** → `BACKOFF` |
|
||
| `SOCKET_OPEN` | `AT+CIPOPEN=0,"TCP","<caster>",<port>` | 30 s → `BACKOFF` |
|
||
| `NTRIP_GET` | send the §3 request; await `ICY 200 OK` / `HTTP/1.x 200` | 15 s → `BACKOFF` |
|
||
| `STREAMING` | transparent RTCM in; GGA + `$PHLG` out; watchdogs of §5 | see §5 |
|
||
| `BACKOFF` | rail stays up, socket closed, exponential wait | → `PWRKEY_PULSE` |
|
||
| `HARD_CYCLE` | `MODEM_PWR_EN` low **≥ 2 s**, then `MODEM_OFF` | invariant #3's recovery path |
|
||
|
||
**Backoff ladder (mirror `chargeGovernor()`'s cooldown idiom):**
|
||
`5 s → 10 → 20 → 40 → 80 → 160 → 300 s cap`. **Reset to 5 s after 10 minutes of healthy
|
||
`STREAMING`.** After **3 consecutive** `BACKOFF` entries without ever reaching `STREAMING`,
|
||
escalate to `HARD_CYCLE`.
|
||
|
||
> **Invariant #2 — never vanish, never loop.** The backoff ladder plus the 3-strike escalation
|
||
> is what stops this becoming a fast-reboot loop. It is not decoration; it is the invariant.
|
||
|
||
---
|
||
|
||
## 3. NTRIP v1 handshake — exact bytes
|
||
|
||
```
|
||
GET /<MOUNTPOINT> HTTP/1.0\r\n
|
||
User-Agent: NTRIP HelgenHBOX/3.1\r\n
|
||
Authorization: Basic <base64(user:pass)>\r\n
|
||
Accept: */*\r\n
|
||
Connection: close\r\n
|
||
\r\n
|
||
```
|
||
|
||
- Accept **either** `ICY 200 OK` (NTRIP v1) or `HTTP/1.0 200 OK` / `HTTP/1.1 200 OK`.
|
||
- `401` → **do not retry blind.** Latch `NTRIP_AUTH_FAIL`, emit an `EVT`, and back off to the
|
||
300 s cap. Hammering a caster on bad credentials gets you IP-banned — and rtk2go does ban.
|
||
- `SOURCETABLE 200 OK` means **the mountpoint does not exist**. Distinct latch, distinct EVT.
|
||
This is a real failure mode and the June RCA would have caught the caster problem months
|
||
earlier if the firmware had reported it.
|
||
- Base64 is computed **at build time** from the credentials header — no runtime encoder, no
|
||
credentials in RAM as plaintext longer than necessary.
|
||
|
||
**GGA uplink.** Send one `$GPGGA`/`$GNGGA` to the caster **every 10 s** while `STREAMING`,
|
||
**byte-exact, never rebuilt** — copy the sentence verbatim from the receiver's own output
|
||
(bench: the USART2 NMEA stream; v6: the PA10 UART2 tap). VRS mountpoints require it;
|
||
single-base mountpoints ignore it harmlessly. A rebuilt GGA with a recomputed checksum is the
|
||
classic way to get silently dropped by a caster — `nmeaChecksumOk()` already exists in the
|
||
firmware for validating it, but the bytes sent must be the originals.
|
||
|
||
---
|
||
|
||
## 4. RTCM handling and the telemetry that closes the June RCA
|
||
|
||
RTCM3 bytes flow **modem → GNSS UART2 in hardware**. The MCU sees the same bytes on its RX and
|
||
**parses them for telemetry without being in the data path's critical timing**.
|
||
|
||
Frame walker (no buffering of payloads — count only):
|
||
|
||
- Preamble `0xD3`, then 6 reserved bits + **10-bit big-endian length**, then payload, then
|
||
**3-byte CRC-24Q**.
|
||
- Message type = first 12 bits of the payload.
|
||
- Maintain: `rtcmBytes` (u32), `rtcmMsgs` (u32), `lastRtcmMs`, `rtcmCrcBad` (u16), and a
|
||
small bitmap of **seen message types** (1005/1006 station, 1074–1077 GPS MSM,
|
||
1084–1087 GLO, 1094–1097 GAL, 1124–1127 BDS).
|
||
|
||
> **This is the single most valuable thing Phase 1 produces.** On 10-Ago the fleet showed
|
||
> *zero corrections applied anywhere* and there was no way to tell whether the caster, the
|
||
> modem, the wiring or the receiver was at fault. After this, `$PHLG` answers it directly:
|
||
> RTCM arriving but no fix ⇒ receiver/config or base-band mismatch. No RTCM ⇒ caster or link.
|
||
> **Build the observability before you build the board.**
|
||
|
||
**`STREAMING` health watchdogs:**
|
||
|
||
| Condition | Action |
|
||
|---|---|
|
||
| no RTCM byte for **30 s** | → `BACKOFF` ("rtcm stall") |
|
||
| no TCP data at all for **60 s** | → `BACKOFF` |
|
||
| `+CIPCLOSE`/`+IPCLOSE` URC | → `BACKOFF` immediately |
|
||
| `CSQ` = 99 for **60 s** | → `BACKOFF` |
|
||
| 3 backoffs, never streamed | → `HARD_CYCLE` |
|
||
|
||
---
|
||
|
||
## 5. `$PHLG` extension — append-only
|
||
|
||
`buildPHLG()` is documented append-only and the backend decoder is already deployed. v3.0.3
|
||
emits 25 fields. **Append six, in this order**, and change nothing before them:
|
||
|
||
| # | Field | Type | Meaning |
|
||
|---|---|---|---|
|
||
| 26 | `ntrip` | u8 | modem state enum (0=OFF … 8=STREAMING, 9=AUTH_FAIL, 10=NO_MOUNT) |
|
||
| 27 | `rtcm_age` | u32 | ms since last RTCM byte (`0xFFFFFFFF` = never) |
|
||
| 28 | `rtcm_msgs` | u32 | RTCM3 frames accepted this power session |
|
||
| 29 | `rtcm_bad` | u16 | CRC-24Q failures |
|
||
| 30 | `csq` | u8 | last `AT+CSQ` RSSI index (99 = unknown) |
|
||
| 31 | `nret` | u16 | backoff entries this power session |
|
||
|
||
Also extend the `COLS,` header in `logBoot()` and the `TLM,` record in `logTelemetry()` with the
|
||
same six, same order. **Backend:** `retranslator`'s `$PHLG` decoder must tolerate both 25- and
|
||
31-field sentences — mixed-firmware fleets are guaranteed during rollout.
|
||
|
||
Update `docs/LOGGING-SPEC.md` to **v2.2** in the same commit.
|
||
|
||
---
|
||
|
||
## 6. hostsim
|
||
|
||
`tools/hostsim` already carries `sim`, `sim_mb`, `rollover` and a `run.sh` driver. Add:
|
||
|
||
- **scenario `ntrip`** — a fake modem behind a pipe that can be scripted to: attach normally ·
|
||
never attach · return `401` · return `SOURCETABLE` · accept then go silent mid-stream ·
|
||
drop TCP · emit malformed RTCM (bad CRC).
|
||
- Assertions: the ladder is exponential and capped; it **resets after 10 min healthy**;
|
||
`HARD_CYCLE` fires on the 3rd fruitless backoff and **not** before; `$PHLG` stays ≤ 160 bytes
|
||
with all 31 fields; the XOR checksum is right; **no path ever calls `delay()`**.
|
||
- `run.sh` builds and runs both `ENABLE_NTRIP_CLIENT` variants, as it already does for `modob`.
|
||
|
||
> Green hostsim is a **precondition** for touching the bench rig, not a post-hoc check.
|
||
|
||
---
|
||
|
||
## 7. G1 — acceptance gate
|
||
|
||
**G1 passes only when all of these hold on the bench rig, in one unbroken run:**
|
||
|
||
1. **24 h continuous**, `fixQuality ≥ 4` (RTK-fixed or float) for **≥ 95 %** of samples.
|
||
2. **≥ 1 forced reconnect survived** — pull the LTE antenna for 60 s, restore. RTK must return
|
||
**without human intervention** and `nret` must increment.
|
||
3. **≥ 1 `HARD_CYCLE` exercised** — hold the modem in a fruitless state 3 backoffs running;
|
||
confirm the rail drops ≥2 s and the unit recovers.
|
||
4. **`rtcm_msgs` monotonically increasing** and the message-type bitmap shows the expected
|
||
MSM sets for the base in use.
|
||
5. **VBAT droop < 300 mV** at LTE attach, scope-captured (§1.2), with the capture archived to
|
||
`06-Testing-Validation/`.
|
||
6. **No IWDG reset** across the whole run (`g_resetCause` stays as booted).
|
||
7. Auth/mountpoint failures produce the **correct distinct latches**, verified by deliberately
|
||
using bad credentials and a bad mountpoint.
|
||
|
||
**Deliverables:** the 24 h `TLM` log, the scope capture, the hostsim run output, and a one-page
|
||
result note in `06-Testing-Validation/`.
|
||
|
||
---
|
||
|
||
## 8. ⚠ Phase 1 depends on Phase −1
|
||
|
||
**Gate G0 first.** The 10-Ago fleet truth is *zero corrections applied anywhere* → check
|
||
`HelgenTestStation` @ rtk2go:2101. If the caster or base is broken, this bench rig will
|
||
faithfully report `STREAMING` with `rtcm_msgs = 0` and you will spend a week debugging
|
||
firmware that is working correctly. **Prove the correction source on existing hardware
|
||
before starting.**
|
||
|
||
---
|
||
|
||
## 9. Optional confirmation on a real v5.5 board (after G1)
|
||
|
||
Only once G1 has passed, and only if you want the extra confidence:
|
||
|
||
1. **Remove the simpleRTK2B Pro shield** from MOD1 — otherwise the F9P's TX2 contends with the
|
||
modem on `/USART1_RX`.
|
||
2. **Energise K1** (`PIN_BYP_CTRL` HIGH) so COM–NO is closed and `/DIN_4G` is isolated from
|
||
`/USART1_RX`.
|
||
3. Solder to the **THT relay pins** (accessible, no fine-pitch work): **K1 pin 10 = PA9**,
|
||
**K1 pin 1 = PA10**, **K1 pins 5/6 = `/DIN_4G`**. `/DOUT_4G` is available at **J7 pin 2**.
|
||
4. The modem still runs from its **own** 4.0 V supply. `3V3_MODEM` stays unloaded.
|
||
|
||
This confirms the connector and relay mechanics only. It does **not** validate the v6 pin map
|
||
(PC6/PC7 have no copper on v5.5) — that is what Phase 6 first-article bring-up is for.
|
||
|
||
---
|
||
|
||
## 10. Cross-references
|
||
|
||
- v6 brief: `2026-08-24_Drogheda_EinarBoard-v6_GNSS-4G-Integration_Design-Brief_v1.0.md`
|
||
- Schematic delta: `2026-08-24_Drogheda_EinarBoard-v6_Phase-2_Schematic-Delta_v1.0.md`
|
||
- Firmware baseline: `04-Source-Code/firmware/blackpillrtkcode_v3/` (v3.0.3), `docs/LOGGING-SPEC.md`
|
||
- Modo B / `$PHLG` decision: `2026-08-08_Drogheda_Firmware-v3.1_ModoB-PHLG_Design-Brief_v1.0.md`
|
||
- Ardusimple config being replaced: `04-Source-Code/firmware/config/4G_NTRIP_HelgenSRV-NMEA.example.txt`
|
||
- Invariants: **#1** power-off safe · **#2** never vanish, never loop (§2.3 ladder) ·
|
||
**#3** corrections ride the 4G, recover by power-cycling (`HARD_CYCLE`) · **#6** no secrets (§2.2)
|