From f83ce0bed40bc1497862e5beae9acd7257070db6 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:14:45 +0200 Subject: [PATCH] gps fiddling --- .gitignore | 1 + zephcore/Kconfig | 44 ++ zephcore/Repeater_CLI_commands.md | 32 + zephcore/adapters/gps/ZephyrGPSManager.cpp | 679 ++++++++++++++++-- zephcore/adapters/gps/ZephyrGPSManager.h | 25 + zephcore/app/RepeaterMesh.cpp | 3 + zephcore/boards/common/repeater.conf | 8 + .../nrf52840/rak3401_1watt/rak3401_1watt.dts | 12 +- zephcore/boards/nrf52840/rak4631/board.conf | 2 +- .../boards/nrf52840/rak4631/board.overlay | 41 +- .../nrf52840/thinknode_m6/thinknode_m6.dts | 42 +- .../nrf52840/wio_tracker_l1/board.overlay | 14 +- .../wio_tracker_l1_nrf52840.dts | 11 +- zephcore/helpers/CommonCLI.cpp | 24 + .../zephyr/0005-gnss-air530z-easy.patch | 119 --- .../0005-gnss-config-and-version-query.patch | 371 ++++++++++ zephcore/tools/crypto_bench/CMakeLists.txt | 113 +++ .../crypto_bench/boards/rak4631/board.conf | 16 + .../crypto_bench/boards/rak4631/board.overlay | 49 ++ zephcore/tools/crypto_bench/prj.conf | 82 +++ zephcore/tools/crypto_bench/src/main.cpp | 524 ++++++++++++++ 21 files changed, 1988 insertions(+), 224 deletions(-) delete mode 100644 zephcore/patches/zephyr/0005-gnss-air530z-easy.patch create mode 100644 zephcore/patches/zephyr/0005-gnss-config-and-version-query.patch create mode 100644 zephcore/tools/crypto_bench/CMakeLists.txt create mode 100644 zephcore/tools/crypto_bench/boards/rak4631/board.conf create mode 100644 zephcore/tools/crypto_bench/boards/rak4631/board.overlay create mode 100644 zephcore/tools/crypto_bench/prj.conf create mode 100644 zephcore/tools/crypto_bench/src/main.cpp diff --git a/.gitignore b/.gitignore index 710c5f9..88401f3 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,7 @@ UI_AUDIT_INDEX.md RADIO2_AUDIT_INDEX.md MISC_AUDIT_INDEX.md HANDOVER_issue34_ble_esp32.md +HANDOVER_crypto_hw_accel.md PATH_HASH_AUDIT_INDEX.md MESHTIMESYNC_PLAN.md /meshtimesync-sim/ diff --git a/zephcore/Kconfig b/zephcore/Kconfig index 0d4686f..fd5af51 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -764,6 +764,50 @@ config ZEPHCORE_REPEATER_GPS_INTERVAL_SEC overrides it with "set gps duty " (persisted to flash). Companions default to ZEPHCORE_GPS_POLL_INTERVAL_SEC (300s) instead. +config ZEPHCORE_GPS_NAV_MODE + int "GNSS navigation dynamic model (CASIC $PCAS11)" + range -1 7 + default 1 if ZEPHCORE_ROLE_REPEATER || ZEPHCORE_ROLE_ROOM_SERVER + default 3 + help + Dynamic model sent to CASIC-family GNSS modules (Quectel L76K/L76KB, + Air530Z) on boards using the generic-NMEA driver: 0 = portable, + 1 = stationary, 2 = pedestrian, 3 = automotive, 4 = sea, + 5-7 = airborne. -1 sends nothing. + + Defaults to stationary for fixed roles and automotive otherwise. + Worth setting rather than leaving alone: the model is stored IN THE + MODULE and survives reflashing the host, so a slot module that + previously lived in another device can arrive stuck in an airborne + model that quietly degrades fixes on a fixed site. + + Boards that always carry a CASIC part use the air530z driver instead + — see CONFIG_GNSS_LUATOS_AIR530Z_NAV_MODE for those. + +config ZEPHCORE_GPS_SAT_DIAG + bool "GPS per-constellation satellite diagnostics" + select GNSS_SATELLITES + default y if ZEPHCORE_ROLE_REPEATER + help + Tally tracked satellites per constellation from GSV sentences and + report them in "get gps" as sys=G/R/E/B/? + (GPS / GLONASS / Galileo / BeiDou / other). + + This is the ground truth for whether the boot-time multi-constellation + configuration (PMTK353 / UBX-CFG-GNSS) actually took: a module still in + its GPS-only default emits only $GPGSV, so every counter but G stays 0. + Satellite count alone cannot prove this — only the talker IDs can. + + Costs RAM: CONFIG_GNSS_SATELLITES adds 512 bytes to the driver's UART + RX buffer plus a per-satellite array (~770 bytes total, measured on + RAK3401). Default on for repeaters, which have the headroom; off for + companions, which are RAM-bound. Enable it explicitly for a companion + diagnostic build. + + Independent of "set gps diag", which is a runtime toggle and always + available — this option only adds the per-constellation evidence to + its report. + endmenu menu "WiFi OTA Update" diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index 5f85250..5d104cc 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -133,6 +133,37 @@ Regions control which flood packets the repeater forwards. The region tree is hi | `gps advert prefs` | Include stored lat/lon from prefs in advertisements | | `set gps duty ` | GPS duty interval (standby seconds between fixes). `0` = always-on (continuous; streams fresh fixes, can download a full almanac). Floor 10s, cap 604800 (1 week). Persists to flash, applied live. | | `set gps duty default` | Reset GPS duty to the role default (repeater/room 48h, companion 300s) | +| `set gps diag <0\|1\|on\|off>` | Arm GPS module-configuration diagnostics (see below). Not persisted — clears on reboot | + +**GPS configuration diagnostics.** At boot the firmware configures the GNSS module — constellations, AssistNow/EASY, minimum elevation, fix rate — and on modules driven over raw NMEA those commands are sent **blind**: nothing reads the module's reply, so a silently rejected configuration is indistinguishable from a working one. These two commands make that visible. + +``` +set gps diag 1 # arm it +gps off # power-cycle the module... +gps on # ...which re-runs configuration and records the result +get gps diag # read it back +``` + +Sample reply: + +``` +> diag=on cfg=uart age=910s mod=URANUS5 sent=12/336B sys=G3/R4/E0/B3/?0 +``` + +- `cfg=` which path ran — `uart` (raw PMTK+PCAS+UBX), `api` (driver GNSS API), `blind` (neither available), or `never-run` +- `mod=` module identification captured from the `$GPTXT` reply to a version query, or `no-reply` +- `sent=` commands/bytes written to the module (UART path), or `sys_ret=`/`rate_ret=` return codes (API path) +- `sys=` tracked satellites per constellation from GSV talker IDs: **G**PS / GLONASS (**R**) / Galileo (**E**) / **B**eiDou / other. A constellation that stops reporting for 30 s decays to zero rather than showing a stale count + +`sys=` totalling more than `sats=` in `get gps` is expected, not a discrepancy: GSV counts satellites **tracked**, GGA counts satellites **used in the fix solution**. + +**`mod=` is the TX-path proof.** Everything else on this transport is written blind, so a module that hears nothing looks exactly like one that hears everything and ignores it. A version string means the module received a command and answered. `mod=no-reply` alongside a healthy `sats=` in `get gps` means the receive direction works but the module is not hearing us — wiring or pin assignment, not configuration. + +**`sent=` proves transmission, not acceptance.** Only `sys=` shows what the module actually did. A module still running its factory or previously saved configuration reports `G` non-zero with the rest at `0`. Note `B0` is expected on u-blox M8 (BeiDou is deliberately disabled — only three major constellations can run concurrently), and `?0` is normal outside Japan (QZSS is regional). + +The generic-NMEA path sends three protocols — PMTK (MediaTek), PCAS (CASIC: Quectel L76K/L76KB, Air530Z) and UBX (u-blox) — because a WisBlock-style GPS slot can hold any of them and each family ignores what it does not understand. Related build option: `CONFIG_ZEPHCORE_GPS_NAV_MODE` sets the CASIC navigation dynamic model (`$PCAS11`), defaulting to stationary for repeaters and room servers and automotive otherwise. It is worth setting because that model is stored *in the module* and survives reflashing the host — a slot module that previously lived in another device can arrive stuck in an airborne model that quietly degrades fixes on a fixed site. + +Caveats: the `sys=` tally needs `CONFIG_ZEPHCORE_GPS_SAT_DIAG` (default on for repeaters, off for companions to save RAM) — the reply says so when built without it. Only the raw-UART path is re-run on `gps on`; boards with a real GNSS driver (Air530Z, LC76G) keep reporting their boot-time result, because that path goes through `modem_chat_run_script()`, which is safe only at boot. On those boards `E0` is also expected — the Air530Z driver supports GPS/GLONASS/BeiDou but not Galileo, and the firmware falls back automatically. --- @@ -216,6 +247,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `get radio.rxgain` | RX gain boost: `0` or `1` | | `get rxduty` | RX duty cycle mode: `0` or `1` | | `get gps duty` | Now-effective GPS duty interval in seconds (`always on (0)` when continuous) | +| `get gps diag` | What the last GPS module-configuration attempt did — which path ran, bytes sent, and tracked satellites per constellation. See **GPS configuration diagnostics** in the GPS section for the field reference | | `get meshtimesync` | Mesh time-sync state + live dry-run: on/off, eligible voter count, votes for/against, consensus skew and radius, would-be verdict (`ok`/`in-band`/`step±N`/`abstain (reason)`/`hold (reason)`; a recent clock set — manual or GPS — shows as `hold (suppressed)`, and a backward step a forward-only role would refuse is annotated `(skipped: forward-only)`), step counters, suppression countdown, and a per-sender evidence table (`prefix hops count skew E`, `E` = counted toward the verdict above). Entries that count print first, so a size-capped reply never hides the ones that explain the summary; if the table doesn't fully fit, a trailing `+N more` shows how many were left out. Sensing runs even while off, so this works as a dry-run before enabling. Over remote admin the reply is truncated to the packet size (summary always fits); the full table needs the USB CLI. | | `get probe.interval` | Seconds between periodic radio measurements (noise-floor sample + CAD probe). 0 = CAD probing off | | `get dc.restarts` | Duty-cycle preamble false-positive re-arm counter (RxTimeout re-arms + parked-RX watchdog recoveries). High values mean the preamble detector is tripping on noise/interference without real packets arriving — inflates RX-on time and drains battery; packets are never lost to it. Reset by `clear stats`. | diff --git a/zephcore/adapters/gps/ZephyrGPSManager.cpp b/zephcore/adapters/gps/ZephyrGPSManager.cpp index a996341..57ff686 100644 --- a/zephcore/adapters/gps/ZephyrGPSManager.cpp +++ b/zephcore/adapters/gps/ZephyrGPSManager.cpp @@ -48,11 +48,51 @@ LOG_MODULE_REGISTER(zephcore_gps, CONFIG_ZEPHCORE_GPS_LOG_LEVEL); #define HAS_GNSS 0 #endif +/* ========== GPS Feature Detection ========== + * Every HAS_GPS_* predicate is defined HERE, before first use. They are pure + * devicetree tests with no side effects, kept apart from the variables they + * gate so that ordering can never drift again. + * + * Why this block exists: HAS_GPS_UART used to be defined ~500 lines below its + * first `#if`, and an undefined identifier in `#if` is silently 0 — so the + * entire PMTK/UBX module-configuration path compiled to nothing on every + * board, and GPS ran at module defaults (GPS-only constellations, no AOP). + * If you add another HAS_GPS_* macro, define it in this block. + */ + +/* GNSS module hangs off a UART we can write to (any compatible). */ +#if HAS_GNSS && DT_NODE_HAS_STATUS(DT_NODELABEL(gnss), okay) && \ + DT_NODE_HAS_STATUS(DT_BUS(DT_NODELABEL(gnss)), okay) +#define HAS_GPS_UART 1 +#else +#define HAS_GPS_UART 0 +#endif + +/* Discrete GPS power-enable GPIO (gps-enable alias). */ +#if DT_NODE_EXISTS(DT_ALIAS(gps_enable)) +#define HAS_GPS_POWER_CONTROL 1 +#else +#define HAS_GPS_POWER_CONTROL 0 +#endif + +/* GPS powered from a PMU regulator rail (chosen zephcore,gps-power). */ +#if DT_NODE_EXISTS(DT_CHOSEN(zephcore_gps_power)) +#define HAS_GPS_POWER_REGULATOR 1 +#else +#define HAS_GPS_POWER_REGULATOR 0 +#endif + /* ========== GPS Power Strategy ========== * Module power is GPIO/regulator controlled — GNSS driver PM is not used * for the module itself: - * - Wio Tracker L1 (L76K): FORCE_ON pin LOW = hardware standby (~360µA, - * Vcc stays on, ephemeris/almanac/RTC preserved, hot-start 1-2s) + * - Wio Tracker L1 (L76K): P1.09 is the module's WAKEUP pin, not a supply + * switch. Per the L76K hardware design: WAKEUP is a digital input, active + * low with an internal pull-up, that "enters or exits Standby mode". In + * Standby the RF is powered off but the internal core and I/O power domain + * stay active, so VCC is never removed and ephemeris/almanac/RTC survive — + * every wake is a warm start, not a cold one. (Backup mode, the deeper + * state, requires cutting VCC while V_BCKP holds the RTC domain; this + * board has no VCC switch, so Standby is the floor available to us.) * - T1000-E (AG3335): GPS_EN LOW + VRTC HIGH = warm standby (ephemeris * preserved via backup RAM, ~1-2µA VRTC current) * - All boards: gps-enable alias → GPIO power control @@ -342,6 +382,68 @@ static void gnss_data_cb(const struct device *dev, const struct gnss_data *data) /* Register GNSS callback for all GNSS devices */ GNSS_DATA_CALLBACK_DEFINE(NULL, gnss_data_cb); +#ifdef CONFIG_ZEPHCORE_GPS_SAT_DIAG +/* ========== Per-constellation satellite tally (diagnostic) ========== + * The Zephyr GSV parser fills gnss_satellite.system from the NMEA talker ID + * ($GPGSV/$GLGSV/$GAGSV/$GBGSV), so this is direct evidence of which + * constellations the module is actually tracking — the only way to confirm + * that the boot-time PMTK353 / UBX-CFG-GNSS configuration was accepted. + * A module still in its GPS-only default yields sats_gps only. + * + * Counts only tracked satellites (is_tracked), not merely visible ones. */ +static uint8_t sat_count[5]; /* gps, glonass, galileo, beidou, other */ +static int64_t sat_seen_ms[5]; /* uptime when each bucket was last reported */ + +/* A constellation absent for this long is reported as zero. Long enough to + * ride out a missed GSV cycle (they repeat at the fix rate), short enough + * that a constellation which genuinely drops out stops being claimed. */ +#define SAT_TALLY_STALE_MS 30000 + +static void gnss_satellites_cb(const struct device *dev, + const struct gnss_satellite *satellites, + uint16_t size) +{ + ARG_UNUSED(dev); + uint8_t tally[5] = { 0 }; + bool seen[5] = { false }; + + for (uint16_t i = 0; i < size; i++) { + int idx; + + switch (satellites[i].system) { + case GNSS_SYSTEM_GPS: idx = 0; break; + case GNSS_SYSTEM_GLONASS: idx = 1; break; + case GNSS_SYSTEM_GALILEO: idx = 2; break; + case GNSS_SYSTEM_BEIDOU: idx = 3; break; + default: idx = 4; break; + } + + /* Mark the constellation as reported even when nothing in it is + * tracked — that is a real "zero", distinct from "not heard". */ + seen[idx] = true; + if (satellites[i].is_tracked) { + tally[idx]++; + } + } + + /* One GSV burst carries ONE constellation: the parser publishes each + * talker's group separately (satellites_length == number_of_svs for + * that group). So update only the buckets this burst reported — + * replacing all five wholesale wipes the constellations that arrived + * in the previous burst, which reads as G0 next to a healthy fix. */ + k_mutex_lock(&gps_mutex, K_FOREVER); + for (int i = 0; i < 5; i++) { + if (seen[i]) { + sat_count[i] = tally[i]; + sat_seen_ms[i] = k_uptime_get(); + } + } + k_mutex_unlock(&gps_mutex); +} + +GNSS_SATELLITES_CALLBACK_DEFINE(NULL, gnss_satellites_cb); +#endif /* CONFIG_ZEPHCORE_GPS_SAT_DIAG */ + /* Find and initialize GNSS device */ static const struct device *gnss_dev = NULL; @@ -358,6 +460,43 @@ static const struct device *gnss_dev = NULL; * persist). So we only need to configure once. */ static bool gnss_configured = false; +/* ========== Configuration Diagnostics ========== + * Module configuration is sent blind — nothing in the protocol path tells us + * the module accepted it. This records what was attempted so the operator can + * read it back over the CLI on a release build. `gps_set_diag(true)` also + * clears gnss_configured, so the next GPS enable re-runs configuration and + * refreshes the record. RAM only, never persisted. */ +enum gps_cfg_path { + GPS_CFG_NEVER = 0, /* configuration has not run yet */ + GPS_CFG_API, /* driver implements the GNSS API (air530z, lc76g, ...) */ + GPS_CFG_UART, /* passive NMEA listener — raw PMTK + UBX sent */ + GPS_CFG_BLIND, /* no GNSS API and no writable UART — module defaults */ +}; + +static struct { + uint8_t path; /* enum gps_cfg_path */ + int8_t api_ret; /* gnss_set_enabled_systems() result */ + int8_t rate_ret; /* gnss_set_fix_rate() result */ + uint8_t cmds; /* config commands written to the UART */ + uint16_t bytes; /* bytes written to the UART */ + int64_t at_ms; /* uptime when configuration last ran */ +} gps_cfg_diag; + +static bool gps_diag_on = false; + +/* Module identification string, captured by the GNSS driver from the reply to + * its version query (CASIC parts answer in-band as a $GPTXT sentence). + * Weak so that boards whose driver has no version query still link — an + * absent symbol and an empty string mean the same thing to the report. + * + * Its real value is not the version text: on a transport where every command + * is written blind, a captured reply is the only positive proof that the + * MCU's TX line reaches the module at all. */ +extern "C" int zephcore_gnss_version_get(char *buf, size_t len) __attribute__((weak)); + +/* True only while gps_configure_via_uart() is running (see gps_uart_send). */ +static bool gps_cfg_counting = false; + /* ========== Vendor-Specific Configuration Commands ========== * * The RAK WisBlock GPS slot accepts multiple modules (L76K, ZOE-M8Q, etc.) @@ -372,7 +511,43 @@ static bool gnss_configured = false; #if HAS_GPS_UART -/* --- Quectel L76K (PMTK) configuration --- */ +/* The UART the GNSS module is connected to. Works for any GNSS-on-UART node + * regardless of compatible string. */ +static const struct device *gps_uart_dev = DEVICE_DT_GET(DT_BUS(DT_NODELABEL(gnss))); + +/* Send raw bytes to the GPS UART using blocking poll_out. + * Safe to call even though modem_chat/modem_ubx owns the UART pipe: + * uart_poll_out writes one byte at a time through the TX register, + * and GNSS modules are receive-only (no TX contention). + * + * Gated on HAS_GPS_UART alone — writing to the module is safe on every + * UART-attached GNSS. The narrower power-control gate below applies to the + * software *sleep* commands, which are only a fallback for boards that + * cannot cut GPS power. */ +static void gps_uart_send(const uint8_t *data, size_t len) +{ + if (!device_is_ready(gps_uart_dev)) { + return; + } + for (size_t i = 0; i < len; i++) { + uart_poll_out(gps_uart_dev, data[i]); + } + /* Count only configuration traffic — the same helper carries the + * sleep/wake commands, which would otherwise inflate the tally. */ + if (gps_cfg_counting) { + gps_cfg_diag.cmds++; + gps_cfg_diag.bytes += (uint16_t)len; + } +} + +/* --- MediaTek-family (PMTK) configuration --- + * + * PMTK is MediaTek's protocol. It applies to genuine MTK parts (L76B and + * relatives). It does NOT apply to the Quectel L76K/L76KB or Air530Z, which + * are CASIC silicon and speak PCAS — their protocol specification contains no + * PMTK command at all, so these sentences are inert there. Those modules are + * driven by the air530z driver via the GNSS API instead and never reach this + * path. Kept because the RAK WisBlock GPS slot can hold an MTK part. */ /* PMTK353: Enable GPS + GLONASS + Galileo + BeiDou (no QZSS). * Default is GPS-only. Multi-constellation dramatically improves TTFF @@ -391,20 +566,172 @@ static const char pmtk_easy[] = "$PMTK869,1,1*35\r\n"; * Especially useful when GPS antenna is near the SX1262 + SKY66122 PA. */ static const char pmtk_aic[] = "$PMTK286,1*23\r\n"; +/* --- CASIC (PCAS) configuration --- + * + * The Quectel L76K/L76KB (RAK12501) and Air530Z are CASIC silicon: they speak + * neither PMTK nor UBX, so without these sentences such a module in a + * WisBlock slot receives no configuration at all and sits on its factory + * defaults. Boards that always carry one use the air530z driver and the GNSS + * API instead; these are for the generic-NMEA boards whose GPS slot can hold + * any module. + * + * Inert on the other families, same as PMTK and UBX are here. */ + +/* PCAS03: NMEA sentence selection. Field order is + * GGA,GLL,GSA,GSV,RMC,VTG,ZDA,ANT,... — keep GGA + RMC (position, time) and + * add GSV only when the satellite tally needs it, to keep the 9600-baud link + * from spending its budget on sentences nobody parses. */ +#ifdef CONFIG_ZEPHCORE_GPS_SAT_DIAG +static const char pcas_sentences[] = "$PCAS03,1,0,0,1,1,0,0,0,0,0,0,0,0*1F\r\n"; +#else +static const char pcas_sentences[] = "$PCAS03,1,0,0,0,1,0,0,0,0,0,0,0,0*1E\r\n"; +#endif + +/* PCAS04,7 = GPS + BeiDou + GLONASS, everything the part supports. + * (No Galileo on these modules — that is silicon, not configuration.) */ +static const char pcas_constellations[] = "$PCAS04,7*1E\r\n"; + +/* PCAS11: navigation dynamic model. Stored IN THE MODULE and survives + * reflashing the host, so a slot module that previously lived in another + * device can arrive stuck in an automotive or airborne model that quietly + * degrades fixes on a fixed site. See CONFIG_ZEPHCORE_GPS_NAV_MODE. */ +#if CONFIG_ZEPHCORE_GPS_NAV_MODE == 0 +static const char pcas_nav_mode[] = "$PCAS11,0*1D\r\n"; +#elif CONFIG_ZEPHCORE_GPS_NAV_MODE == 1 +static const char pcas_nav_mode[] = "$PCAS11,1*1C\r\n"; +#elif CONFIG_ZEPHCORE_GPS_NAV_MODE == 2 +static const char pcas_nav_mode[] = "$PCAS11,2*1F\r\n"; +#elif CONFIG_ZEPHCORE_GPS_NAV_MODE == 3 +static const char pcas_nav_mode[] = "$PCAS11,3*1E\r\n"; +#elif CONFIG_ZEPHCORE_GPS_NAV_MODE == 4 +static const char pcas_nav_mode[] = "$PCAS11,4*19\r\n"; +#elif CONFIG_ZEPHCORE_GPS_NAV_MODE == 5 +static const char pcas_nav_mode[] = "$PCAS11,5*18\r\n"; +#elif CONFIG_ZEPHCORE_GPS_NAV_MODE == 6 +static const char pcas_nav_mode[] = "$PCAS11,6*1B\r\n"; +#elif CONFIG_ZEPHCORE_GPS_NAV_MODE == 7 +static const char pcas_nav_mode[] = "$PCAS11,7*1A\r\n"; +#endif + +/* PCAS06,0: ask the module to identify itself. A CASIC part answers in-band + * with a $GPTXT sentence — the only readable reply available on this + * otherwise write-only path, and therefore the only positive proof that the + * MCU's TX line reaches the module at all. Captured by the GNSS driver and + * surfaced as `mod=` in "get gps diag". */ +static const char pcas_version_query[] = "$PCAS06,0*1B\r\n"; + +/* u-blox equivalent of the query above. u-blox ignore $PCAS06 entirely, so + * without this a u-blox module always reports "no reply" and the TX-path + * proof — the whole point of asking — is unavailable on those boards. The + * $PUBX,04 reply carries time and clock status, not a version; what matters + * is that a reply arrives at all, which only happens if the module received + * the request. */ +static const char pubx_version_query[] = "$PUBX,04*37\r\n"; + +/* --- u-blox NMEA output trim ($PUBX,40) --- + * + * THE LINK BUDGET IS THE CONSTRAINT, and it is easy to blow past it. At 9600 + * baud only ~960 bytes/s fit. With multi-GNSS enabled a u-blox emits, per + * second: GGA + RMC + GLL + VTG, one GSA per constellation, and a GSV burst + * that grows with satellite count — roughly 1030 bytes/s at ~26 SVs. The + * stream then cannot fit in the second it is generated in, sentences are + * truncated or dropped, and the symptom is not "slow GPS" but a receiver + * that appears to have stopped: no parseable GGA, so no fix, so no position. + * + * Enabling constellations without trimming output is therefore actively + * harmful on a 9600-baud link. We already do exactly this trim for CASIC + * parts via $PCAS03; u-blox had no equivalent, which is the asymmetry this + * fixes. GLL, GSA and VTG are dropped outright — nothing in the driver parses + * them — and GSV is kept only when the satellite tally needs it. + * + * Dropping GLL/GSA/VTG takes ~1030 -> ~650 bytes/s; dropping GSV as well + * takes it to ~160. */ +static const char pubx_off_gll[] = "$PUBX,40,GLL,0,0,0,0,0,0*5C\r\n"; +static const char pubx_off_gsa[] = "$PUBX,40,GSA,0,0,0,0,0,0*4E\r\n"; +static const char pubx_off_vtg[] = "$PUBX,40,VTG,0,0,0,0,0,0*5E\r\n"; +#ifndef CONFIG_ZEPHCORE_GPS_SAT_DIAG +static const char pubx_off_gsv[] = "$PUBX,40,GSV,0,0,0,0,0,0*59\r\n"; +#endif + /* --- u-blox ZOE-M8Q (UBX binary) configuration --- */ -/* UBX-CFG-GNSS: Enable GPS + Galileo + BeiDou + GLONASS. - * ZOE-M8Q defaults to GPS-only. Multi-constellation dramatically improves - * TTFF and fix reliability — more visible satellites in any sky condition. - * 32 tracking channels allocated across 4 active systems. - * SBAS disabled — needs 30-60s to download corrections, useless for our - * quick-fix-then-sleep pattern (companions: 30s, repeaters: 5min). - * QZSS disabled — Japan regional, wastes tracking channels elsewhere. */ +/* UBX-CFG-PRT: force UART1 to 9600 8N1 with BOTH UBX and NMEA enabled in and + * out. Sent first, before anything that depends on the module talking to us. + * + * This exists because the module's port configuration is persistent and not + * necessarily ours. RAK's own RAK12500 example — and any host using the + * SparkFun u-blox library — calls setUART1Output(COM_TYPE_UBX) followed by + * saveConfiguration(), which stores "UBX only, NMEA off" in the module's + * flash. A module that has ever been driven that way stays silent on an + * NMEA-only host forever after, through power cycles and reflashes, and + * presents as a completely dead receiver: no GGA, no fix, no reply to any + * query. Re-asserting the port configuration costs one frame and removes a + * failure mode that is otherwise almost impossible to diagnose from the host. + * + * Limitation: if the module was also saved at a different baud rate, it will + * not parse this frame either. Recovering from that needs a baud scan, which + * the devicetree's fixed current-speed does not currently allow. */ +static const uint8_t ubx_cfg_prt_uart1[] = { + 0xB5, 0x62, 0x06, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC0, 0x08, + 0x00, 0x00, 0x80, 0x25, 0x00, 0x00, 0x03, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x8E, 0x95 +}; + +/* UBX-CFG-GNSS: Enable GPS + Galileo + GLONASS (+ QZSS) on u-blox M8. + * + * Config block layout is gnssId, resTrkCh, maxTrkCh, reserved0, flags[4] — + * EIGHT bytes, and the payload length must be exactly 4 + 8*numConfigBlocks + * or the receiver rejects the whole message. The previous version of this + * frame omitted reserved0, giving 7-byte blocks and a 39-byte payload where + * numConfigBlocks=5 demanded 44, so no u-blox module ever accepted it. + * + * Only THREE major GNSS (GPS/Galileo/GLONASS/BeiDou) can run concurrently on + * M8, so BeiDou is explicitly disabled rather than left alone: if the module + * came up with BeiDou on, enabling three others would make four and the + * message would be refused. + * + * QZSS is enabled even though it is Japan-regional and costs ~3 channels — + * u-blox require GPS and QZSS to be both enabled or both disabled (they + * share L1 C/A), and a mismatch is grounds for rejection. + * + * SBAS stays off: it needs 30-60 s to download corrections, useless for our + * quick-fix-then-sleep pattern (companions 30 s, repeaters 5 min). + * + * numTrkChHw = 0 and numTrkChUse = 0xFF (read-only / "use max available"). + * + * resTrkCh MUST be 0 on the disabled blocks. Reserving tracking channels for + * a system whose enable bit is clear is self-contradictory, and the receiver + * validates CFG-GNSS atomically — one bad block rejects all six. An earlier + * revision left SBAS at 1 and BeiDou at 8 and the whole frame was refused + * (observed on hardware: RAK12500/ZOE-M8Q stayed GPS-only, sys=G8/R0/E0/B0). + * + * NOTE — this is an M8 frame. u-blox 7 parts (MAX-7Q on the RAK1910) have no + * Galileo or BeiDou and use a different sigCfgMask, so they will refuse it + * and stay GPS-only. Sending an M7 frame as well is NOT safe blind: its + * sigCfgMask of 0 would be a signal-disabling value on M8. */ static const uint8_t ubx_cfg_gnss[] = { - 0xB5, 0x62, 0x06, 0x3E, 0x27, 0x00, 0x00, 0x20, 0x20, 0x05, 0x00, 0x08, - 0x10, 0x01, 0x00, 0x01, 0x00, 0x02, 0x04, 0x0A, 0x01, 0x00, 0x01, 0x00, - 0x03, 0x04, 0x0A, 0x01, 0x00, 0x01, 0x00, 0x05, 0x00, 0x03, 0x00, 0x00, - 0x01, 0x00, 0x06, 0x04, 0x0A, 0x01, 0x00, 0x01, 0x00, 0x0E, 0x13 + 0xB5, 0x62, 0x06, 0x3E, 0x34, 0x00, 0x00, 0x00, 0xFF, 0x06, 0x00, 0x08, + 0x10, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x01, 0x01, 0x02, 0x04, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x03, 0x00, + 0x10, 0x00, 0x00, 0x00, 0x01, 0x01, 0x05, 0x00, 0x03, 0x00, 0x01, 0x00, + 0x01, 0x01, 0x06, 0x08, 0x0E, 0x00, 0x01, 0x00, 0x01, 0x01, 0xEE, 0x64 +}; + +/* UBX-CFG-NMEA: switch the NMEA output to version 4.10. + * + * Without this, Galileo and BeiDou satellites cannot be reported at all: the + * $GAGSV and $GBGSV talker IDs only exist from NMEA 4.10, and M8 firmware + * defaults lower. So a fully successful CFG-GNSS would still show zero + * Galileo in "get gps diag" — a reporting limit masquerading as a config + * failure. Mirrors Meshtastic's "enable NMEA 4.10" step (src/gps/ubx.h). + * + * gsvTalkerId = 0 (use the GNSS-specific talker per constellation) is what + * makes the per-constellation tally work — do not set it to 1, which forces + * every GSV onto the main talker and would collapse the tally into GPS. */ +static const uint8_t ubx_cfg_nmea_410[] = { + 0xB5, 0x62, 0x06, 0x17, 0x14, 0x00, 0x00, 0x41, 0x00, 0x02, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x75, 0x57 }; /* UBX-CFG-NAV5: Set 5° minimum satellite elevation. @@ -413,12 +740,15 @@ static const uint8_t ubx_cfg_gnss[] = { * everything including horizon-level junk. * Dynamic model left at factory default (Portable) — works for fixed * repeaters, walking companions, and vehicles alike. - * apply mask 0x0002 = minEl(bit1) only */ + * apply mask 0x0002 = minEl(bit1) only + * + * The trailing checksum was CK_B=0x37 (should be 0xE7) — a one-byte typo + * that made every receiver drop this frame silently, with no NAK. */ static const uint8_t ubx_cfg_nav5_minelev[] = { 0xB5, 0x62, 0x06, 0x24, 0x24, 0x00, 0x02, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58, 0x37 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58, 0xE7 }; /* UBX-CFG-NAVX5: Enable AssistNow Autonomous (AOP). @@ -443,20 +773,73 @@ static const uint8_t ubx_cfg_save[] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x17, 0x31, 0xBF }; -/* Send a PMTK command string (including \r\n). Adds small delay after. */ -static void gps_send_pmtk(const char *cmd) +/* Settle time after a command that makes the receiver restart its navigation + * engine — constellation changes on every family do this. A command issued + * into a restarting engine is simply lost, and because everything here is + * written blind the loss is silent. 20-50 ms was optimistic. */ +#define GPS_CFG_SETTLE_MS 120 +#define GPS_CFG_RESTART_MS 500 + +/* Send an NMEA command string (including \r\n). */ +static void gps_send_nmea(const char *cmd, uint32_t settle_ms = GPS_CFG_SETTLE_MS) { gps_uart_send((const uint8_t *)cmd, strlen(cmd)); - k_msleep(20); /* Let module process before next command */ + k_msleep(settle_ms); } -/* Send a UBX binary frame. Adds small delay after for processing. */ -static void gps_send_ubx(const uint8_t *frame, size_t len) +/* Send a UBX binary frame. */ +static void gps_send_ubx(const uint8_t *frame, size_t len, + uint32_t settle_ms = GPS_CFG_SETTLE_MS) { gps_uart_send(frame, len); - k_msleep(50); /* UBX needs more time to ACK + apply config */ + k_msleep(settle_ms); } +/* u-blox dynamic model for CFG-NAV5, translated from the CASIC-numbered + * CONFIG_ZEPHCORE_GPS_NAV_MODE so one setting drives both families. + * u-blox: 0 portable, 2 stationary, 3 pedestrian, 4 automotive, 5 sea, + * 6-8 airborne. */ +#if CONFIG_ZEPHCORE_GPS_NAV_MODE == 1 +#define UBX_DYNMODEL 2 +#elif CONFIG_ZEPHCORE_GPS_NAV_MODE == 2 +#define UBX_DYNMODEL 3 +#elif CONFIG_ZEPHCORE_GPS_NAV_MODE == 3 +#define UBX_DYNMODEL 4 +#elif CONFIG_ZEPHCORE_GPS_NAV_MODE == 4 +#define UBX_DYNMODEL 5 +#elif CONFIG_ZEPHCORE_GPS_NAV_MODE >= 5 +#define UBX_DYNMODEL 6 +#elif CONFIG_ZEPHCORE_GPS_NAV_MODE == 0 +#define UBX_DYNMODEL 0 +#endif + +/* Send CFG-NAV5 with the dynamic model patched in and the checksum redone. + * A stationary model on a fixed node suppresses position wander and lets the + * receiver apply much tighter velocity assumptions; leaving a roof-mounted + * repeater on the factory Portable model throws that away. Patched at runtime + * rather than as eight hard-coded frames — one place to get the checksum + * right instead of eight. */ +#ifdef UBX_DYNMODEL +static void gps_send_ubx_nav5(void) +{ + uint8_t f[sizeof(ubx_cfg_nav5_minelev)]; + + memcpy(f, ubx_cfg_nav5_minelev, sizeof(f)); + f[6] = 0x03; /* mask: dyn (bit0) + minEl (bit1) */ + f[8] = UBX_DYNMODEL; /* dynModel */ + + uint8_t ck_a = 0, ck_b = 0; + for (size_t i = 2; i < sizeof(f) - 2; i++) { + ck_a = (uint8_t)(ck_a + f[i]); + ck_b = (uint8_t)(ck_b + ck_a); + } + f[sizeof(f) - 2] = ck_a; + f[sizeof(f) - 1] = ck_b; + + gps_send_ubx(f, sizeof(f)); +} +#endif + /* Configure the GPS module with optimal settings for a mesh repeater. * Sends both PMTK (Quectel) and UBX (u-blox) commands — the module that * isn't present ignores bytes it doesn't understand. */ @@ -464,21 +847,91 @@ static void gps_configure_via_uart(void) { LOG_INF("GPS: Configuring via UART (PMTK + UBX dual-protocol)"); - /* --- Quectel L76K (PMTK) --- */ - gps_send_pmtk(pmtk_constellations); - gps_send_pmtk(pmtk_easy); - gps_send_pmtk(pmtk_aic); + gps_cfg_diag.cmds = 0; + gps_cfg_diag.bytes = 0; + gps_cfg_counting = true; + + /* --- MediaTek-family (PMTK) --- */ + gps_send_nmea(pmtk_constellations, GPS_CFG_RESTART_MS); + gps_send_nmea(pmtk_easy); + gps_send_nmea(pmtk_aic); LOG_INF("GPS: PMTK config sent (constellations, EASY, AIC)"); - /* --- u-blox ZOE-M8Q (UBX) --- */ - gps_send_ubx(ubx_cfg_gnss, sizeof(ubx_cfg_gnss)); + /* --- CASIC (PCAS) --- + * Version query last, so its $GPTXT reply is not stepped on by a + * constellation restart. */ + gps_send_nmea(pcas_sentences); + gps_send_nmea(pcas_constellations, GPS_CFG_RESTART_MS); +#if CONFIG_ZEPHCORE_GPS_NAV_MODE >= 0 + gps_send_nmea(pcas_nav_mode); +#endif + gps_send_nmea(pcas_version_query); + LOG_INF("GPS: PCAS config sent (sentences, constellations, nav mode, version query)"); + + /* --- u-blox ZOE-M8Q (UBX) --- + * NMEA 4.10 goes first: it governs whether the constellations enabled + * by the next frame can be *reported* at all. CFG-GNSS restarts the + * navigation engine, so it gets the long settle before the frames that + * follow it — at 50 ms they were being issued into a restarting + * receiver. CFG-CFG stays last so it only persists whatever was + * actually accepted; note that makes a rejected configuration sticky + * too, which is why a frame bug here survives power cycles. */ + /* Make sure NMEA output is even switched on before anything else — a + * module saved as UBX-only by a previous host is otherwise mute. */ + gps_send_ubx(ubx_cfg_prt_uart1, sizeof(ubx_cfg_prt_uart1), GPS_CFG_RESTART_MS); + + /* Trim the NMEA stream BEFORE enabling more constellations — the extra + * GSV traffic must have somewhere to fit. */ + gps_send_nmea(pubx_off_gll); + gps_send_nmea(pubx_off_gsa); + gps_send_nmea(pubx_off_vtg); +#ifndef CONFIG_ZEPHCORE_GPS_SAT_DIAG + gps_send_nmea(pubx_off_gsv); +#endif + gps_send_ubx(ubx_cfg_nmea_410, sizeof(ubx_cfg_nmea_410)); + gps_send_ubx(ubx_cfg_gnss, sizeof(ubx_cfg_gnss), GPS_CFG_RESTART_MS); +#ifdef UBX_DYNMODEL + gps_send_ubx_nav5(); +#else gps_send_ubx(ubx_cfg_nav5_minelev, sizeof(ubx_cfg_nav5_minelev)); +#endif gps_send_ubx(ubx_cfg_navx5_aop, sizeof(ubx_cfg_navx5_aop)); gps_send_ubx(ubx_cfg_save, sizeof(ubx_cfg_save)); - LOG_INF("GPS: UBX config sent (multi-GNSS, 5° min elev, AOP, saved to flash)"); + /* Ask a u-blox to say something back — the TX-path proof for this + * family, sent last so the reply is not stepped on by a restart. */ + gps_send_nmea(pubx_version_query); + LOG_INF("GPS: UBX config sent (NMEA 4.10, multi-GNSS, 5° min elev, AOP, saved)"); + + gps_cfg_counting = false; } #endif /* HAS_GPS_UART */ +/* Re-run module configuration on a GPS enable, but only when diagnostics are + * armed — this is a deliberate, operator-triggered action, not a normal path. + * + * Only the raw-UART path is re-runnable. gnss_configure()'s API path goes + * through modem_chat_run_script(), which is safe at boot only: after a GPIO + * power restore the chip needs ~300 ms and calling it here deadlocks the main + * thread. So on API-driver boards this records nothing new and "get gps diag" + * keeps reporting the boot-time result, which is the honest answer. */ +static void gps_diag_maybe_reconfigure(void) +{ + if (!gps_diag_on || gnss_configured || gnss_dev == NULL) { + return; + } +#if HAS_GPS_UART + if (gps_cfg_diag.path == GPS_CFG_UART || gps_cfg_diag.path == GPS_CFG_NEVER) { + /* The module has just been powered; give it time to boot before + * clocking configuration at it (same ~300 ms the modem needs). */ + k_msleep(300); + gps_cfg_diag.path = GPS_CFG_UART; + gps_cfg_diag.at_ms = k_uptime_get(); + gps_configure_via_uart(); + } +#endif + gnss_configured = true; +} + static void gnss_configure(void) { if (gnss_configured || gnss_dev == NULL) { @@ -496,15 +949,21 @@ static void gnss_configure(void) systems = GNSS_SYSTEM_GPS | GNSS_SYSTEM_GLONASS | GNSS_SYSTEM_BEIDOU; ret = gnss_set_enabled_systems(gnss_dev, systems); } + gps_cfg_diag.api_ret = (int8_t)ret; + gps_cfg_diag.at_ms = k_uptime_get(); + if (ret == 0) { LOG_INF("GPS: Multi-constellation enabled via GNSS API"); + gps_cfg_diag.path = GPS_CFG_API; } else if (ret == -ENOSYS || ret == -ENOTSUP) { #if HAS_GPS_UART /* gnss-nmea-generic is a passive listener — no GNSS API. * Configure everything via direct UART commands instead. */ + gps_cfg_diag.path = GPS_CFG_UART; gps_configure_via_uart(); #else LOG_INF("GPS: No GNSS API and no UART access — using module defaults"); + gps_cfg_diag.path = GPS_CFG_BLIND; #endif } else { LOG_WRN("GPS: Failed to set constellations: %d", ret); @@ -514,6 +973,7 @@ static void gnss_configure(void) /* Set 1Hz fix rate (explicit, don't rely on chip defaults) */ ret = gnss_set_fix_rate(gnss_dev, 1000); + gps_cfg_diag.rate_ret = (int8_t)ret; if (ret == 0) { LOG_INF("GPS: Fix rate set to 1Hz"); } else if (ret != -ENOSYS && ret != -ENOTSUP) { @@ -538,11 +998,8 @@ static void gnss_configure(void) * - T1000-E: P1.11 (GPS_EN), P0.8 (GPS_VRTC_EN), P1.15 (GPS_RESET), P1.12 (GPS_SLEEP_INT) * - Wio Tracker L1: P1.09 (GPS power, shared with luatos,air530z on-off-gpios) */ -#if DT_NODE_EXISTS(DT_ALIAS(gps_enable)) +#if HAS_GPS_POWER_CONTROL static const struct gpio_dt_spec gps_enable_gpio = GPIO_DT_SPEC_GET(DT_ALIAS(gps_enable), gpios); -#define HAS_GPS_POWER_CONTROL 1 -#else -#define HAS_GPS_POWER_CONTROL 0 #endif /* GPS powered by a PMU regulator rail instead of a discrete enable GPIO (e.g. @@ -551,15 +1008,12 @@ static const struct gpio_dt_spec gps_enable_gpio = GPIO_DT_SPEC_GET(DT_ALIAS(gps * switch driven by enable/disable; the duty-cycle standby/wake uses software * sleep/wake (UART) and leaves the rail up, so the regulator is only toggled on * the (unguarded) enable/disable/boot paths — never per duty cycle. */ -#if DT_NODE_EXISTS(DT_CHOSEN(zephcore_gps_power)) +#if HAS_GPS_POWER_REGULATOR static const struct device *const gps_power_reg = DEVICE_DT_GET(DT_CHOSEN(zephcore_gps_power)); /* Tracks our intended rail state so enable/disable stay balanced (idempotent). * Starts true: the rail is `regulator-boot-on`, so it is already up at boot. */ static bool gps_reg_enabled = true; -#define HAS_GPS_POWER_REGULATOR 1 -#else -#define HAS_GPS_POWER_REGULATOR 0 #endif /* AXP2101 backup (button-battery) charger — feeds the GPS receiver's V_BCKP @@ -860,24 +1314,24 @@ void gps_power_off_for_shutdown(void) * 3V3_S rail is shared with the LoRa FEM), we send vendor-specific UART * commands to put the GPS module into low-power mode. * - * Strategy: send BOTH Quectel and u-blox sleep commands — the module that + * Strategy: send BOTH MediaTek and u-blox sleep commands — the module that * isn't present simply ignores the bytes it doesn't understand. * - * - Quectel L76K (RAK1910): $PMTK161,0*28\r\n → standby (~1mA), wake on UART - * - u-blox ZOE-M8Q (RAK12500): UBX-RXM-PMREQ → backup (~7µA), wake on UART + * - MediaTek (e.g. L76B): $PMTK161,0*28\r\n → standby (~1mA), wake on UART + * - u-blox ZOE-M8Q (RAK12500): UBX-RXM-PMREQ → backup (~7µA), wake on UART + * + * Neither reaches a CASIC part (L76K/L76KB/Air530Z): those ignore PMTK, UBX + * and PCAS12 sleep commands alike — verified on hardware, which is why the + * boards carrying them duty-cycle with a power GPIO instead. Note also that + * the RAK1910 is a u-blox MAX-7Q, not an L76K, despite older comments here. * * Wake: any byte on UART wakes both modules from their low-power modes. * After wake, the module resumes outputting NMEA autonomously. */ -/* Get the UART device that the GNSS module is connected to. - * Works for any GNSS-on-UART node regardless of compatible string. */ -#if DT_NODE_HAS_STATUS(DT_NODELABEL(gnss), okay) && \ - DT_NODE_HAS_STATUS(DT_BUS(DT_NODELABEL(gnss)), okay) -#define HAS_GPS_UART 1 -#else -#define HAS_GPS_UART 0 -#endif +/* HAS_GPS_UART, gps_uart_dev and gps_uart_send are defined near the top of + * this file (see "GPS Feature Detection") — they are needed by the boot-time + * module configuration, which runs long before this section. */ /* ========== GNSS UART Suspend/Resume (device PM) ========== * nRF UARTE only. An armed UARTE RX holds HFCLK (~0.5-1 mA on nRF52840) @@ -921,11 +1375,6 @@ void gps_power_off_for_shutdown(void) #define HAS_GPS_UART_PM 0 #endif -#if HAS_GPS_UART && \ - (HAS_GPS_UART_PM || (!HAS_GPS_POWER_CONTROL && !HAS_GPS_POWER_REGULATOR)) -static const struct device *gps_uart_dev = DEVICE_DT_GET(DT_BUS(DT_NODELABEL(gnss))); -#endif - /* Suspend/resume the GNSS UART. Main thread only (like all GPS power * paths — pm_device_action_run() calls the driver synchronously). * Ordering: resume BEFORE powering the module / sending the wake byte; @@ -965,22 +1414,13 @@ static inline void gps_uart_set_power(bool on) { ARG_UNUSED(on); } #endif #if HAS_GPS_UART && !HAS_GPS_POWER_CONTROL && !HAS_GPS_POWER_REGULATOR -/* Send raw bytes to the GPS UART using blocking poll_out. - * Safe to call even though modem_chat/modem_ubx owns the UART pipe: - * uart_poll_out writes one byte at a time through the TX register, - * and GNSS modules are receive-only (no TX contention). */ -static void gps_uart_send(const uint8_t *data, size_t len) -{ - if (!device_is_ready(gps_uart_dev)) { - return; - } - for (size_t i = 0; i < len; i++) { - uart_poll_out(gps_uart_dev, data[i]); - } -} +/* gps_uart_send() lives near the top of the file — see "GPS Feature + * Detection". Only the sleep/wake commands below are gated on this board + * having no hardware GPS power control. */ -/* Quectel L76K: $PMTK161,0*28\r\n → enter standby mode - * Module stops NMEA output and draws ~1mA. Wakes on any UART RX byte. */ +/* MediaTek parts: $PMTK161,0*28\r\n → enter standby mode + * Module stops NMEA output and draws ~1mA. Wakes on any UART RX byte. + * Inert on CASIC parts (L76K and relatives) — they have no such command. */ static const uint8_t pmtk_standby[] = "$PMTK161,0*28\r\n"; /* u-blox ZOE-M8Q: UBX-RXM-PMREQ → enter backup mode @@ -1012,7 +1452,7 @@ static void gps_software_sleep(void) { LOG_INF("GPS: Sending software sleep (PMTK + UBX)"); - /* Quectel L76K standby */ + /* MediaTek standby */ gps_uart_send(pmtk_standby, sizeof(pmtk_standby) - 1); /* exclude null terminator */ /* Small delay between commands — let the first one drain */ @@ -1540,6 +1980,7 @@ void gps_enable(bool enable) * Do NOT call modem_chat_run_script() here — the chip needs * ~300ms to boot after GPIO power restore and calling it * immediately deadlocks the main thread. */ + gps_diag_maybe_reconfigure(); /* Bounded first-acquisition window, then the normal duty cycle — * unless always-on (interval 0), where GPS never sleeps. */ @@ -1781,6 +2222,93 @@ void gps_request_fresh_fix(void) #endif } +void gps_set_diag(bool on) +{ + gps_diag_on = on; +#if HAS_GNSS + if (on) { + /* Re-arm configuration so the next GPS enable ("gps off" then + * "gps on") actually re-runs it and refreshes the record. */ + gnss_configured = false; + } +#endif +} + +bool gps_get_diag(void) +{ + return gps_diag_on; +} + +void gps_get_diag_report(char *buf, size_t len) +{ +#if HAS_GNSS + static const char *const path_str[] = { "never", "api", "uart", "blind" }; + uint8_t path = gps_cfg_diag.path; + const char *pname = (path < ARRAY_SIZE(path_str)) ? path_str[path] : "?"; + + if (path == GPS_CFG_NEVER) { + snprintf(buf, len, "diag=%s cfg=never-run (enable, then 'gps off'/'gps on')", + gps_diag_on ? "on" : "off"); + return; + } + + uint32_t age_s = (uint32_t)((k_uptime_get() - gps_cfg_diag.at_ms) / 1000); + size_t n = (size_t)snprintf(buf, len, "diag=%s cfg=%s age=%us", + gps_diag_on ? "on" : "off", pname, age_s); + if (n >= len) { + return; + } + + /* Module identity, when the driver could obtain it. Present means the + * module answered us, i.e. the TX path is good; absent on a driver that + * asks is a strong hint the module never hears our configuration. */ + if (zephcore_gnss_version_get != NULL) { + char ver[40]; + if (zephcore_gnss_version_get(ver, sizeof(ver)) > 0) { + n += (size_t)snprintf(buf + n, len - n, " mod=%s", ver); + } else { + n += (size_t)snprintf(buf + n, len - n, " mod=no-reply"); + } + if (n >= len) { + return; + } + } + + if (path == GPS_CFG_UART) { + /* Bytes actually clocked out to the module. Note these are sent + * blind — this proves transmission, not acceptance. Constellation + * tallies below are the acceptance evidence. */ + n += (size_t)snprintf(buf + n, len - n, " sent=%u/%uB", + gps_cfg_diag.cmds, gps_cfg_diag.bytes); + } else if (path == GPS_CFG_API) { + n += (size_t)snprintf(buf + n, len - n, " sys_ret=%d rate_ret=%d", + gps_cfg_diag.api_ret, gps_cfg_diag.rate_ret); + } + if (n >= len) { + return; + } + +#ifdef CONFIG_ZEPHCORE_GPS_SAT_DIAG + k_mutex_lock(&gps_mutex, K_FOREVER); + /* Age out constellations that have stopped reporting, so a stale count + * is never presented as current. */ + int64_t now_ms = k_uptime_get(); + uint8_t shown[5]; + for (int i = 0; i < 5; i++) { + shown[i] = ((now_ms - sat_seen_ms[i]) > SAT_TALLY_STALE_MS) ? 0 : sat_count[i]; + } + k_mutex_unlock(&gps_mutex); + snprintf(buf + n, len - n, " sys=G%u/R%u/E%u/B%u/?%u", + shown[0], shown[1], shown[2], shown[3], shown[4]); +#else + snprintf(buf + n, len - n, " (build without GPS_SAT_DIAG: no per-constellation proof)"); +#endif +#else + ARG_UNUSED(gps_diag_on); + snprintf(buf, len, "no GNSS on this board"); +#endif +} + void gps_get_state_info(struct gps_state_info *info) { memset(info, 0, sizeof(*info)); @@ -1803,6 +2331,19 @@ void gps_get_state_info(struct gps_state_info *info) } else if (gps_current_state == GPS_STATE_ACQUIRING) { info->next_search_s = 0; /* Searching right now */ } + +#ifdef CONFIG_ZEPHCORE_GPS_SAT_DIAG + /* Same staleness rule as the diag report — never present a count for a + * constellation that has stopped reporting. */ + k_mutex_lock(&gps_mutex, K_FOREVER); + int64_t sat_now = k_uptime_get(); + uint8_t *dst[5] = { &info->sats_gps, &info->sats_glonass, &info->sats_galileo, + &info->sats_beidou, &info->sats_other }; + for (int i = 0; i < 5; i++) { + *dst[i] = ((sat_now - sat_seen_ms[i]) > SAT_TALLY_STALE_MS) ? 0 : sat_count[i]; + } + k_mutex_unlock(&gps_mutex); +#endif #endif } diff --git a/zephcore/adapters/gps/ZephyrGPSManager.h b/zephcore/adapters/gps/ZephyrGPSManager.h index 47b4bb1..907bf2f 100644 --- a/zephcore/adapters/gps/ZephyrGPSManager.h +++ b/zephcore/adapters/gps/ZephyrGPSManager.h @@ -31,8 +31,33 @@ struct gps_state_info { uint16_t satellites; /* Current/last satellite count */ uint32_t last_fix_age_s; /* Seconds since last validated fix (UINT32_MAX = never) */ uint32_t next_search_s; /* Seconds until next search (0 = searching now or off) */ + /* Tracked satellites per constellation, from GSV talker IDs. Populated + * only when CONFIG_ZEPHCORE_GPS_SAT_DIAG is enabled; all zero otherwise. + * Proves whether multi-constellation configuration actually took — a + * module left in its GPS-only default reports sats_gps only. */ + uint8_t sats_gps; + uint8_t sats_glonass; + uint8_t sats_galileo; + uint8_t sats_beidou; + uint8_t sats_other; }; +/* ===== GPS configuration diagnostics (runtime toggle, RAM only) ===== + * + * Module configuration (PMTK/UBX or the GNSS API) is sent blind at boot and + * runs exactly once. With diag on, the next GPS enable re-runs it and records + * what actually happened, so an operator can power-cycle the GPS ("gps off" + * then "gps on") and read the outcome back over the CLI on a release build — + * no debug logging, no reflash. + * + * Not persisted: a diagnostic, not a setting. Clears on reboot. */ +void gps_set_diag(bool on); +bool gps_get_diag(void); + +/* Render the last configuration attempt as a single CLI line. Always + * succeeds; reports "never run" if configuration has not happened yet. */ +void gps_get_diag_report(char *buf, size_t len); + /* GPS enable callback - called when GPS is enabled/disabled (for power management) */ typedef void (*gps_enable_callback_t)(bool enabled); void gps_set_enable_callback(gps_enable_callback_t cb); diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index d49eebb..90f51d5 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -1102,6 +1102,9 @@ void RepeaterMesh::formatGpsStatsReply(char* reply) { "on state=%s sats=%u no fix", state, gsi.satellites); } + + /* Per-constellation tally is deliberately NOT appended here — "get gps" + * has to fit a LoRa reply. It lives in "get gps diag" instead. */ } void RepeaterMesh::savePrefs() { diff --git a/zephcore/boards/common/repeater.conf b/zephcore/boards/common/repeater.conf index 3170e2c..afcc46c 100644 --- a/zephcore/boards/common/repeater.conf +++ b/zephcore/boards/common/repeater.conf @@ -22,3 +22,11 @@ CONFIG_ENTROPY_GENERATOR=y # Observer-style WiFi+MQTT reporting from repeater role. # Runtime-configured and reboot-applied. CONFIG_ZEPHCORE_REPEATER_UPLINK=n + +# ========== GNSS dynamic model ========== +# Repeaters and room servers don't move. Tell CASIC-family GNSS modules so: +# the dynamic model is stored IN THE MODULE and survives reflashing the host, +# so a board that previously ran other GNSS firmware can arrive stuck in an +# automotive or airborne model that quietly degrades fixes on a fixed site. +# 1 = stationary. Only applies to boards using the air530z-family driver. +CONFIG_GNSS_LUATOS_AIR530Z_NAV_MODE=1 diff --git a/zephcore/boards/nrf52840/rak3401_1watt/rak3401_1watt.dts b/zephcore/boards/nrf52840/rak3401_1watt/rak3401_1watt.dts index b6122e6..c9b9a20 100644 --- a/zephcore/boards/nrf52840/rak3401_1watt/rak3401_1watt.dts +++ b/zephcore/boards/nrf52840/rak3401_1watt/rak3401_1watt.dts @@ -69,8 +69,16 @@ /* ---- Power control ---- */ /* 3V3_S rail — must be HIGH for GPS and IO-slot peripherals. - * WB_IO2 (P1.02) doubles as GPS_RESET on the RAK1910 slot, - * so it must remain HIGH when GPS is active. */ + * + * Never duty-cycle this rail on THIS board: WB_IO2 (P1.02) also gates the + * 5V boost feeding the SKY66122 PA, so dropping it to save GPS power kills + * the 1W transmit path. That is why it is `regulator-boot-on` and is NOT + * wired up as `chosen zephcore,gps-power`. + * + * (This pin also lands on RESET_N of a RAK1910/RAK12500 in Slot A, which is + * why it is sometimes called "GPS reset". It is not one — u-blox specify + * RESET_N as a reset only, not an enable, since the SiP draws significant + * current while held in reset. See MeshCore PR #3051.) */ v3v3s_enable: v3v3s-enable { compatible = "regulator-fixed"; regulator-name = "v3v3s-enable"; diff --git a/zephcore/boards/nrf52840/rak4631/board.conf b/zephcore/boards/nrf52840/rak4631/board.conf index 3bb4ced..593b3d0 100644 --- a/zephcore/boards/nrf52840/rak4631/board.conf +++ b/zephcore/boards/nrf52840/rak4631/board.conf @@ -6,7 +6,7 @@ # # Hardware: # - SX1262 LoRa on SPI0 (built into RAK4631 module) -# - RAK1910 GPS on UART0 (Slot A) - u-blox MAX-7Q at 9600 baud +# - RAK1910 GPS on UART1 (Slot A, P0.15/P0.16 = Serial1) - u-blox MAX-7Q at 9600 baud # - Battery ADC on AIN3 (P0.05) # - I2C0 for WisBlock sensors (auto-detected): # - RAK1901 (SHTC3) at 0x70 diff --git a/zephcore/boards/nrf52840/rak4631/board.overlay b/zephcore/boards/nrf52840/rak4631/board.overlay index 60cc09b..9b1de92 100644 --- a/zephcore/boards/nrf52840/rak4631/board.overlay +++ b/zephcore/boards/nrf52840/rak4631/board.overlay @@ -9,7 +9,7 @@ * Supports: P25Q16H, MX25R1635F, W25Q16, GD25Q16, IS25LP080, ZD25WQ16 * If no QSPI flash present, falls back to internal flash only. * - * GPS: RAK1910 (u-blox MAX-7Q) on UART0 at 9600 baud (Slot A) + * GPS: RAK1910 (u-blox MAX-7Q) on UART1 at 9600 baud (Slot A) * Sensors: Auto-detected on I2C0 - supports: * - RAK1901 (SHTC3) at 0x70 * - RAK1902 (LPS22HB) at 0x5C @@ -119,6 +119,26 @@ output-high; line-name = "sx1262-power-en"; }; + + /* 3V3_S rail enable — WB_IO2 / P1.02, HIGH = on. This is the switched + * 3.3V bus on every WisBlock base board: it feeds the GPS slot, the + * sensor slot and the OLED. Without this the pin sits at its reset + * default (input/float) and the GPS module is simply never powered. + * + * Hogged rather than exposed as `chosen zephcore,gps-power`: the rail is + * not GPS-private, so duty-cycling it for the GPS would also drop the + * I2C sensors and the display. + * + * Note this pin is often described as "GPS reset" (it lands on the + * RAK1910/RAK12500 RESET_N in Slot A). Do not drive it as a reset — + * u-blox specify RESET_N as a reset only, not an enable (the SiP draws + * significant current while held in reset). See MeshCore PR #3051. */ + v3v3s_en { + gpio-hog; + gpios = <2 GPIO_ACTIVE_HIGH>; + output-high; + line-name = "3v3s-enable"; + }; }; /* LEDs are active-high on this board (stock RAK4631 DTS used active-low). */ @@ -131,16 +151,29 @@ }; /* - * GPS on UART0 - RAK1910 (u-blox MAX-7Q) at 9600 baud - * Connect to Slot A (UART pins P0.19 RX, P0.20 TX) + * GPS on UART1 - RAK1910 (u-blox MAX-7Q) at 9600 baud, Slot A. + * + * The WisBlock GPS slot is wired to the core module's Serial1 = P0.15 (MCU RX + * <- GPS TX) / P0.16 (MCU TX -> GPS RX), which is Zephyr's uart1 on this board + * (uart0 is P0.19/P0.20 = Serial2, which the GPS slot does not reach). Matches + * MeshCore variants/rak4631 (PIN_SERIAL1_RX 15 / _TX 16) and ZephCore's sibling + * RAK-family boards gat562_30s and rak3401_1watt. uart1 is also nrf-uarte + * upstream, whereas uart0 is the legacy nrf-uart. */ -&uart0 { +&uart1 { current-speed = <9600>; gnss: gnss-nmea-generic { compatible = "gnss-nmea-generic"; }; }; +/* uart0 (P0.19/P0.20) is unused — console is USB CDC ACM and the GPS is on + * uart1. Upstream leaves it enabled at 115200; turn it off so it does not + * hold pins or a UARTE instance. */ +&uart0 { + status = "disabled"; +}; + /* I2C0: optional SSD1306 + sensors */ &i2c0 { ssd1306: ssd1306@3c { diff --git a/zephcore/boards/nrf52840/thinknode_m6/thinknode_m6.dts b/zephcore/boards/nrf52840/thinknode_m6/thinknode_m6.dts index a76a9e6..4634ca1 100644 --- a/zephcore/boards/nrf52840/thinknode_m6/thinknode_m6.dts +++ b/zephcore/boards/nrf52840/thinknode_m6/thinknode_m6.dts @@ -9,8 +9,8 @@ * - SX1262 LoRa radio on SPI1 (DIO2 RF switch, DIO3 TCXO 3.3V, 22dBm) * NSS=P1.12, DIO1=P1.06, RESET=P1.10, BUSY=P1.11 * - GPS L76K on UART0 (9600 baud, CASIC/Air530Z — luatos,air530z driver) - * Control: GPS_EN=P0.06 (active-HIGH), GPS_RESET=P0.29 (active-LOW), - * GPS_STANDBY=P0.30, GPS_PPS=P0.31 + * Control: GPS_EN=P0.06 (active-HIGH), GPS_RESET=P0.29 (REINIT — must + * float, see below), GPS_STANDBY=P0.30, GPS_PPS=P0.31 * - Battery ADC on AIN4 (P0.28), 1.75:1 voltage divider * ADC_CTRL enable pin: P0.11 (gpio-hog, always HIGH) * - LEDs: RED=P0.12, BLUE=P0.07 (both active-HIGH, LED_STATE_ON=HIGH) @@ -138,9 +138,8 @@ }; }; - /* GPS_RESET (P0.29) is driven statically HIGH (de-asserted) by a gpio-hog - * in &gpio0 below — see the note there. Arduino MeshCore does the same - * (variant.cpp: digitalWrite(PIN_GPS_RESET, HIGH), never pulsed). */ + /* GPS_RESET (P0.29) is the module's REINIT line and is intentionally left + * floating (unconfigured input) — see the note in &gpio0 below. */ aliases { led0 = &led_red; @@ -154,8 +153,8 @@ * L76K (CASIC/Air530Z) is driven by the luatos,air530z driver via * on-off-gpios, and exposing those aliases would push M6 down the * T1000-E GPS sequence in ZephyrGPSManager (HAS_GPS_SLEEP/RESET). - * GPS_RESET (P0.29) and GPS_STANDBY (P0.30) are hogged HIGH in - * &gpio0 instead — static drive, matching Arduino MeshCore. */ + * GPS_STANDBY (P0.30) is hogged HIGH in &gpio0; GPS_RESET (P0.29) + * is left floating — see the note there. */ gps-enable = &gps_enable_pin; }; }; @@ -199,13 +198,20 @@ * (see the / node) — not hogged HIGH — so the VBAT divider only draws * current during a battery sample, matching Arduino MeshCore. */ - /* GPS control lines held static, mirroring Arduino MeshCore variant.cpp - * (GPS_EN is driven separately by the luatos,air530z on-off-gpios): - * GPS_STANDBY (P0.30) HIGH — L76K runs, never parked in standby. - * GPS_RESET (P0.29) HIGH — reset de-asserted (active-LOW line). + /* GPS control lines (GPS_EN is driven separately by the luatos,air530z + * on-off-gpios): + * GPS_STANDBY (P0.30) hogged HIGH — L76K runs, never parked in standby. + * GPS_RESET (P0.29) NOT driven — this is the module's REINIT line and + * must float. Driving it (in particular HIGH, which this board used to + * do via a gpio-hog on the mistaken premise that HIGH was a de-asserted + * active-LOW reset) holds the L76K silent: it emits no NMEA at all and + * GPS is never detected. Upstream MeshCore bench-tested a sealed M6 + * (passive NMEA capture): pin driven HIGH = 0 bytes, floating = full + * stream — so they set GPS_RESET = -1 (5c534c43). Meshtastic likewise + * leaves the pin an input. * M6 has no physical GPS switch; runtime on/off is via GPS_EN - * (gps-enable alias) under software/CLI control. Hogs are used so the - * pins are actively driven at boot without needing a consumer — the + * (gps-enable alias) under software/CLI control. A hog is used for STANDBY + * so the pin is actively driven at boot without needing a consumer — the * gpio-leds nodes are not bound by any driver in this build. */ gps-standby-hog { gpio-hog; @@ -213,13 +219,6 @@ output-high; line-name = "GPS Standby"; }; - - gps-reset-hog { - gpio-hog; - gpios = <29 GPIO_ACTIVE_HIGH>; - output-high; - line-name = "GPS Reset"; - }; }; &gpio1 { @@ -233,7 +232,8 @@ * via on-off-gpios (P0.06 = GPS_EN, shared with the gps-enable alias used for * runtime power control in ZephyrGPSManager). No zephyr,deferred-init: the * driver powers the module on-off-gpios HIGH at init before opening the pipe. - * GPS_RESET (P0.29) and GPS_STANDBY (P0.30) are hogged HIGH in &gpio0. */ + * GPS_STANDBY (P0.30) is hogged HIGH in &gpio0; GPS_RESET (P0.29) is left + * floating (driving it silences the module — see the note there). */ &uart0 { compatible = "nordic,nrf-uarte"; status = "okay"; diff --git a/zephcore/boards/nrf52840/wio_tracker_l1/board.overlay b/zephcore/boards/nrf52840/wio_tracker_l1/board.overlay index edcb086..3134fd8 100644 --- a/zephcore/boards/nrf52840/wio_tracker_l1/board.overlay +++ b/zephcore/boards/nrf52840/wio_tracker_l1/board.overlay @@ -5,10 +5,16 @@ * Battery: AIN7 = P0.31, voltage divider 2:1. */ -/* GPS power control - P1.09 - * The luatos,air530z driver also manages this pin via on-off-gpios, - * but we need our own alias so ZephyrSensorManager can toggle power - * independently (driver PM suspend/resume hangs on modem_pipe ops). */ +/* GPS standby control — P1.09 = the L76K's WAKEUP pin (schematic net + * GNSS_WAKEUP). This is NOT a supply switch: per the L76K hardware design the + * pin only enters/exits Standby, where the RF is off but the core and I/O + * domain stay powered. VCC is never removed, so ephemeris/almanac/RTC are + * retained and each wake is a warm start. Active low with an internal + * pull-up, which is why the module runs even with this pin unconfigured. + * + * The luatos,air530z driver also manages this pin via on-off-gpios, but we + * need our own alias so ZephyrSensorManager can toggle it independently + * (driver PM suspend/resume hangs on modem_pipe ops). */ / { gps_en: gps-enable { compatible = "gpio-leds"; diff --git a/zephcore/boards/nrf52840/wio_tracker_l1/wio_tracker_l1_nrf52840.dts b/zephcore/boards/nrf52840/wio_tracker_l1/wio_tracker_l1_nrf52840.dts index 5b9d3c1..227d848 100644 --- a/zephcore/boards/nrf52840/wio_tracker_l1/wio_tracker_l1_nrf52840.dts +++ b/zephcore/boards/nrf52840/wio_tracker_l1/wio_tracker_l1_nrf52840.dts @@ -188,9 +188,11 @@ /* NOTE: buzzer-off gpio-hog REMOVED - buzzer pin is now managed by PWM * driver. When PWM is not driving, the pin is low (no current draw). */ - /* NOTE: GPS enable hog REMOVED - Feb 6 working version had no GPIO hog - * and GPS worked fine. The L76KB module may have internal pull-up or - * the Arduino bootloader leaves it enabled. */ + /* NOTE: GPS enable hog REMOVED — GPS works fine without it, and the + * reason is now confirmed rather than guessed: P1.09 is the L76K's + * WAKEUP pin, which the L76K hardware design specifies as active low + * "with pull-up internally". Left unconfigured it floats high and the + * module stays in Continuous mode. */ }; /* PWM0 for buzzer */ @@ -207,7 +209,8 @@ /* Quectel L76KB GNSS on UART0 - 9600 baud (PCAS/CASIC protocol) * Uses Zephyr luatos,air530z driver (same PCAS command set for * constellation config via PCAS04, fix rate via PCAS02, etc.) - * on-off-gpios = GPS enable pin (P1.09), managed by driver. */ + * on-off-gpios = P1.09, the module's WAKEUP/standby pin (see &gpio1 note and + * board.overlay) — driver drives it high for Continuous, low for Standby. */ &uart0 { compatible = "nordic,nrf-uarte"; status = "okay"; diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 10a7238..e55f147 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -573,6 +573,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } } else if (memcmp(config, "rxduty", 6) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->rx_duty_cycle); + } else if (memcmp(config, "gps diag", 8) == 0) { + // What the last module-configuration attempt actually did. + reply[0] = '>'; reply[1] = ' '; + gps_get_diag_report(reply + 2, CLI_REPLY_SIZE - 2); } else if (memcmp(config, "gps duty", 8) == 0) { uint32_t s = gps_get_poll_interval_sec(); // now-effective value if (s == 0) strcpy(reply, "> always on (0)"); @@ -1066,6 +1070,26 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else { strcpy(reply, "Error: must be 0, 1, on, or off"); } + } else if (memcmp(config, "gps diag", 8) == 0) { + // set gps diag <0|1|on|off> — arm module-configuration reporting. + // Not persisted: clears on reboot, by design. + const char* arg = config + 8; + while (*arg == ' ') arg++; + int val = -1; + if (memcmp(arg, "on", 2) == 0) val = 1; + else if (memcmp(arg, "off", 3) == 0) val = 0; + else if (arg[0] == '0' || arg[0] == '1') val = atoi(arg); + if (val == 0 || val == 1) { + gps_set_diag(val == 1); + if (val == 1) { + strcpy(reply, "OK - gps diag on; run 'gps off' then 'gps on', " + "then 'get gps diag'"); + } else { + strcpy(reply, "OK - gps diag off"); + } + } else { + strcpy(reply, "usage: set gps diag <0|1|on|off>"); + } } else if (memcmp(config, "gps duty", 8) == 0) { // set gps duty | default (0 = always on) const char* arg = config + 8; diff --git a/zephcore/patches/zephyr/0005-gnss-air530z-easy.patch b/zephcore/patches/zephyr/0005-gnss-air530z-easy.patch deleted file mode 100644 index 774d79c..0000000 --- a/zephcore/patches/zephyr/0005-gnss-air530z-easy.patch +++ /dev/null @@ -1,119 +0,0 @@ -diff --git a/drivers/gnss/Kconfig.luatos_air530z b/drivers/gnss/Kconfig.luatos_air530z -index c5d09261da5..ffc218b22e2 100644 ---- a/drivers/gnss/Kconfig.luatos_air530z -+++ b/drivers/gnss/Kconfig.luatos_air530z -@@ -28,4 +28,16 @@ config GNSS_LUATOS_AIR530Z_SATELLITES_COUNT - the device is actually tracking, just how many of those can - be reported in the satellites callback. - -+config GNSS_LUATOS_AIR530Z_EASY -+ bool "Enable EASY (Embedded Assist System) for faster TTFF" -+ default y -+ help -+ Enable MediaTek EASY (Embedded Assist System) mode via PMTK869 -+ command. EASY caches predicted ephemeris in the GNSS module's -+ internal flash, reducing Time-To-First-Fix from 15-45s (cold) -+ to 1-3s (warm). Sent on every driver init (boot and PM resume). -+ The setting persists in GNSS flash, so resending is a no-op. -+ Compatible with L76K/L76KB and other MediaTek-based GNSS chips -+ that accept PMTK commands alongside PCAS. -+ - endif -diff --git a/drivers/gnss/gnss_luatos_air530z.c b/drivers/gnss/gnss_luatos_air530z.c -index 74708edbd62..7de70ad93c8 100644 ---- a/drivers/gnss/gnss_luatos_air530z.c -+++ b/drivers/gnss/gnss_luatos_air530z.c -@@ -9,7 +9,6 @@ - #include - #include - #include --#include - #include - #include - -@@ -36,6 +35,13 @@ MODEM_CHAT_SCRIPT_CMDS_DEFINE(init_script_cmds, - /* receive only GGA and RMC NMEA messages */ - MODEM_CHAT_SCRIPT_CMD_RESP_NONE("$PCAS03,1,0,0,0,1,0,0,0,0,0,0,0,0*1E", 10), - #endif -+#if IS_ENABLED(CONFIG_GNSS_LUATOS_AIR530Z_EASY) -+ /* Enable EASY (Embedded Assist System) — caches predicted ephemeris -+ * in GNSS internal flash for 1-3s warm start instead of 15-45s cold. -+ * PMTK869,1,1 = Set EASY, Enable. Persists across power cycles. -+ * L76K/L76KB accept PMTK alongside PCAS (both MediaTek MT33xx). */ -+ MODEM_CHAT_SCRIPT_CMD_RESP_NONE("$PMTK869,1,1*36", 10), -+#endif - ); - - MODEM_CHAT_SCRIPT_NO_ABORT_DEFINE(init_script, init_script_cmds, NULL, 5); -@@ -217,54 +223,11 @@ static int gnss_luatos_air530z_init(const struct device *dev) - return 0; - } - --static int luatos_air530z_pm_resume(const struct device *dev) --{ -- struct gnss_luatos_air530z_data *data = dev->data; -- int ret; -- -- ret = modem_pipe_open(data->uart_pipe, K_SECONDS(10)); -- if (ret < 0) { -- return ret; -- } -- -- ret = modem_chat_attach(&data->chat, data->uart_pipe); -- if (ret < 0) { -- modem_pipe_close(data->uart_pipe, K_SECONDS(10)); -- return ret; -- } -- -- ret = modem_chat_run_script(&data->chat, &init_script); -- if (ret < 0) { -- modem_pipe_close(data->uart_pipe, K_SECONDS(10)); -- return ret; -- } -- -- return 0; --} -- --static int luatos_air530z_pm_action(const struct device *dev, enum pm_device_action action) --{ -- struct gnss_luatos_air530z_data *data = dev->data; -- const struct gnss_luatos_air530z_config *config = dev->config; -- int ret = -ENOTSUP; -- -- switch (action) { -- case PM_DEVICE_ACTION_SUSPEND: -- gpio_pin_set_dt(&config->on_off_gpio, 0); -- ret = modem_pipe_close(data->uart_pipe, K_SECONDS(10)); -- break; -- -- case PM_DEVICE_ACTION_RESUME: -- gpio_pin_set_dt(&config->on_off_gpio, 1); -- ret = luatos_air530z_pm_resume(dev); -- break; -- -- default: -- break; -- } -- -- return ret; --} -+/* PM hooks intentionally removed — the L76K ignores $PMTK161,0 (AT6558- -+ * based, not genuine MediaTek), so PM-driven software sleep can never -+ * work; standby drives the FORCE_ON GPIO and the app suspends the UART -+ * instead (ZephyrGPSManager, main thread only). PM-less also keeps -+ * modem_chat scripts out of PM callbacks (deadlock risk off sysworkq). */ - - static int luatos_air530z_set_fix_rate(const struct device *dev, uint32_t fix_interval_ms) - { -@@ -356,10 +319,8 @@ static DEVICE_API(gnss, gnss_api) = { - .dynamic_separators_buf = {',', '*'}, \ - }; \ - \ -- PM_DEVICE_DT_INST_DEFINE(inst, luatos_air530z_pm_action); \ -- \ - DEVICE_DT_INST_DEFINE(inst, gnss_luatos_air530z_init, \ -- PM_DEVICE_DT_INST_GET(inst), \ -+ NULL, \ - &gnss_luatos_air530z_data_##inst, \ - &gnss_luatos_air530z_cfg_##inst, \ - POST_KERNEL, CONFIG_GNSS_INIT_PRIORITY, &gnss_api); diff --git a/zephcore/patches/zephyr/0005-gnss-config-and-version-query.patch b/zephcore/patches/zephyr/0005-gnss-config-and-version-query.patch new file mode 100644 index 0000000..a8b56b4 --- /dev/null +++ b/zephcore/patches/zephyr/0005-gnss-config-and-version-query.patch @@ -0,0 +1,371 @@ +diff --git a/drivers/gnss/Kconfig.luatos_air530z b/drivers/gnss/Kconfig.luatos_air530z +index c5d09261da5..94ff2848b88 100644 +--- a/drivers/gnss/Kconfig.luatos_air530z ++++ b/drivers/gnss/Kconfig.luatos_air530z +@@ -28,4 +28,39 @@ config GNSS_LUATOS_AIR530Z_SATELLITES_COUNT + the device is actually tracking, just how many of those can + be reported in the satellites callback. + ++config GNSS_LUATOS_AIR530Z_NAV_MODE ++ int "Navigation dynamic model ($PCAS11)" ++ range -1 7 ++ default 3 ++ help ++ Dynamic model sent to the module at init: 0 = portable, ++ 1 = stationary, 2 = pedestrian, 3 = automotive, 4 = sea, ++ 5-7 = airborne. Set -1 to send nothing and leave the module on ++ whatever it already has. ++ ++ Set this even though it looks like a tuning detail. The dynamic ++ model is stored IN THE MODULE and survives reflashing the host, so ++ a board that previously ran other GNSS firmware can arrive stuck in ++ an airborne or automotive model that quietly degrades fixes for a ++ stationary node. Repeaters and room servers should use 1. ++ ++ $PCAS11 is not in the L76K protocol specification (which documents ++ only PCAS01/02/03/04/10); it is an undocumented CASIC command these ++ parts accept in practice. Mode 1 is confirmed against a published ++ CASIC command reference. ++ ++config GNSS_LUATOS_AIR530Z_VERSION_QUERY ++ bool "Query the module version at init ($PCAS06)" ++ default y ++ help ++ Send $PCAS06,0 at init and capture the module's identification ++ string from the $GPTXT reply it sends back in-band. ++ ++ Besides identifying the part, this is the only positive proof ++ available on this transport that the MCU's TX line actually reaches ++ the module — every other command in the init script is written ++ blind, so a module that hears nothing is indistinguishable from one ++ that hears everything and ignores it. The captured string is ++ reported by the application's GPS diagnostics. ++ + endif +diff --git a/drivers/gnss/gnss_luatos_air530z.c b/drivers/gnss/gnss_luatos_air530z.c +index 74708edbd62..7fc5e4768f1 100644 +--- a/drivers/gnss/gnss_luatos_air530z.c ++++ b/drivers/gnss/gnss_luatos_air530z.c +@@ -9,7 +9,6 @@ + #include + #include + #include +-#include + #include + #include + +@@ -28,16 +27,75 @@ LOG_MODULE_REGISTER(luatos_air530z, CONFIG_GNSS_LOG_LEVEL); + #define CHAT_RECV_BUF_SZ 256 + #define CHAT_ARGV_SZ 32 + ++/* Inter-command delay. These parts are CASIC silicon on a 9600-baud link and ++ * some commands (constellation selection especially) restart the navigation ++ * engine, so the module needs real settling time between sentences. 10 ms is ++ * not enough — a command issued into a restarting engine is simply lost. ++ */ ++#define AIR530Z_CMD_DELAY_MS 250 ++ ++/* Navigation dynamic model — $PCAS11,. Undocumented in the L76K ++ * protocol specification (which lists only PCAS01/02/03/04/10) but accepted ++ * by these parts in practice. Mode 1 = stationary is confirmed against a ++ * published CASIC command reference; the remaining values follow the usual ++ * dynamic-model ladder. ++ * ++ * Worth setting explicitly even though it looks like a tuning detail: the ++ * dynamic model PERSISTS IN THE MODULE across firmware flashes, so a board ++ * that previously ran other GNSS firmware can arrive stuck in an airborne or ++ * automotive model that quietly degrades fixes for a stationary node. ++ */ ++#if CONFIG_GNSS_LUATOS_AIR530Z_NAV_MODE == 0 ++#define AIR530Z_NAV_CMD "$PCAS11,0*1D" ++#elif CONFIG_GNSS_LUATOS_AIR530Z_NAV_MODE == 1 ++#define AIR530Z_NAV_CMD "$PCAS11,1*1C" ++#elif CONFIG_GNSS_LUATOS_AIR530Z_NAV_MODE == 2 ++#define AIR530Z_NAV_CMD "$PCAS11,2*1F" ++#elif CONFIG_GNSS_LUATOS_AIR530Z_NAV_MODE == 3 ++#define AIR530Z_NAV_CMD "$PCAS11,3*1E" ++#elif CONFIG_GNSS_LUATOS_AIR530Z_NAV_MODE == 4 ++#define AIR530Z_NAV_CMD "$PCAS11,4*19" ++#elif CONFIG_GNSS_LUATOS_AIR530Z_NAV_MODE == 5 ++#define AIR530Z_NAV_CMD "$PCAS11,5*18" ++#elif CONFIG_GNSS_LUATOS_AIR530Z_NAV_MODE == 6 ++#define AIR530Z_NAV_CMD "$PCAS11,6*1B" ++#elif CONFIG_GNSS_LUATOS_AIR530Z_NAV_MODE == 7 ++#define AIR530Z_NAV_CMD "$PCAS11,7*1A" ++#endif ++ + MODEM_CHAT_SCRIPT_CMDS_DEFINE(init_script_cmds, + #if CONFIG_GNSS_SATELLITES + /* receive only GGA, RMC and GSV NMEA messages */ +- MODEM_CHAT_SCRIPT_CMD_RESP_NONE("$PCAS03,1,0,0,1,1,0,0,0,0,0,0,0,0*1F", 10), ++ MODEM_CHAT_SCRIPT_CMD_RESP_NONE("$PCAS03,1,0,0,1,1,0,0,0,0,0,0,0,0*1F", ++ AIR530Z_CMD_DELAY_MS), + #else + /* receive only GGA and RMC NMEA messages */ +- MODEM_CHAT_SCRIPT_CMD_RESP_NONE("$PCAS03,1,0,0,0,1,0,0,0,0,0,0,0,0*1E", 10), ++ MODEM_CHAT_SCRIPT_CMD_RESP_NONE("$PCAS03,1,0,0,0,1,0,0,0,0,0,0,0,0*1E", ++ AIR530Z_CMD_DELAY_MS), ++#endif ++#ifdef AIR530Z_NAV_CMD ++ MODEM_CHAT_SCRIPT_CMD_RESP_NONE(AIR530Z_NAV_CMD, AIR530Z_CMD_DELAY_MS), ++#endif ++#if IS_ENABLED(CONFIG_GNSS_LUATOS_AIR530Z_VERSION_QUERY) ++ /* Ask the module to identify itself. The reply arrives in-band as an ++ * ordinary $GPTXT NMEA sentence (see the unsolicited match below), so ++ * it costs nothing to read and needs no raw access to the UART. ++ * ++ * This is the only positive proof available on this transport that the ++ * MCU's TX line actually reaches the module: every other command here ++ * is written blind. If configuration appears to have no effect, a ++ * missing version string says the module never heard any of it. ++ */ ++ MODEM_CHAT_SCRIPT_CMD_RESP_NONE("$PCAS06,0*1B", AIR530Z_CMD_DELAY_MS), + #endif + ); + ++/* NOTE: configuration is deliberately NOT persisted to the module ($PCAS00). ++ * The init script re-sends it on every boot, and a saved configuration is ++ * sticky — including a wrong one, which then survives reflashing and has to ++ * be hunted down on hardware. ++ */ ++ + MODEM_CHAT_SCRIPT_NO_ABORT_DEFINE(init_script, init_script_cmds, NULL, 5); + + struct gnss_luatos_air530z_config { +@@ -73,12 +131,57 @@ struct gnss_luatos_air530z_data { + struct k_sem lock; + }; + ++#if IS_ENABLED(CONFIG_GNSS_LUATOS_AIR530Z_VERSION_QUERY) ++/* Module identification string, captured from the $PCAS06 reply. ++ * ++ * $GPTXT,01,01,02,SW=,... — the interesting field is the one ++ * starting "SW=". Stored verbatim (truncated) rather than parsed: this is a ++ * diagnostic, and different parts word the reply differently. ++ */ ++static char air530z_version[40]; ++ ++static void air530z_gptxt_callback(struct modem_chat *chat, char **argv, uint16_t argc, ++ void *user_data) ++{ ++ ARG_UNUSED(chat); ++ ARG_UNUSED(user_data); ++ ++ /* Take the first field that looks like a software-version token. */ ++ for (uint16_t i = 1; i < argc; i++) { ++ if (strncmp(argv[i], "SW=", 3) == 0) { ++ strncpy(air530z_version, argv[i] + 3, sizeof(air530z_version) - 1); ++ air530z_version[sizeof(air530z_version) - 1] = '\0'; ++ LOG_INF("GNSS module version: %s", air530z_version); ++ return; ++ } ++ } ++} ++ ++/* Exported for application-level diagnostics. Returns the number of ++ * characters written, or 0 if the module has not identified itself — which ++ * is itself the useful signal (no reply => the module is not receiving). ++ */ ++__weak int zephcore_gnss_version_get(char *buf, size_t len) ++{ ++ if (buf == NULL || len == 0 || air530z_version[0] == '\0') { ++ return 0; ++ } ++ ++ strncpy(buf, air530z_version, len - 1); ++ buf[len - 1] = '\0'; ++ return (int)strlen(buf); ++} ++#endif /* CONFIG_GNSS_LUATOS_AIR530Z_VERSION_QUERY */ ++ + MODEM_CHAT_MATCHES_DEFINE(unsol_matches, + MODEM_CHAT_MATCH_WILDCARD("$??GGA,", ",*", gnss_nmea0183_match_gga_callback), + MODEM_CHAT_MATCH_WILDCARD("$??RMC,", ",*", gnss_nmea0183_match_rmc_callback), + #if CONFIG_GNSS_SATELLITES + MODEM_CHAT_MATCH_WILDCARD("$??GSV,", ",*", gnss_nmea0183_match_gsv_callback), + #endif ++#if IS_ENABLED(CONFIG_GNSS_LUATOS_AIR530Z_VERSION_QUERY) ++ MODEM_CHAT_MATCH_WILDCARD("$??TXT,", ",*", air530z_gptxt_callback), ++#endif + ); + + static void luatos_air530z_lock(const struct device *dev) +@@ -217,54 +320,11 @@ static int gnss_luatos_air530z_init(const struct device *dev) + return 0; + } + +-static int luatos_air530z_pm_resume(const struct device *dev) +-{ +- struct gnss_luatos_air530z_data *data = dev->data; +- int ret; +- +- ret = modem_pipe_open(data->uart_pipe, K_SECONDS(10)); +- if (ret < 0) { +- return ret; +- } +- +- ret = modem_chat_attach(&data->chat, data->uart_pipe); +- if (ret < 0) { +- modem_pipe_close(data->uart_pipe, K_SECONDS(10)); +- return ret; +- } +- +- ret = modem_chat_run_script(&data->chat, &init_script); +- if (ret < 0) { +- modem_pipe_close(data->uart_pipe, K_SECONDS(10)); +- return ret; +- } +- +- return 0; +-} +- +-static int luatos_air530z_pm_action(const struct device *dev, enum pm_device_action action) +-{ +- struct gnss_luatos_air530z_data *data = dev->data; +- const struct gnss_luatos_air530z_config *config = dev->config; +- int ret = -ENOTSUP; +- +- switch (action) { +- case PM_DEVICE_ACTION_SUSPEND: +- gpio_pin_set_dt(&config->on_off_gpio, 0); +- ret = modem_pipe_close(data->uart_pipe, K_SECONDS(10)); +- break; +- +- case PM_DEVICE_ACTION_RESUME: +- gpio_pin_set_dt(&config->on_off_gpio, 1); +- ret = luatos_air530z_pm_resume(dev); +- break; +- +- default: +- break; +- } +- +- return ret; +-} ++/* PM hooks intentionally removed — the L76K ignores $PMTK161,0 (AT6558- ++ * based, not genuine MediaTek), so PM-driven software sleep can never ++ * work; standby drives the FORCE_ON GPIO and the app suspends the UART ++ * instead (ZephyrGPSManager, main thread only). PM-less also keeps ++ * modem_chat scripts out of PM callbacks (deadlock risk off sysworkq). */ + + static int luatos_air530z_set_fix_rate(const struct device *dev, uint32_t fix_interval_ms) + { +@@ -356,10 +416,8 @@ static DEVICE_API(gnss, gnss_api) = { + .dynamic_separators_buf = {',', '*'}, \ + }; \ + \ +- PM_DEVICE_DT_INST_DEFINE(inst, luatos_air530z_pm_action); \ +- \ + DEVICE_DT_INST_DEFINE(inst, gnss_luatos_air530z_init, \ +- PM_DEVICE_DT_INST_GET(inst), \ ++ NULL, \ + &gnss_luatos_air530z_data_##inst, \ + &gnss_luatos_air530z_cfg_##inst, \ + POST_KERNEL, CONFIG_GNSS_INIT_PRIORITY, &gnss_api); +diff --git a/drivers/gnss/gnss_nmea0183.c b/drivers/gnss/gnss_nmea0183.c +index d4e7b502e39..c8e8f2da2d2 100644 +--- a/drivers/gnss/gnss_nmea0183.c ++++ b/drivers/gnss/gnss_nmea0183.c +@@ -52,6 +52,14 @@ static int gnss_system_from_gsv_header_args(const struct gsv_header_args *args, + case 'B': + *sv_system = GNSS_SYSTEM_BEIDOU; + break; ++ case 'D': ++ /* "$BDGSV" — the pre-4.10 BeiDou talker, still emitted by CASIC ++ * receivers (Quectel L76K and relatives) and by u-blox left at ++ * their default NMEA version. Without this the entire group is ++ * rejected and BeiDou silently reads as zero satellites. ++ * 'D' at index 2 is unique to the BD talker. */ ++ *sv_system = GNSS_SYSTEM_BEIDOU; ++ break; + case 'P': + *sv_system = GNSS_SYSTEM_GPS; + break; +diff --git a/drivers/gnss/gnss_nmea_generic.c b/drivers/gnss/gnss_nmea_generic.c +index bef7989828d..5e5a90716fb 100644 +--- a/drivers/gnss/gnss_nmea_generic.c ++++ b/drivers/gnss/gnss_nmea_generic.c +@@ -50,12 +50,83 @@ struct gnss_nmea_generic_data { + uint8_t *chat_argv[CHAT_ARGV_SZ]; + }; + ++/* Module identification, captured from a $GPTXT sentence. ++ * ++ * This driver is a passive listener — every configuration command the ++ * application sends is written blind, and nothing on this transport confirms ++ * the module received any of it. A CASIC part answers a $PCAS06 version query ++ * in-band with $GPTXT, and u-blox emit $GNTXT boot banners unprompted, so a ++ * captured string here is the one positive proof that the MCU's TX line ++ * reaches the module (or, for the u-blox banner, that RX is alive and the ++ * part is identified). Absence is the diagnostic signal. ++ */ ++static char nmea_generic_version[40]; ++ ++static void nmea_generic_txt_callback(struct modem_chat *chat, char **argv, uint16_t argc, ++ void *user_data) ++{ ++ ARG_UNUSED(chat); ++ ARG_UNUSED(user_data); ++ ++ /* Prefer an explicit software-version token; otherwise keep the last ++ * text field, which is where u-blox put their boot banner. */ ++ for (uint16_t i = 1; i < argc; i++) { ++ if (strncmp(argv[i], "SW=", 3) == 0) { ++ strncpy(nmea_generic_version, argv[i] + 3, ++ sizeof(nmea_generic_version) - 1); ++ nmea_generic_version[sizeof(nmea_generic_version) - 1] = '\0'; ++ LOG_INF("GNSS module version: %s", nmea_generic_version); ++ return; ++ } ++ } ++ ++ if (argc > 4 && nmea_generic_version[0] == '\0') { ++ strncpy(nmea_generic_version, argv[4], sizeof(nmea_generic_version) - 1); ++ nmea_generic_version[sizeof(nmea_generic_version) - 1] = '\0'; ++ } ++} ++ ++/* u-blox reply to a $PUBX poll. u-blox ignore the CASIC $PCAS06 query, so ++ * without this a u-blox module always reads as "no reply" and the TX-path ++ * proof is unavailable on exactly the boards that most need it. The content ++ * is time/status, not a version — what matters is that a reply arrived at ++ * all, which is what proves the module received our command. */ ++static void nmea_generic_pubx_callback(struct modem_chat *chat, char **argv, uint16_t argc, ++ void *user_data) ++{ ++ ARG_UNUSED(chat); ++ ARG_UNUSED(argv); ++ ARG_UNUSED(argc); ++ ARG_UNUSED(user_data); ++ ++ if (nmea_generic_version[0] == '\0') { ++ strcpy(nmea_generic_version, "u-blox"); ++ LOG_INF("GNSS module replied to $PUBX poll (u-blox family)"); ++ } ++} ++ ++/* Exported for application-level diagnostics. Returns characters written, or ++ * 0 if the module never identified itself. Weak so a board that also builds ++ * another GNSS driver exporting this symbol still links. */ ++__weak int zephcore_gnss_version_get(char *buf, size_t len) ++{ ++ if (buf == NULL || len == 0 || nmea_generic_version[0] == '\0') { ++ return 0; ++ } ++ ++ strncpy(buf, nmea_generic_version, len - 1); ++ buf[len - 1] = '\0'; ++ return (int)strlen(buf); ++} ++ + MODEM_CHAT_MATCHES_DEFINE(unsol_matches, + MODEM_CHAT_MATCH_WILDCARD("$??GGA,", ",*", gnss_nmea0183_match_gga_callback), + MODEM_CHAT_MATCH_WILDCARD("$??RMC,", ",*", gnss_nmea0183_match_rmc_callback), + #if CONFIG_GNSS_SATELLITES + MODEM_CHAT_MATCH_WILDCARD("$??GSV,", ",*", gnss_nmea0183_match_gsv_callback), + #endif ++ MODEM_CHAT_MATCH_WILDCARD("$??TXT,", ",*", nmea_generic_txt_callback), ++ MODEM_CHAT_MATCH("$PUBX", ",*", nmea_generic_pubx_callback), + ); + + static int gnss_nmea_generic_resume(const struct device *dev) diff --git a/zephcore/tools/crypto_bench/CMakeLists.txt b/zephcore/tools/crypto_bench/CMakeLists.txt new file mode 100644 index 0000000..0701469 --- /dev/null +++ b/zephcore/tools/crypto_bench/CMakeLists.txt @@ -0,0 +1,113 @@ +# SPDX-License-Identifier: MIT +# ZephCore crypto benchmark — is our crypto worth accelerating? +# +# Times every crypto primitive the mesh uses (SHA-256, AES-128-ECB, +# HMAC-SHA256, Ed25519, X25519), then divides the per-packet total by LoRa +# airtime. See HANDOVER_crypto_hw_accel.md — this tool is the "measure first" +# step that gates every option in that document. +# +# It compiles src/Utils.cpp, src/Identity.cpp and src/Packet.cpp straight from +# the main tree, so what is measured is the shipping implementation, not a +# copy of it. +# +# Build: +# west build -b rak4631 zephcore/tools/crypto_bench --pristine +# west build -b xiao_esp32s3/esp32s3/procpu zephcore/tools/crypto_bench --pristine +# +# Iteration counts: +# west build ... zephcore/tools/crypto_bench -- -DCRYPTO_BENCH_ITERS=1000 +# west build ... zephcore/tools/crypto_bench -- -DCRYPTO_BENCH_ITERS_SLOW=100 +# +# Group-channel worst case (default 40 = MAX_CHANNELS): +# west build ... zephcore/tools/crypto_bench -- -DCRYPTO_BENCH_CHANNELS=8 +# +# Flash: nRF52 — drag build/zephyr/zephyr.uf2 onto the UF2 drive +# ESP32 — west flash --esp-device COMx +# +# The tool never reads or writes stored identity, and never brings up the +# radio, flash or BLE. Reflash normal firmware afterwards. + +cmake_minimum_required(VERSION 3.20.0) + +# Reuse the main project's custom board definitions +list(APPEND BOARD_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../..) + +# Strip the board qualifier (e.g. "/esp32s3/procpu") to get the base name +string(REPLACE "/" ";" BOARD_PARTS ${BOARD}) +list(GET BOARD_PARTS 0 BOARD_BASE) + +# Tool-local board overlay ONLY — deliberately not the node's board.overlay, +# which declares /fstab, radio, sensor and partition nodes this tool does not +# enable (CONFIG_FLASH=n, SPI=n, I2C=n) and would fail on an undefined +# lfs_partition label. Same arrangement as tools/rng_selftest. +set(_BENCH_OVERLAY + "${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_BASE}/board.overlay") +if(EXISTS "${_BENCH_OVERLAY}") + set(EXTRA_DTC_OVERLAY_FILE "${_BENCH_OVERLAY}" CACHE STRING "" FORCE) + message(STATUS "crypto_bench overlay: ${_BENCH_OVERLAY}") +else() + message(STATUS "crypto_bench: using upstream board console (no overlay)") +endif() + +# Per-board conf (UF2 output, code partition) — nRF52 needs it, ESP32 must not +# have it. See boards/rak4631/board.conf. +set(_BENCH_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_BASE}/board.conf") +if(EXISTS "${_BENCH_CONF}") + set(EXTRA_CONF_FILE "${_BENCH_CONF}" CACHE STRING "" FORCE) + message(STATUS "crypto_bench conf: ${_BENCH_CONF}") +endif() + +find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) +project(zephcore_crypto_bench) + +# The point of this tool is to time the SHIPPING code. These three files are +# compiled from the main tree, at the same optimisation level the node uses, +# so the numbers transfer. +# +# ZephyrRNG.cpp is deliberately NOT linked: nothing here needs entropy (all +# test vectors are fixed, on purpose — see src/main.cpp), and pulling it in +# would drag CONFIG_ENTROPY_GENERATOR and hwinfo into a config that otherwise +# has no reason for them. The one thing it does that this tool needs — +# psa_crypto_init() — main.cpp calls itself. +target_sources(app PRIVATE + src/main.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../src/Utils.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../src/Identity.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../src/Packet.cpp +) + +# Ed25519 / X25519 backend — the real one Identity.cpp uses. +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../../lib/monocypher monocypher) +target_link_libraries(app PRIVATE monocypher) + +target_include_directories(app PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/../../include + ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/monocypher +) + +# Iteration counts. The headline figure is the MINIMUM over N, so N mainly +# buys confidence that no run escaped interference; 200 is plenty for the +# symmetric primitives. +if(NOT DEFINED CRYPTO_BENCH_ITERS) + set(CRYPTO_BENCH_ITERS 200) +endif() + +# Ed25519 is ~1000x slower than SHA-256. 20 iterations keeps the total runtime +# in seconds; the variance on an operation that long is negligible anyway. +if(NOT DEFINED CRYPTO_BENCH_ITERS_SLOW) + set(CRYPTO_BENCH_ITERS_SLOW 20) +endif() + +# Worst-case group-channel loop (Mesh.cpp:398 tries every configured channel). +# Default matches MAX_CHANNELS. +if(NOT DEFINED CRYPTO_BENCH_CHANNELS) + set(CRYPTO_BENCH_CHANNELS 40) +endif() + +target_compile_definitions(app PRIVATE + CRYPTO_BENCH_ITERS=${CRYPTO_BENCH_ITERS} + CRYPTO_BENCH_ITERS_SLOW=${CRYPTO_BENCH_ITERS_SLOW} + CRYPTO_BENCH_CHANNELS=${CRYPTO_BENCH_CHANNELS} +) +message(STATUS "crypto_bench: N=${CRYPTO_BENCH_ITERS} slow=${CRYPTO_BENCH_ITERS_SLOW} channels=${CRYPTO_BENCH_CHANNELS}") diff --git a/zephcore/tools/crypto_bench/boards/rak4631/board.conf b/zephcore/tools/crypto_bench/boards/rak4631/board.conf new file mode 100644 index 0000000..fc83c50 --- /dev/null +++ b/zephcore/tools/crypto_bench/boards/rak4631/board.conf @@ -0,0 +1,16 @@ +# RAK4631 — crypto benchmark board config +# +# UF2 output lives here rather than in prj.conf: it is nRF52-with-bootloader +# specific, and on ESP32 uf2conv fails outright (no UF2 family for that SoC). +# +# CONFIG_FLASH is enabled ONLY so the devicetree flash partitions resolve — +# uf2conv derives its base address from CONFIG_FLASH_LOAD_OFFSET, which +# USE_DT_CODE_PARTITION reads from the `zephyr,code-partition` chosen node. +# With flash off the offset comes out empty and uf2conv aborts with +# "argument -b/--base: expected one argument". +# +# This does NOT let the tool touch stored data: CONFIG_FILE_SYSTEM stays off, +# nothing mounts /lfs, and the tool never reads or writes an identity. +CONFIG_FLASH=y +CONFIG_USE_DT_CODE_PARTITION=y +CONFIG_BUILD_OUTPUT_UF2=y diff --git a/zephcore/tools/crypto_bench/boards/rak4631/board.overlay b/zephcore/tools/crypto_bench/boards/rak4631/board.overlay new file mode 100644 index 0000000..85c724b --- /dev/null +++ b/zephcore/tools/crypto_bench/boards/rak4631/board.overlay @@ -0,0 +1,49 @@ +/* + * RAK4631 — crypto benchmark console overlay + * SPDX-License-Identifier: MIT + * + * Minimal ON PURPOSE. The node's own board.overlay is NOT reused here: it + * declares an /fstab entry, radio, sensors and partitions, none of which this + * tool enables (CONFIG_FLASH=n, CONFIG_SPI=n ...) — pulling it in fails the + * build on an undefined lfs_partition label. Same reasoning as the LR1110 + * updater's per-board overlays. + * + * RAK4631 connects USB straight to the nRF52840, so the console has to ride + * USB CDC ACM; uart0's physical pins are not reachable on a plain WisBlock + * base board. + */ + +/* Same flash layout as the node build. + * + * NOT just for UF2 packaging: RAK4631 boots through the Adafruit nRF52 + * bootloader, so the application must be LINKED at the bootloader's app + * offset. Without a code partition the image links at 0 and would not run even + * if uf2conv managed to package it (it does not — the missing + * CONFIG_FLASH_LOAD_OFFSET is what produces "argument -b/--base: expected one + * argument"). SoftDevice v6 layout, matching boards/nrf52840/rak4631. + * + * The delete-nodes are required by that dtsi's own contract (see its header): + * it redefines partitions the upstream board DTS already labels, and without + * removing those first the build fails with a duplicate 'storage_partition' + * label. */ +/delete-node/ &boot_partition; +/delete-node/ &slot0_partition; +/delete-node/ &slot1_partition; +/delete-node/ &storage_partition; + +#include "../../../../boards/common/nrf52_partitions_sdv6.dtsi" + +/ { + chosen { + zephyr,code-partition = &code_partition; + zephyr,console = &cdc_acm_uart; + zephyr,shell-uart = &cdc_acm_uart; + }; + +}; + +&zephyr_udc0 { + cdc_acm_uart: cdc_acm_uart { + compatible = "zephyr,cdc-acm-uart"; + }; +}; diff --git a/zephcore/tools/crypto_bench/prj.conf b/zephcore/tools/crypto_bench/prj.conf new file mode 100644 index 0000000..282d5f3 --- /dev/null +++ b/zephcore/tools/crypto_bench/prj.conf @@ -0,0 +1,82 @@ +# ZephCore crypto benchmark — minimal config. +# +# Mirrors the crypto block of boards/common/zephcore_common.conf EXACTLY, so +# what is timed is the same PSA backend selection the node builds. Any +# divergence here (a different PSA_WANT set, a different mbedTLS config) would +# change which implementation mbedTLS picks and silently invalidate the whole +# measurement. + +# ========== Crypto (PSA) — copied verbatim from zephcore_common.conf ========== +CONFIG_MBEDTLS=y +CONFIG_MBEDTLS_PSA_CRYPTO_C=y +CONFIG_PSA_WANT_ALG_SHA_256=y +CONFIG_PSA_WANT_ALG_ECB_NO_PADDING=y +CONFIG_PSA_WANT_KEY_TYPE_AES=y +CONFIG_PSA_WANT_ALG_HMAC=y +CONFIG_PSA_WANT_KEY_TYPE_HMAC=y + +# Portable CPU cycle counter — the measurement instrument. Same symbol the +# node enables, so enabling it here does not perturb anything relative to the +# node's own timing. +CONFIG_TIMING_FUNCTIONS=y + +# sys_reboot() — Utils::cryptoPanicReboot() calls it. Kept live rather than +# stubbed: if a benchmarked primitive ever panics, the tool should reboot the +# way the node would, not quietly print a number that means nothing. +CONFIG_REBOOT=y + +# C++ — Utils, Identity and Packet are C++ +CONFIG_CPP=y +CONFIG_STD_CPP17=y +CONFIG_REQUIRES_FULL_LIBC=y + +# Console only, no log subsystem. All output is printk, which is synchronous — +# important here, because an asynchronous log backend would do work between +# timed regions and inflate the mean. +CONFIG_SERIAL=y +CONFIG_CONSOLE=y +CONFIG_UART_CONSOLE=y +CONFIG_PRINTK=y +CONFIG_LOG=n + +# No floating-point printf: every figure this tool prints is integer math on +# purpose (see print_result / airtime_us). Leaving FP support off keeps the +# image small and rules out an FP-emulation call inside a timed region. +CONFIG_CBPRINTF_FP_SUPPORT=n + +# CONFIG_ASSERT stays off: on ESP32 the Espressif blob trips false kswap.h +# assertions, and this tool has no reason to differ from the node. +CONFIG_ASSERT=n + +# Main stack: Ed25519 verify through Monocypher plus the static test vectors +# and the ~200-byte scratch buffers in Utils::encrypt. 4096 is generous; the +# node runs 8192. +CONFIG_MAIN_STACK_SIZE=4096 + +# NOTE: no CONFIG_BUILD_OUTPUT_UF2 here — it is nRF52-with-bootloader +# specific and fails outright on ESP32. It lives in boards/rak4631/board.conf. + +# USB CDC ACM console — nRF52840 boards (RAK4631) route console over USB +# because the UART pins are not reachable on a plain base board. Harmless on +# ESP32, which uses its own console and ignores these. +CONFIG_USB_DEVICE_STACK_NEXT=y +CONFIG_UART_LINE_CTRL=y +CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT=y +CONFIG_CDC_ACM_SERIAL_ENABLE_AT_BOOT=y + +# Explicitly off — this tool must not touch the radio, storage or BLE. In +# particular it must never mount /lfs: it does not read or write any stored +# identity, so a device under test keeps whatever it already had. +# +# Turning BT off matters for a second reason on nRF: CONFIG_CRYPTO_NRF_ECB +# depends on !HAS_BT_CTLR. If anyone later extends this tool to compare a +# hardware AES backend, that comparison is only valid against a build with the +# same BT setting the target role actually uses — see +# HANDOVER_crypto_hw_accel.md, "Traps and constraints". +CONFIG_BT=n +CONFIG_FLASH=n +CONFIG_FILE_SYSTEM=n +CONFIG_SPI=n +CONFIG_I2C=n +CONFIG_SENSOR=n +CONFIG_GNSS=n diff --git a/zephcore/tools/crypto_bench/src/main.cpp b/zephcore/tools/crypto_bench/src/main.cpp new file mode 100644 index 0000000..95f424a --- /dev/null +++ b/zephcore/tools/crypto_bench/src/main.cpp @@ -0,0 +1,524 @@ +/* + * ZephCore crypto benchmark + * SPDX-License-Identifier: MIT + * + * Answers one question: does ZephCore's crypto cost enough to be worth + * accelerating? See HANDOVER_crypto_hw_accel.md — every option in that + * document (a second Zephyr-crypto-API backend in Utils.cpp, nrfxlib PSA + * drivers in west.yml, or the free software wins) is gated on numbers that + * nobody has ever taken. + * + * WHY A SEPARATE IMAGE rather than a CLI command on a live node: + * the measurement has to be free of mesh-loop, BLE and LoRa-RX interference, + * and it runs each primitive hundreds of times back to back — which would + * starve exactly those subsystems. Same reasoning as tools/rng_selftest. + * + * WHAT IT MEASURES, and why the answer is a RATIO not a number: + * the absolute cost of SHA-256 is uninteresting on its own. What decides the + * question is crypto time as a fraction of packet AIRTIME — at SF11/250 kHz a + * packet occupies the channel for hundreds of milliseconds, so a per-packet + * crypto bill in the tens of microseconds is noise, and the whole hardware + * exploration closes with a documented "no". The final table does that + * division for you, per LoRa preset, using the SAME airtime formula the node + * uses (LoRaRadioBase::getAirtimeMillis) so the comparison is apples to + * apples. + * + * It measures the SHIPPING code: src/Utils.cpp, src/Identity.cpp and + * src/Packet.cpp are compiled straight from the main tree, not copied. What + * is timed here is byte-for-byte what the node runs. + * + * The tool never reads or writes stored identity, and never touches the + * radio, flash or BLE. A device under test keeps whatever key it had. + */ + +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace mesh; + +#ifndef CRYPTO_BENCH_ITERS +#define CRYPTO_BENCH_ITERS 200 +#endif + +/* Ed25519 is ~3 orders of magnitude slower than the symmetric primitives. + * At the default iteration count a full sweep would take minutes and tell us + * nothing extra — the variance on an operation that long is negligible. */ +#ifndef CRYPTO_BENCH_ITERS_SLOW +#define CRYPTO_BENCH_ITERS_SLOW 20 +#endif + +/* Payload sizes. 184 is MAX_PACKET_PAYLOAD; calculatePacketHash hashes + * 1 + payload_len, so the hash input tops out at 185. 60 is a typical text + * message, 100 a typical advert. */ +#define PAYLOAD_TYPICAL 60 +#define PAYLOAD_ADVERT 100 +#define PAYLOAD_MAX MAX_PACKET_PAYLOAD + +/* Worst case for the group-channel loop at Mesh.cpp:398. */ +#ifndef CRYPTO_BENCH_CHANNELS +#define CRYPTO_BENCH_CHANNELS 40 +#endif + +/* Anti-elision. Without a visible consumer the optimiser is entitled to + * delete calls whose output is unused, and at -Os it does. + * + * It reads only the first and last byte, NOT the whole buffer: the sink runs + * INSIDE the timed region, and summing 176 bytes would add real work to every + * AES measurement. Two bytes is enough — the compiler cannot prove the rest + * of the buffer was unnecessary to produce them. */ +static volatile uint32_t g_sink; + +static inline void sink(const uint8_t *p, size_t n) +{ + g_sink += (uint32_t)p[0] + (uint32_t)p[n - 1]; +} + +/* ---------------------------------------------------------------- timing */ + +/* Zephyr's portable cycle counter (DWT on Cortex-M, CCOUNT on Xtensa) — the + * same mechanism ZephyrRNG's two-clock beat uses. See + * memory/zephyrrng-entropy.md for the platform traps. + * + * MIN, not mean, is the headline figure: every perturbation (interrupt, + * cache miss, flash wait state) can only ADD time, so the minimum over N runs + * is the closest estimate of the true cost. Mean is printed alongside because + * a large min/mean gap is itself information — it says the operation is being + * interfered with, which matters on a node where it shares a CPU with the + * radio ISR. */ +struct BenchResult { + const char *name; + uint64_t min_ns; + uint64_t mean_ns; + int iters; +}; + +static uint64_t cycles_to_ns(uint64_t cycles) +{ + return timing_cycles_to_ns(cycles); +} + +/* Parameter names are underscore-prefixed on purpose: the preprocessor + * substitutes macro arguments after `.` too, so a parameter called `iters` + * would rewrite the `(dst).iters` member access into `(dst).200`. */ +#define BENCH(dst, _label, _n, stmt) \ + do { \ + uint64_t _min = UINT64_MAX, _sum = 0; \ + for (int _i = 0; _i < (_n); _i++) { \ + timing_t _t0 = timing_counter_get(); \ + { stmt; } \ + timing_t _t1 = timing_counter_get(); \ + uint64_t _c = timing_cycles_get(&_t0, &_t1); \ + if (_c < _min) _min = _c; \ + _sum += _c; \ + } \ + (dst).name = (_label); \ + (dst).min_ns = cycles_to_ns(_min); \ + (dst).mean_ns = cycles_to_ns(_sum / (uint64_t)(_n)); \ + (dst).iters = (_n); \ + } while (0) + +/* us with 3 decimals, integer math — no CBPRINTF_FP_SUPPORT needed. */ +static void print_result(const BenchResult *r) +{ + uint64_t mn = r->min_ns, mu = r->mean_ns; + printk(" %-34s %6llu.%03llu %8llu.%03llu %4d\n", + r->name, mn / 1000u, mn % 1000u, mu / 1000u, mu % 1000u, r->iters); +} + +static void print_header(const char *section) +{ + printk("\n%s\n", section); + printk(" %-34s %10s %12s %4s\n", "operation", "min us", "mean us", "N"); + printk(" ---------------------------------- ---------- ------------ ----\n"); +} + +/* --------------------------------------------------------------- airtime */ + +/* Integer port of LoRaRadioBase::getAirtimeMillis(), in microseconds. + * Deliberately mirrors that function line for line — including the + * preamble-length rule and the LDRO threshold tied to symbol time (not to + * SF), because a divergence here would silently distort the only comparison + * this tool exists to make. */ +static uint32_t preamble_syms(uint8_t sf) +{ + return (sf <= 8) ? 32u : 16u; /* PR #1954 parity */ +} + +static uint64_t airtime_us(uint8_t sf, uint32_t bw_hz, uint8_t cr, uint32_t len) +{ + /* Tsym in ns, to keep the LDRO comparison exact at narrow BW. */ + uint64_t tsym_ns = ((uint64_t)(1u << sf) * 1000000000ull) / bw_hz; + + /* 4.25 preamble symbols -> multiply by 425, divide by 100. */ + uint64_t t_pre_ns = ((uint64_t)preamble_syms(sf) * 100ull + 425ull) * tsym_ns / 100ull; + + int de = (tsym_ns > 16380000ull) ? 1 : 0; /* > 16.38 ms */ + + int64_t num = 8ll * (int64_t)len - 4ll * sf + 28 + 16; + int64_t den = 4ll * ((int64_t)sf - 2ll * de); + if (den < 1) den = 4; + int64_t ceil_div = (num + den - 1) / den; + int64_t n_pay = 8 + (ceil_div * (int64_t)cr > 0 ? ceil_div * (int64_t)cr : 0); + + return (t_pre_ns + (uint64_t)n_pay * tsym_ns) / 1000ull; +} + +struct Preset { + const char *name; + uint8_t sf; + uint32_t bw_hz; + uint8_t cr; /* 5..8, used as (cr-4+4) == cr in the formula */ +}; + +static const Preset presets[] = { + { "SF7 BW62.5k CR4/5", 7, 62500, 5 }, /* EmpireMesh, since 2026-07-25 */ + { "SF8 BW62.5k CR4/8", 8, 62500, 8 }, /* EmpireMesh, before that */ + { "SF10 BW125k CR4/5", 10, 125000, 5 }, + { "SF11 BW250k CR4/5", 11, 250000, 5 }, /* MeshCore default */ +}; + +/* ------------------------------------------------------------------ main */ + +int main(void) +{ + k_msleep(2000); /* USB CDC enumerates after boot on some boards */ + + timing_init(); + timing_start(); + + /* Utils.cpp assumes PSA is initialised. On a node that happens inside + * ZephyrRNG (adapters/rng/ZephyrRNG.cpp:285), which this tool + * deliberately does not link — so do it here, and fail loudly rather + * than silently benchmarking a pile of PSA_ERROR returns. */ + psa_status_t ps = psa_crypto_init(); + if (ps != PSA_SUCCESS) { + printk("FATAL: psa_crypto_init failed: %d\n", (int)ps); + return 0; + } + + printk("\n"); + printk("========================================================\n"); + printk(" ZephCore crypto benchmark\n"); + printk(" board: %s N=%d (slow ops N=%d)\n", + CONFIG_BOARD, CRYPTO_BENCH_ITERS, CRYPTO_BENCH_ITERS_SLOW); + printk("========================================================\n"); + + /* Test vectors. Fixed, not random: this is a timing measurement and a + * deterministic input makes runs comparable across boards and builds. + * Ed25519/X25519 timing is data-dependent at the margins, which the + * min-over-N already absorbs. */ + static uint8_t buf[1 + MAX_PACKET_PAYLOAD + CIPHER_BLOCK_SIZE]; + static uint8_t out[MAX_PACKET_PAYLOAD + CIPHER_BLOCK_SIZE * 2]; + static uint8_t key[PUB_KEY_SIZE]; + static uint8_t hash[MAX_HASH_SIZE]; + static uint8_t sig[SIGNATURE_SIZE]; + static uint8_t secret[CIPHER_KEY_SIZE * 2]; + + for (size_t i = 0; i < sizeof(buf); i++) buf[i] = (uint8_t)(i * 7 + 3); + for (size_t i = 0; i < sizeof(key); i++) key[i] = (uint8_t)(i * 11 + 5); + + uint8_t seed[SEED_SIZE]; + for (size_t i = 0; i < sizeof(seed); i++) seed[i] = (uint8_t)(i * 13 + 1); + LocalIdentity self; + self.fromSeed(seed); + + uint8_t seed2[SEED_SIZE]; + for (size_t i = 0; i < sizeof(seed2); i++) seed2[i] = (uint8_t)(i * 17 + 9); + LocalIdentity peer; + peer.fromSeed(seed2); + + self.sign(sig, buf, PAYLOAD_ADVERT); + if (!self.verify(sig, buf, PAYLOAD_ADVERT)) { + printk("FATAL: sign/verify self-check failed — results would be " + "meaningless\n"); + return 0; + } + + BenchResult r; + + /* ---- instrument baseline ---------------------------------------- */ + print_header("Instrument baseline — subtract this from everything below"); + + /* The cost of the two timing_counter_get() calls and the sink, with no + * crypto between them. Every figure in this run carries it. If it is + * not small compared to the fastest operation measured, the fastest + * rows are measuring the ruler, not the thing. */ + BENCH(r, "empty (timer pair + sink)", CRYPTO_BENCH_ITERS, + sink(buf, 16)); + print_result(&r); + uint64_t overhead = r.min_ns; + + /* ---- primitives: SHA-256 ---------------------------------------- */ + print_header("SHA-256 (PSA one-shot) — the dedup hash"); + + BENCH(r, "sha256, 16 B", CRYPTO_BENCH_ITERS, + Utils::sha256(hash, MAX_HASH_SIZE, buf, 16); sink(hash, MAX_HASH_SIZE)); + print_result(&r); + uint64_t sha_16 = r.min_ns; + + BENCH(r, "sha256, 61 B (typical msg pkt)", CRYPTO_BENCH_ITERS, + Utils::sha256(hash, MAX_HASH_SIZE, buf, 1 + PAYLOAD_TYPICAL); sink(hash, MAX_HASH_SIZE)); + print_result(&r); + uint64_t sha_typical = r.min_ns; + + BENCH(r, "sha256, 101 B (typical advert)", CRYPTO_BENCH_ITERS, + Utils::sha256(hash, MAX_HASH_SIZE, buf, 1 + PAYLOAD_ADVERT); sink(hash, MAX_HASH_SIZE)); + print_result(&r); + + BENCH(r, "sha256, 185 B (max payload)", CRYPTO_BENCH_ITERS, + Utils::sha256(hash, MAX_HASH_SIZE, buf, 1 + PAYLOAD_MAX); sink(hash, MAX_HASH_SIZE)); + print_result(&r); + uint64_t sha_max = r.min_ns; + + BENCH(r, "sha256, 2-frag (32+32 B)", CRYPTO_BENCH_ITERS, + Utils::sha256(hash, MAX_HASH_SIZE, buf, 32, buf + 32, 32); sink(hash, MAX_HASH_SIZE)); + print_result(&r); + + /* ---- primitives: AES ------------------------------------------- */ + print_header("AES-128-ECB (PSA, key imported+destroyed per call)"); + + BENCH(r, "encrypt, 16 B (1 block)", CRYPTO_BENCH_ITERS, + int n = Utils::encrypt(key, out, buf, 16); sink(out, n > 0 ? n : 1)); + print_result(&r); + uint64_t aes_1blk = r.min_ns; + + BENCH(r, "encrypt, 60 B (4 blocks)", CRYPTO_BENCH_ITERS, + int n = Utils::encrypt(key, out, buf, PAYLOAD_TYPICAL); sink(out, n > 0 ? n : 1)); + print_result(&r); + + BENCH(r, "encrypt, 176 B (11 blocks)", CRYPTO_BENCH_ITERS, + int n = Utils::encrypt(key, out, buf, 176); sink(out, n > 0 ? n : 1)); + print_result(&r); + uint64_t aes_max = r.min_ns; + + /* decrypt needs valid ciphertext-shaped input; ECB accepts anything + * block-aligned, and the timing does not depend on the plaintext. */ + BENCH(r, "decrypt, 176 B (11 blocks)", CRYPTO_BENCH_ITERS, + int n = Utils::decrypt(key, out, buf, 176); sink(out, n > 0 ? n : 1)); + print_result(&r); + + /* ---- the per-call key import ------------------------------------ */ + print_header("PSA key handling — the per-call import/destroy overhead"); + + /* Isolates what src/Utils.cpp:86,101 and :149,159 spend on key + * lifecycle ALONE, with no cipher or MAC work attached. This is the + * number that decides whether caching a key handle per channel secret + * is worth changing Utils.cpp for: compare it against "AES encrypt, + * 16 B" above. If import+destroy dominates a one-block encrypt, the + * group-channel loop is paying for key management, not crypto. */ + BENCH(r, "psa_import_key + psa_destroy_key", CRYPTO_BENCH_ITERS, { + psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attr, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attr, CIPHER_KEY_SIZE * 8); + psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_ENCRYPT); + psa_set_key_algorithm(&attr, PSA_ALG_ECB_NO_PADDING); + psa_key_id_t kid; + if (psa_import_key(&attr, key, CIPHER_KEY_SIZE, &kid) == PSA_SUCCESS) { + g_sink += (uint32_t)kid; + psa_destroy_key(kid); + } + }); + print_result(&r); + uint64_t key_import = r.min_ns; + + /* ---- HMAC, by subtraction --------------------------------------- */ + print_header("HMAC-SHA256 (via encryptThenMAC / MACThenDecrypt)"); + + /* compute_hmac_truncated() is static in Utils.cpp, so it cannot be + * called directly and is NOT worth un-staticing for a benchmark. It is + * derived instead: encryptThenMAC = encrypt + HMAC, and the failing + * MACThenDecrypt path is HMAC + a constant-time compare and nothing + * else (it returns before decrypt on MAC mismatch) — so the mismatch + * case below is very nearly a direct HMAC measurement. */ + BENCH(r, "encryptThenMAC, 60 B (TX path)", CRYPTO_BENCH_ITERS, + int n = Utils::encryptThenMAC(key, out, buf, PAYLOAD_TYPICAL); sink(out, n > 0 ? n : 1)); + print_result(&r); + uint64_t etm_typical = r.min_ns; + + BENCH(r, "encryptThenMAC, 176 B (TX path)", CRYPTO_BENCH_ITERS, + int n = Utils::encryptThenMAC(key, out, buf, 176); sink(out, n > 0 ? n : 1)); + print_result(&r); + + /* A real ciphertext, so the success path actually reaches decrypt. */ + static uint8_t ct[MAX_PACKET_PAYLOAD + CIPHER_BLOCK_SIZE * 2]; + int ct_len = Utils::encryptThenMAC(key, ct, buf, PAYLOAD_TYPICAL); + if (ct_len <= 0) { + printk("FATAL: encryptThenMAC produced nothing\n"); + return 0; + } + + BENCH(r, "MACThenDecrypt, MATCH (RX ours)", CRYPTO_BENCH_ITERS, + int n = Utils::MACThenDecrypt(key, out, ct, ct_len); sink(out, n > 0 ? n : 1)); + print_result(&r); + uint64_t mtd_match = r.min_ns; + + /* Wrong key -> MAC mismatch -> returns before AES. This is the cost of + * ONE non-matching channel in the Mesh.cpp:398 loop. */ + static uint8_t wrong_key[PUB_KEY_SIZE]; + for (size_t i = 0; i < sizeof(wrong_key); i++) wrong_key[i] = (uint8_t)(i * 3 + 200); + + BENCH(r, "MACThenDecrypt, MISS (wrong chan)", CRYPTO_BENCH_ITERS, + int n = Utils::MACThenDecrypt(wrong_key, out, ct, ct_len); g_sink += (uint32_t)n); + print_result(&r); + uint64_t mtd_miss = r.min_ns; + + /* ---- asymmetric -------------------------------------------------- */ + print_header("Ed25519 / X25519 (Monocypher, software only)"); + + BENCH(r, "Ed25519 verify (advert RX)", CRYPTO_BENCH_ITERS_SLOW, + bool ok = self.verify(sig, buf, PAYLOAD_ADVERT); g_sink += ok ? 1 : 0); + print_result(&r); + uint64_t ed_verify = r.min_ns; + + BENCH(r, "Ed25519 sign (advert TX)", CRYPTO_BENCH_ITERS_SLOW, + self.sign(sig, buf, PAYLOAD_ADVERT); sink(sig, SIGNATURE_SIZE)); + print_result(&r); + + BENCH(r, "X25519 shared secret (contact)", CRYPTO_BENCH_ITERS_SLOW, + self.calcSharedSecret(secret, peer); sink(secret, sizeof(secret))); + print_result(&r); + + /* ---- composites: real call paths -------------------------------- */ + print_header("Per-packet totals — the real RX/TX call paths"); + + /* Dedup as implemented today. SimpleMeshTables::wasSeen() and + * markSeen() each call packet->calculatePacketHash() independently, + * and since the 2026-07-02 hasSeen split every caller invokes markSeen + * right after wasSeen returns false — so a NEW packet hashes twice. + * The linear memcmp scan those functions also do is not crypto and is + * not timed here; it is a 160-entry 8-byte compare, nowhere near the + * hash. Packet.cpp is compiled from the main tree, so the buffer + * assembly (the memcpy into a stack buffer) is included, as it is on a + * node. */ + /* Not `static`: a function-local static with a non-trivial constructor + * emits __cxa_guard_acquire/release, which Zephyr's minimal C++ runtime + * does not provide. ~260 bytes on the stack instead. */ + Packet pkt; + pkt.header = (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD; + pkt.payload_len = PAYLOAD_TYPICAL; + memcpy(pkt.payload, buf, PAYLOAD_TYPICAL); + + BENCH(r, "dedup, 1x calculatePacketHash", CRYPTO_BENCH_ITERS, + pkt.calculatePacketHash(hash); sink(hash, MAX_HASH_SIZE)); + print_result(&r); + uint64_t dedup_1 = r.min_ns; + + BENCH(r, "dedup, 2x (wasSeen + markSeen)", CRYPTO_BENCH_ITERS, + pkt.calculatePacketHash(hash); sink(hash, MAX_HASH_SIZE); + pkt.calculatePacketHash(hash); sink(hash, MAX_HASH_SIZE)); + print_result(&r); + uint64_t dedup_2 = r.min_ns; + + /* Group packet that matches no configured channel: the loop runs every + * channel and every one costs a full HMAC. */ + BENCH(r, "group RX, " STRINGIFY(CRYPTO_BENCH_CHANNELS) " channel misses", + CRYPTO_BENCH_ITERS / 4, + for (int c = 0; c < CRYPTO_BENCH_CHANNELS; c++) { + int n = Utils::MACThenDecrypt(wrong_key, out, ct, ct_len); + g_sink += (uint32_t)n; + }); + print_result(&r); + uint64_t group_miss_all = r.min_ns; + + /* ---- the verdict table ------------------------------------------ */ + printk("\n"); + printk("========================================================\n"); + printk(" PER-PACKET CRYPTO BUDGET vs AIRTIME\n"); + printk("--------------------------------------------------------\n"); + + struct Scenario { + const char *name; + uint64_t ns; + uint32_t pkt_bytes; + }; + + /* Packet byte counts are the on-air length the airtime formula wants: + * 2 header bytes + path + payload. Path is assumed 3 hops x 1-byte + * hash, a common flood. */ + const uint32_t hdr_path = 2 + 3; + + const Scenario scen[] = { + { "flood not for us (dedup only)", + dedup_2, hdr_path + PAYLOAD_TYPICAL }, + { "direct msg for us (dedup+MAC+AES)", + dedup_2 + mtd_match, hdr_path + PAYLOAD_TYPICAL }, + { "group msg, no channel matches", + dedup_2 + group_miss_all, hdr_path + PAYLOAD_TYPICAL }, + { "advert (dedup + Ed25519 verify)", + dedup_2 + ed_verify, hdr_path + PAYLOAD_ADVERT }, + { "our TX (encryptThenMAC + dedup)", + dedup_1 + etm_typical, hdr_path + PAYLOAD_TYPICAL }, + }; + + for (size_t p = 0; p < ARRAY_SIZE(presets); p++) { + printk("\n %s\n", presets[p].name); + printk(" %-36s %9s %9s %8s\n", + "scenario", "crypto us", "air us", "share"); + printk(" ------------------------------------ --------- --------- --------\n"); + for (size_t s = 0; s < ARRAY_SIZE(scen); s++) { + uint64_t air = airtime_us(presets[p].sf, presets[p].bw_hz, + presets[p].cr, scen[s].pkt_bytes); + uint64_t cry_us = scen[s].ns / 1000u; + /* share in hundredths of a percent (basis points) */ + uint64_t bp = air ? (scen[s].ns * 10000ull) / (air * 1000ull) : 0; + printk(" %-36s %9llu %9llu %3llu.%02llu%%\n", + scen[s].name, cry_us, air, bp / 100u, bp % 100u); + } + } + + /* ---- what the software wins would buy --------------------------- */ + printk("\n"); + printk("--------------------------------------------------------\n"); + printk(" HEADROOM OF THE FREE SOFTWARE WINS (no hardware needed)\n"); + printk("--------------------------------------------------------\n"); + printk(" single-hash dedup saves : %llu us/packet\n", + (dedup_2 - dedup_1) / 1000u); + printk(" cached key handle would save up to: %llu us/packet\n", + ((uint64_t)CRYPTO_BENCH_CHANNELS * key_import) / 1000u); + printk(" (%d channels x %llu.%03llu us import/destroy)\n", + CRYPTO_BENCH_CHANNELS, key_import / 1000u, key_import % 1000u); + printk(" key import as share of a 1-block AES: %llu%%\n", + aes_1blk ? (key_import * 100u) / aes_1blk : 0); + + printk("\n"); + printk("--------------------------------------------------------\n"); + printk(" HOW TO READ THIS\n"); + printk("--------------------------------------------------------\n"); + printk(" The 'share' column is the whole question. Crypto competes\n"); + printk(" with airtime, not with itself. If every scenario sits well\n"); + printk(" under 1%%, hardware acceleration cannot buy throughput the\n"); + printk(" radio would ever notice, and the honest answer to\n"); + printk(" HANDOVER_crypto_hw_accel.md is Option C (do nothing) --\n"); + printk(" regardless of how large the microsecond figures look.\n"); + printk(" Only a scenario in the double-digit percent range, or one\n"); + printk(" whose absolute cost approaches the inter-packet gap,\n"); + printk(" justifies going further.\n"); + printk(" Power is NOT measured here. A CPU-bound millisecond also\n"); + printk(" costs battery; if the share numbers are borderline, measure\n"); + printk(" current draw before deciding.\n"); + + printk("\n"); + printk("========================================================\n"); + printk(" reference: sha16=%llu.%03llu us sha185=%llu.%03llu us\n", + sha_16 / 1000u, sha_16 % 1000u, sha_max / 1000u, sha_max % 1000u); + printk(" reference: aes1blk=%llu.%03llu us aes176=%llu.%03llu us\n", + aes_1blk / 1000u, aes_1blk % 1000u, aes_max / 1000u, aes_max % 1000u); + printk(" reference: hmac_miss=%llu.%03llu us ed_verify=%llu.%03llu us\n", + mtd_miss / 1000u, mtd_miss % 1000u, ed_verify / 1000u, ed_verify % 1000u); + printk(" instrument overhead=%llu ns (already included in every row)\n", + overhead); + printk(" (sha_typical=%llu ns, sink=%u — ignore, anti-elision)\n", + sha_typical, (unsigned)g_sink); + printk("========================================================\n"); + printk("\n[crypto_bench] done — halting. Reflash normal firmware.\n"); + + timing_stop(); + return 0; +}