diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..3f4b69365 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +# AGENTS.md + +Rules for AI-assisted contributions to ZephCore. They apply to human contributors too. Read the section of +`docs/DESIGN.md` (invariants, layer rules) and `docs/ARCHITECTURE.md` (component reference) for the area you +touch before writing code. Decisions are in `docs/adr/`. + +## How a change is judged + +1. **Find the root cause.** The PR states the cause and the evidence for it (datasheet section, register dump, + log, trace). If the cause is not known, say so; do not ship a guess. +2. **No band-aids.** Do not add a retry, delay, timeout, re-init, read-back check or watchdog to make a symptom + go away. Recovery code is accepted only for a named, understood hardware failure, in the layer that owns the + state, with its bound (`docs/DESIGN.md` §8.7). There is no hardware watchdog and none will be added. +3. **The smallest change that fixes it.** Close what is reachable today, not what might be reachable later. No + speculative guards, build asserts, new abstractions, options or refactors riding along. One concern per PR. + If the diff is larger than the bug, expect to be asked to cut it. +4. **Do what upstream MeshCore does** when it already solves the problem, unless ours can be more accurate at no + compatibility cost (`docs/DESIGN.md` §9). +5. **Comment diet.** A comment says what the code does or the invariant it keeps, in a few lines. Rationale, + history, incidents, measurements and rejected alternatives go in the PR description and `docs/`, not in code. + Do not comment the obvious. +6. **Fix it for every board.** A fix or feature that concerns behaviour goes in the common path, for all + platforms and roles. Do not scope it to the board or SoC family where it was found or tested, and do not + exclude a platform because the problem cannot happen there today; a later port or Zephyr bump can make it + real. Untested boards still get the change; the PR says which hardware it ran on. +7. **Gate on the capability, never on the name.** The only reason to gate code is that the hardware is absent, + and the test for that is a devicetree node or property, or a Kconfig symbol for the feature. Never + `CONFIG_SOC_*`, `CONFIG_BOARD_*` or a board name as a stand-in for "has this part" or "has this bug". A board + that lacks the hardware then carries no code for it, and a new board that has it gets the behaviour for free. +8. **A real platform difference is stated as one.** If something truly is specific to one SoC or chip (an + erratum, a vendor blob, a peripheral that exists nowhere else), it lives in that platform's layer and the PR + cites the source that makes it specific. "Only seen on X" is not that. + +## Invariants: do not change without an ADR + +- Byte compatibility with Arduino MeshCore on the air and on the companion protocol; `prefs.json` format. +- The wall clock only moves forward. +- Mesh, GPS state and UI run on the main thread; loops are event-driven, no polling. +- No heap in the packet path. Companion builds are RAM-bound. +- Never write to a peripheral that was not cleanly identified first. +- BLE: no proactive SMP Security Request, keep `BT_LE_ADV_OPT_USE_IDENTITY` (ADR 0004), leave connection + parameters alone. +- Files ported from MeshCore (listed in `.editorconfig`) keep upstream's text verbatim; every change in them is + fenced `// ZEPHCORE:`. Do not restyle or de-duplicate them. +- `zephcore/patches/zephyr`: one patch owns each Zephyr file. +- `board.conf` does not repeat what a parent conf or the devicetree already provides. + +## Mechanics + +- Branch from `dev`, target `dev`, rebase instead of merging. +- Tabs for indentation, except the upstream-ported files. +- Build with `--pristine` when the board or role changes; ESP32 S3/C-series repeaters need `--sysbuild`. +- Host tests: `python3 tests/run.py run --profile quick` (Linux or WSL). A new case needs a + `tests/catalog.json` entry. +- New board: `zephcore.yml` without `release:`, then `python zephcore/scripts/board_manifest.py check` and + `... docs`. +- Docs in the same PR: a clause in `docs/ARCHITECTURE.md` when behaviour, an interface or a limit changes; + `docs/CLI_commands.md` for any CLI change; an ADR when a decision is taken or reversed. + +## PR description + +- Cause, fix, and what was deliberately left out. +- Which boards it was built for, and which hardware it actually ran on. Never claim hardware verification that + was not done. +- Flash and RAM delta when code is added to a common path. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 43430a802..e79158651 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -860,7 +860,7 @@ Repeaters and room servers default to `CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC **Telemetry** (`REQ_TYPE_GET_TELEMETRY_DATA`, every role, upstream's shape): battery on channel 1, then `sensors.querySensors()` (`adapters/sensors/ZephyrSensorManager.cpp`): GPS position on channel 1 while the GPS is on and has a fix, each environment sensor and power-monitor channel found at boot on its own channel from 2 up (probe order), board-local analog sensors (T1000-E) on channel 1; then the MCU temperature on channel 1. Servers give guests battery + MCU temperature only and honour the requester's inverse permission mask, as upstream. Encoded by `helpers/compat/CayenneLPP.h`: upstream's library API and wire format, but values round to the nearest step where the library truncates. -**Clock** (`adapters/clock/ZephyrRTCClock`): `setCurrentTime()` also writes the hardware RTC when the board has one (coalesced on the system work queue), as upstream's `AutoDiscoverRTCClock` writes its chip; `seedCurrentTime()` is the boot restore, not written back. A chip is adopted at boot only once it is identified (`adapters/clock/ZephyrRTCDiscover.c`), because other parts can answer at the same address: a device is passed over when two reads each show a bit set that the chip's data sheet fixes at zero, or a time field out of range, and a time is restored only from a read in which every field is valid. The time is written in each chip's own encoding (the RX8130CE takes a one-hot weekday, `weekday-one-hot` in its descriptor), and an RV-3028 whose descriptor carries `rv3028-eeprom-config` (the RAK12002 on a RAK4631) has that configuration, its backup switchover, stored in the chip's EEPROM. Without a hardware RTC, the last time read is kept in no-init memory (RTC slow memory on ESP32) and restored after a reset that keeps RAM (reboot, crash, watchdog), as upstream's `ESP32RTCClock`; a power cycle starts from 1970 again. +**Clock** (`adapters/clock/ZephyrRTCClock`): `setCurrentTime()` also writes the hardware RTC when the board has one (coalesced on the system work queue; a time outside 2000-2099 is not written), as upstream's `AutoDiscoverRTCClock` writes its chip; `seedCurrentTime()` is the boot restore, not written back. A chip is adopted at boot only once it is identified (`adapters/clock/ZephyrRTCDiscover.c`), because other parts can answer at the same address: a device is passed over when two reads each show a bit set that the chip's data sheet fixes at zero, or a time field out of range, and a time is restored only from a read in which every field is valid. An RV-3028 cleanly identified in 12-hour mode has its `twelve-hour-bit` cleared first, so the chip converts its own hours. No time is taken from a cleanly identified RV-3028 whose `twelve-hour-bit` register cannot be read or reads FFh, or whose clear or re-read fails. The time is written in each chip's own encoding (the RX8130CE takes a one-hot weekday, `weekday-one-hot` in its descriptor), and an RV-3028 whose descriptor carries `rv3028-eeprom-config` (the RAK12002 on a RAK4631) has that configuration, its backup switchover, stored in the chip's EEPROM. Without a hardware RTC, the last time read is kept in no-init memory (RTC slow memory on ESP32) and restored after a reset that keeps RAM (reboot, crash, watchdog), as upstream's `ESP32RTCClock`; a power cycle starts from 1970 again. **LED master switch** (`helpers/led_gate.{c,h}`, `set leds on|off`, all roles): one process-wide flag every LED driver consults — heartbeat and unread-message LEDs in `helpers/ui/ui_common.c`, the diff --git a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md index 62f0d0016..0b24b30d2 100644 --- a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md @@ -167,6 +167,10 @@ matches the SoftDevice version of your bootloader. measurement the chip rejected the command that puts it back into its duty cycle. The next channel check recovered it and no packets were lost; the driver now returns the chip to standby first, and the command is accepted. +- **Joystick UI** (Wio Tracker L1 and other joystick boards): switching the GPS on from the GPS + screen showed "GPS disabled", and switching it off showed "GPS enabled". The GPS itself switched + correctly; the message now matches. The 3-tap buzzer and 4-tap GPS shortcuts now show a message + too, like the LED and advert shortcuts. - **New board**: Seeed LR2021 LoRa Plus EVK with a XIAO nRF54LM20A. Build it from source; there is no published firmware for it yet. - **Zephyr** updated to `74b7173e9c9`. One change in it would have been visible and is handled: diff --git a/zephcore/Kconfig b/zephcore/Kconfig index d80b0b14f..dd5d4d282 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -911,8 +911,9 @@ config ZEPHCORE_RTC_AUTODISCOVER backed RTC opt in by including boards/common/rtc-i2c.dtsi or declaring their own node; if a chip holds a valid time it is restored at boot — shown tagged "L" until the next external sync. - On every GPS/app/CLI sync the time is written back so it survives - power-off. A node with rv3028-eeprom-config has its time read and + On every GPS/app/CLI sync to a time within 2000-2099 the time is + written back so it survives power-off. A node with + rv3028-eeprom-config has its time read and written with the chip's backup switch flag cleared before and checked after, and, when its chip is the one adopted, a read identifies it, and a checked time read just before the store shows diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index b701a4b5b..6de57586d 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.c +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.c @@ -40,6 +40,8 @@ struct rtc_desc { uint8_t status_mask; /* "time unreliable" bit within status_reg */ const uint8_t *zero; /* 7 bytes of bits the data sheet shows as 0, or NULL */ bool week_one_hot; /* weekday as one bit per day, not 0-6 */ + uint8_t h12_reg; /* register of the 12-hour bit */ + uint8_t h12_mask; /* 12-hour bit, or 0 if none */ #if RTC_RV3028_CFG const uint8_t *cfg; /* rv3028-eeprom-config triplets, or NULL */ uint8_t cfg_len; @@ -79,6 +81,10 @@ DT_FOREACH_STATUS_OKAY(RTC_COMPAT, RTC_CFG_ARRAY) #define RTC_CFG_FIELDS(node) #endif +#define RTC_H12(node, i) \ + COND_CODE_1(DT_NODE_HAS_PROP(node, twelve_hour_bit), \ + (DT_PROP_BY_IDX(node, twelve_hour_bit, i)), (0)) + #define RTC_DESC_ENTRY(node) \ { \ .bus = DEVICE_DT_GET(DT_BUS(node)), \ @@ -91,6 +97,8 @@ DT_FOREACH_STATUS_OKAY(RTC_COMPAT, RTC_CFG_ARRAY) DT_NODE_HAS_PROP(node, zero_mask), \ (RTC_ZERO_NAME(node)), (NULL)), \ .week_one_hot = DT_PROP(node, weekday_one_hot), \ + .h12_reg = (uint8_t)RTC_H12(node, 0), \ + .h12_mask = (uint8_t)RTC_H12(node, 1), \ RTC_CFG_FIELDS(node) \ .name = DT_NODE_FULL_NAME(node), \ }, @@ -224,8 +232,33 @@ static enum rtc_verdict rtc_identify(const struct rtc_desc *d, uint8_t blk[7]) return RTC_FOUND; } -/* The 7-byte time block for an epoch, in d's register order. */ -static void rtc_time_block(const struct rtc_desc *d, uint32_t epoch, uint8_t blk[7]) +/* Clear a set 12-hour bit and re-read blk; the chip converts its hours + * itself. False on a failed or FFh read of the bit, or a failed clear or + * re-read. */ +static bool rtc_clear_12h(const struct rtc_desc *d, uint8_t blk[7]) +{ + uint8_t r; + + if (d->h12_mask == 0) { + return true; + } + if (i2c_reg_read_byte(d->bus, d->addr, d->h12_reg, &r) != 0 || r == 0xFF) { + return false; + } + if (!(r & d->h12_mask)) { + return true; + } + if (i2c_reg_write_byte(d->bus, d->addr, d->h12_reg, r & ~d->h12_mask) != 0 || + i2c_burst_read(d->bus, d->addr, d->time_reg, blk, 7) != 0) { + return false; + } + LOG_INF("%s: 12-hour mode cleared", d->name); + return true; +} + +/* The 7-byte time block for an epoch, in d's register order. False outside + * 2000-2099: the year is written as two BCD digits with no century. */ +static bool rtc_time_block(const struct rtc_desc *d, uint32_t epoch, uint8_t blk[7]) { int y; unsigned m, day; @@ -236,6 +269,9 @@ static void rtc_time_block(const struct rtc_desc *d, uint32_t epoch, uint8_t blk unsigned sec = rem % 60; unsigned dow = (unsigned)(((epoch / 86400) + 4) % 7); /* 1970-01-01 = Thu */ + if (y < 2000 || y > 2099) { + return false; + } blk[0] = BIN2BCD(sec); blk[1] = BIN2BCD(min); blk[2] = BIN2BCD(hour); @@ -244,6 +280,7 @@ static void rtc_time_block(const struct rtc_desc *d, uint32_t epoch, uint8_t blk blk[d->date_index == 4 ? 3 : 4] = d->week_one_hot ? (uint8_t)BIT(dow) : (uint8_t)dow; blk[5] = BIN2BCD(m); blk[6] = BIN2BCD((unsigned)(y % 100)); + return true; } /* Clear the power-loss flag (for chips whose flag is a separate reg; @@ -537,8 +574,11 @@ static void rv3028_save_retry_fn(struct k_work *work) uint8_t blk[7]; ARG_UNUSED(work); - rtc_time_block(s_active, s_save_epoch + - (uint32_t)((k_uptime_get() - s_save_at) / 1000), blk); + if (!rtc_time_block(s_active, s_save_epoch + + (uint32_t)((k_uptime_get() - s_save_at) / 1000), blk)) { + LOG_WRN("RTC %s: time outside 2000-2099 not written", s_active->name); + return; + } if (!rv3028_steady(s_active, blk, true)) { if (++s_save_tries < RTC_SAVE_RETRIES) { k_work_schedule(&s_save_retry, RTC_SAVE_RETRY); @@ -576,6 +616,9 @@ static bool rtc_probe(uint32_t *epoch_out) if (v == RTC_ABSENT) { continue; } + if (v == RTC_FOUND && !rtc_clear_12h(d, blk)) { + v = RTC_FOUND_GARBLED; + } #if RTC_RV3028_CFG /* Only a clean identification is configured: the store writes to @@ -675,7 +718,10 @@ void zephcore_rtc_save(uint32_t epoch) const struct rtc_desc *d = s_active; uint8_t blk[7]; - rtc_time_block(d, epoch, blk); + if (!rtc_time_block(d, epoch, blk)) { + LOG_WRN("RTC %s: time outside 2000-2099 not written", d->name); + return; + } #if RTC_RV3028_CFG if (d->cfg != NULL) { (void)k_work_cancel_delayable(&s_save_retry); diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.h b/zephcore/adapters/clock/ZephyrRTCDiscover.h index 42943c3a7..4af5b28ef 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.h +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.h @@ -10,8 +10,8 @@ * * If a present chip holds a valid time, zephcore_rtc_restore() returns it so * the soft clock can be seeded at boot (shown tagged "L" — local). Every - * ZephyrRTCClock::setCurrentTime() writes it back via zephcore_rtc_save(), so - * time survives the next power-off. + * ZephyrRTCClock::setCurrentTime() within 2000-2099 writes it back via + * zephcore_rtc_save(), so time survives the next power-off. */ #pragma once @@ -28,7 +28,9 @@ extern "C" { * sane time (year >= 2025 and its power-loss flag clear): store its Unix * epoch in *epoch_out and return true. The first chip found (valid time or * not) is remembered as the write-back target. Returns false if none is - * present or none holds a trustworthy time. + * present or none holds a trustworthy time. On a clean identification, a set + * twelve-hour-bit is cleared before the time is taken. No time is taken if its + * register cannot be read or reads FFh, or if the clear or the re-read fails. * * A descriptor with rv3028-eeprom-config also has that config stored in the * chip's EEPROM, retried on the system work queue if it fails. @@ -40,7 +42,7 @@ bool zephcore_rtc_restore(uint32_t *epoch_out); * power-loss flag. No-op if no RTC was discovered. Intended for real syncs * (GPS/app/CLI), not per-packet clock nudges. Call only from the system work * queue: an unconfirmed write is repeated there and shares state with this - * call. + * call. A time outside 2000-2099 is not written. */ void zephcore_rtc_save(uint32_t epoch); diff --git a/zephcore/boards/common/rtc-i2c.dtsi b/zephcore/boards/common/rtc-i2c.dtsi index 2dabd133a..18d97bf32 100644 --- a/zephcore/boards/common/rtc-i2c.dtsi +++ b/zephcore/boards/common/rtc-i2c.dtsi @@ -65,6 +65,7 @@ rtc_rv3028: rtc-rv3028@52 { date-index = <4>; status-reg = <0x0e>; /* status */ status-mask = <0x01>; /* PORF — power-on reset */ + twelve-hour-bit = [10 02]; /* Control 2 12_24, p. 24 */ /* RV-3028-C7 Application Manual Rev. 1.4, 3.2, p. 12 */ zero-mask = [80 80 c0 f8 c0 e0 00]; }; diff --git a/zephcore/boards/nrf52840/rak4631/board.overlay b/zephcore/boards/nrf52840/rak4631/board.overlay index 02ef9ed06..450f41f23 100644 --- a/zephcore/boards/nrf52840/rak4631/board.overlay +++ b/zephcore/boards/nrf52840/rak4631/board.overlay @@ -228,6 +228,7 @@ date-index = <4>; status-reg = <0x0e>; /* status */ status-mask = <0x01>; /* PORF — power-on reset */ + twelve-hour-bit = [10 02]; /* Control 2 12_24, p. 24 */ /* RV-3028-C7 Application Manual Rev. 1.4, 3.2, p. 12 */ zero-mask = [80 80 c0 f8 c0 e0 00]; /* The RAK12002's backup is a supercapacitor. As the RV-3028-C7 diff --git a/zephcore/dts/bindings/rtc/zephcore,rtc-i2c.yaml b/zephcore/dts/bindings/rtc/zephcore,rtc-i2c.yaml index efde76778..bcd205459 100644 --- a/zephcore/dts/bindings/rtc/zephcore,rtc-i2c.yaml +++ b/zephcore/dts/bindings/rtc/zephcore,rtc-i2c.yaml @@ -62,6 +62,16 @@ properties: bit 6) rather than a number 0-6. Set it for a chip whose data sheet says so, such as the RX8130CE. + twelve-hour-bit: + type: uint8-array + description: > + [register mask]: a bit outside the time block that reads set in + 12-hour mode, where clearing it makes the chip convert its own + hours to 24-hour, such as the RV3028's Control 2 12_24. Once the + chip is cleanly identified, a set bit is cleared before a time is + taken from it. No time is taken if the register cannot be read or + reads FFh, or if the write or the re-read fails. + rv3028-eeprom-config: type: uint8-array description: > @@ -80,11 +90,12 @@ properties: it, off once the store has disabled it, until a retry or that refresh. The store is retried up to 3 times, 10 minutes apart, identifying the chip again first; a boot whose read could not - identify it cleanly waits for those retries. The time is read at - boot and written on each sync with the chip's backup switch flag - cleared before and checked after. A write sets year 00h first and - the real year last, so a write cut by a power loss reads as "time - not yet set", and a write not confirmed is repeated every 5 s, up - to 12 times. Set it only on a board whose backup supply is known: - trickle charging suits a supercapacitor or rechargeable cell, - never a primary cell. + identify it cleanly, or whose 12-hour clear failed, waits for those + retries. The time is read at + boot and written on each sync within 2000-2099 with the chip's + backup switch flag cleared before and checked after. A write sets + year 00h first and the real year last, so a write cut by a power + loss reads as "time not yet set", and a write not confirmed is + repeated every 5 s, up to 12 times. Set it only on a board whose + backup supply is known: trickle charging suits a supercapacitor or + rechargeable cell, never a primary cell. diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp index 2d9827213..4b119bc3a 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp @@ -878,6 +878,7 @@ void JoystickUITask::loop() bool consumed = false; if (key == KEY_BUZZ_TOGGLE) { toggleBuzzer(); + showAlert(isBuzzerQuiet() ? "Buzzer off" : "Buzzer on", 1000); consumed = true; } else if (key == KEY_GPS_TOGGLE) { toggleGPS(); @@ -995,8 +996,15 @@ bool JoystickUITask::getGPSState() const void JoystickUITask::toggleGPS() { - if (!gps_is_available()) return; - mesh_gps_set_enabled(!gps_is_enabled()); + if (!gps_is_available()) { + showAlert("No GPS", 1000); + return; + } + /* The mesh loop applies the change later, so getGPSState() is still + * the old state here: the alert names the requested one. */ + bool enable = !gps_is_enabled(); + mesh_gps_set_enabled(enable); + showAlert(enable ? "GPS enabled" : "GPS disabled", 1000); } uint32_t JoystickUITask::getGpsDutySec() const diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.h b/zephcore/helpers/ui-joystick/joystick_ui_task.h index 226ea5a06..7449702f0 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_task.h +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.h @@ -114,6 +114,7 @@ class JoystickUITask { void toggleBuzzer(); bool isGPSAvailable() const; bool getGPSState() const; + /* Requests the opposite state and shows the alert for it. */ void toggleGPS(); /* GPS duty-cycle interval, seconds (0 = always on). Steps through a fixed * preset ladder (step = +1/-1) rather than raw seconds, since the valid diff --git a/zephcore/helpers/ui-joystick/screens/system.cpp b/zephcore/helpers/ui-joystick/screens/system.cpp index c8edcec2a..f8c1c5a5d 100644 --- a/zephcore/helpers/ui-joystick/screens/system.cpp +++ b/zephcore/helpers/ui-joystick/screens/system.cpp @@ -874,7 +874,6 @@ bool GPSSettingsScreen::handleInput(char key) if (key == KEY_ENTER) { if (_selected == 0) { _task->toggleGPS(); - _task->showAlert(_task->getGPSState() ? "GPS enabled" : "GPS disabled", 1000); } else { _task->adjustGpsDuty(1); }