server_main_common.cpp and main_observer.cpp both configured led0 as
a plain GPIO output at boot, unconditionally. On ESP32 that calls
esp_rom_gpio_matrix_out() under the hood, which routes the pin's
output to the GPIO signal and takes it away from whatever peripheral
had it before, including the LEDC channel a heartbeat-pwm-led or
lora-tx-pwm-led alias had already claimed during the PWM driver's own
init. The PWM write then succeeds (no error, no warning) but never
reaches the pin: confirmed on V4.3 hardware, where the same pin lights
up fine over plain GPIO, and over PWM in a different (non-Zephyr)
firmware, but stays dark over Zephyr's PWM once this GPIO configure
has run.
Companion builds never hit this (they don't use server_main_common.cpp)
and nRF52 isn't affected (no shared GPIO matrix), which is why T096
tested fine while V4.3 didn't.
Fix is alias-based, not a board list, so any future board with either
PWM alias is covered without extra wiring.
Also: CLI_HAS_RADIO_LED only checked the plain lora-tx-led alias, so
"set leds.radio" replied "(no radio LED on this board)" on boards
whose radio LED exists only via lora-tx-pwm-led (V4.3). Check both.
Documents the two PWM aliases in the board porting guide, and
leds.brightness plus the corrected LED topology counts in
docs/CLI_commands.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CONFIG_PWM now comes from each board.conf (see "prefs: move
led_brightness after wifi_pwd; enable PWM in board.conf"), so the
EXTRA_CONF_FILE fragment is no longer referenced anywhere and can go.
The WT2 PWM LED node comment still said "Option 1" and "RAM-only",
both stale: the design no longer needs that internal name, and the
brightness has been persisted since the earlier commit in this branch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The heartbeat and activity LED each computed the PWM pulse inline.
zephcore_led_pwm_write() in led_gate.c now does it once.
Comments added for the LED work described branch history, dates and
release notes; keep only what the code needs. led_gate.c still said the
brightness was RAM-only, which is no longer true, and the server decode
comment gave the old 10% default.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The on/off-only parser dropped "default", "1" and "0" from set leds
and reverted the shared cliOnOff() helper that the other toggles use.
Only the leds.brightness handling needed to change; the master switch
goes back to upstream's form.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
led_brightness sat at companion offset 174, in front of the WiFi
fields, so a 272-byte file written by upstream dev decoded with the
wrong led_brightness and a shifted SSID. Append it after wifi_pwd
(offset 272) and restore the original test offsets and the 208/272
historical lengths.
WT2 and V4.3 only got CONFIG_PWM through esp32s3_pwm_led.conf, which
the release build never passes, so release links would fail. Set
CONFIG_PWM=y in each board.conf instead. board.conf is app-only and
mcuboot does not inherit it, so the mcuboot link failure that rules out
the defconfig does not apply.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
UNIT-PREFS-001 and UNIT-PREFS-006 still said 272/311-byte in
catalog.json while prefs_codec_tests.cpp was bumped to 273/312 by
f8a4992. Same ID sets on both sides (missing=[], unexpected=[]) but
differing names made the executable/catalog dict comparison fail,
which is exactly what CI's host regression job flagged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
companionPrefsEncode()/serverPrefsEncode() never wrote the led_brightness
byte that companionPrefsDecode()/serverPrefsDecode() already read,
desyncing every field that follows it by one byte on the companion side
(wifi_enabled/wifi_ssid/wifi_pwd) and silently truncating the server
format by one byte. Caught by CI's ASan/UBSan run on the host regression
suite: an out-of-bounds read/write in the pinned layout tests.
Since led_brightness never existed upstream, nothing already accounted
for it: fixing only the encoder still leaves the pinned byte tables and
the documented 272/311-byte totals (COMPANION_PREFS_SIZE/
SERVER_PREFS_SIZE, used by the real prefs stores too, not just tests)
one byte short of what the encoder now actually produces.
- PrefsCodec.h: bump both size constants (272->273, 311->312).
- PrefsCodec.cpp: add the missing w.put() in both encoders, mirroring
the position already used by both decoders.
- prefs_codec_tests.cpp: add led_brightness to both layout tables at its
real file offset (shifts wifi_enabled/wifi_ssid/wifi_pwd by one byte
on the companion side; server's is appended last, nothing else
shifts), extend both historical-boundary length lists with the new
full-format length, and give it a non-default value in sample() so
the layout/roundtrip tests actually exercise it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT was 10 on the branch this was
developed on (a local preference for that project's own compiles).
For general use it should default to what every PWM LED already ran
at before this setting existed: full brightness. Also fixes a stale
led_gate.h comment that still said "RAM-only, not persisted" after
brightness became a persisted NodePrefs field.
heltec_wifi_lora32_v43_procpu.dts already declared a pwm-leds node for
its single onboard LED (pwm_led_white, LEDC0/GPIO35, generic pwm-led0
alias) -- it just never had the two project-specific aliases
(heartbeat-pwm-led / lora-tx-pwm-led) that helpers/ui/ui_common.c and
adapters/board/ZephyrBoard.cpp actually look for. T096 and Wireless
Tracker V2 have had this wired for a while; V4.3 was simply never
included in that earlier rollout, not blocked by anything in its
hardware -- the PWM path was sitting there unused this whole time,
confirmed by grepping for any other consumer of pwm-led0/pwm_led_white
before touching it (none).
Aliased both roles to the same node, same as T096/WT2: this board has
only one physical LED, shared between heartbeat and LoRa TX activity.
ZEPHCORE_LED_PIN_SHARED and the RX-pulse/pin-hold arbitration (extended
to PWM boards earlier today, commit a57d4c7) apply automatically since
both aliases point at the same node.
Gives V4.3 the full leds.radio/leds.hb/leds.brightness set for the first
time: leds.radio previously had no effect at all on this board (no
activity LED aliased whatsoever), leds.brightness was accepted but
inert (no PWM-capable LED reachable).
Verified building for heltec_wifi_lora32_v43/esp32s3 with
boards/common/esp32s3_pwm_led.conf (same flag already used for WT2).
The RX-pulse/pin-hold interlock (s_tx_lit/s_rx_pulse_off/
zephcore_led_radio_hold_pin) was gated by HAS_TX_LED alone, so it never
compiled in for any PWM-capable board (HAS_TX_LED_PWM). Left two real
gaps:
- T096 and Wireless Tracker V2 alias heartbeat and TX activity to the
SAME physical PWM LED (ZEPHCORE_LED_PIN_SHARED's intent exactly), but
had none of the arbitration that protects every plain-GPIO board with
the same wiring -- a transmit and a heartbeat tick could still clip
each other on the shared PWM channel.
- All PWM boards, RAK3401 included, had no onPacketReceived() blink at
all: "set leds.radio rx" (or "all") silently did nothing on the
activity LED, since that whole code path only existed under
HAS_TX_LED.
Fixed by widening every "#if HAS_TX_LED" guard around this machinery to
"#if HAS_TX_LED_PWM || HAS_TX_LED", and adding a small tx_led_write(bool)
helper (mirroring heartbeat_led_write() in helpers/ui/ui_common.c) so the
shared interlock logic doesn't need to know which path is compiled in --
only the final write differs.
ZEPHCORE_LED_PIN_SHARED itself gained a PWM branch: true when
heartbeat-pwm-led and lora-tx-pwm-led alias the same DT node (T096, WT2),
same as it already was for the GPIO led0/lora-tx-led case. RAK3401's two
PWM LEDs are on distinct nodes, so it correctly stays unshared -- no
arbitration overhead there, but it does now get the RX blink like every
other PWM board.
The heartbeat side (helpers/ui/ui_common.c) needed no change: it already
checks zephcore_led_radio_holds_pin() unconditionally through
heartbeat_led_write(), from the LED brightness persistence commit earlier
today.
Verified building for heltec_t096/nrf52840 (shared PWM LED),
heltec_wireless_tracker_v2/esp32s3 (shared PWM LED), and rak3401_1watt
(two independent PWM LEDs, confirms ZEPHCORE_LED_PIN_SHARED stays 0
there).
leds.brightness (PWM-capable boards) was RAM-only since it was added: it
always reset to ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT (10%) on every boot,
regardless of what a user had last set it to.
New field led_brightness on NodePrefs, following the exact same
append-only pattern as leds_radio_mode/leds_hb_mode added just before it:
offset 311 in RepeaterDataStore.cpp (repeater/room-server/observer,
shared file), offset 174 in ZephyrDataStore.cpp (companion). Both are
backward compatible by construction: a prefs file written before this
field existed simply leaves it at the initNodePrefs() default (10%),
which is exactly the behaviour every already-deployed node has today --
no special-casing needed to keep "10% on a new/not-yet-upgraded node" as
the effective default.
CommonCLI.cpp's "set leds.brightness" handler now writes the prefs field
and calls savePrefs(), same as the leds.radio/leds.hb handlers right
above it; "get leds.brightness" now reads from prefs instead of the RAM
getter directly, for the same reason those two already do (always in
sync, prefs is the source of truth). The four boot call sites
(main_companion.cpp, main_repeater.cpp, main_room_server.cpp,
main_observer.cpp) each gained a zephcore_led_set_brightness_pct() call
seeding the RAM cache from the persisted value, mirroring the existing
leds_radio_mode/leds_hb_mode lines right next to them.
Verified building for heltec_t096/nrf52840 (companion) and rak3401_1watt
(repeater), the two roles/datastores touched by this change.
Ports the T096 "Option 1" design (single LED, get/set leds on|off
persisted + get/set leds.brightness 0-100 RAM-only) to the Wireless
Tracker V2, which has the same single-LED-multi-purpose situation
(status_led doubles as heartbeat/message-flash/shutdown-flash/TX
indicator, same as T096's P0.28).
Adds a new ledc0 PWM channel on GPIO18 (same physical pin as the
existing plain-GPIO status_led) with heartbeat-pwm-led/lora-tx-pwm-led
aliases -- the shared C++ logic (helpers/ui/ui_common.c,
adapters/board/ZephyrBoard.cpp, helpers/led_gate.c/h,
helpers/CommonCLI.cpp) already existed from the T096 commits and needed
no changes, being board-agnostic behind DT_NODE_EXISTS checks.
CONFIG_PWM=y deliberately NOT in the board defconfig: WT2 builds via
--sysbuild (mcuboot + app as two images sharing that defconfig), and
mcuboot's minimal kernel config has no k_sem_give()/k_sem_take(), which
the ESP32 PWM LED driver needs -- CONFIG_PWM=y there links the app image
fine but fails mcuboot's link. New boards/common/esp32s3_pwm_led.conf
scopes it to the app image only via EXTRA_CONF_FILE, alongside the
existing esp32s3_usb.conf.
Reverts the previous merge: "leds" now only ever parses on/off (a pure
word command again, no numeric fallback), and brightness moves to its
own "leds.brightness <0-100>" command instead of overloading "leds"
with a value that could be either a word or a number. Checked before
"leds " in the chain since it shares the same 4-byte prefix. Still
RAM-only, no savePrefs() call in the brightness branch.
This is the syntax settled on after comparing two options: reuse
"leds" via a namespaced brightness command (chosen), vs a full
per-LED led.hb/led.tx split like the RAK3401 branch (not chosen,
decided to apply the "leds"/"leds.brightness" shape everywhere without
exception, including any future RAK3401 rework).
Reuses the existing leds on|off switch for brightness too, same
"one command, wording vs number" convention as led.tx/led.hb on
_scratch_leds_test, rather than introducing a separate led command.
Drops the legacy '1'/'0' single-char aliases from set leds while at
it: same 1%/0% shadowing bug already found and fixed on led.tx/led.hb
on 2026-08-16, latent here until a numeric value became meaningful.
get leds now reports both: "on N%" / "off (N%)".
Single LED (P0.28) doubles as heartbeat, message flash, shutdown flash
and LoRa TX indicator, all sharing the same physical pin. Adds one PWM
channel on that pin (heartbeat-pwm-led/lora-tx-pwm-led aliases) and a
single 0-100 brightness knob (get/set led) covering every one of those
events at once, deliberately RAM-only: resets to a 10% default on every
reboot regardless of CLI changes made in a previous session. The
existing persisted on/off gate (set leds on|off) is untouched.
Boards without heartbeat-pwm-led/lora-tx-pwm-led aliases keep their
current plain-GPIO behavior unchanged.
ZephCore assumed 24-hour mode. If other firmware had left an RV3028 in
12-hour mode, the boot read masked the hours with 0x3F and could restore
a time up to twelve hours off, and each save's 24-hour hours byte meant
a different hour to the chip.
A new optional twelve-hour-bit = [register mask] names the bit, set on
the two RV3028 nodes to Control 2 12_24. Once the chip is cleanly
identified at boot, rtc_probe() clears a set bit and re-reads the time
block; the chip converts its own hours, as MeshCore relies on. If the
register cannot be read, reads FFh, or the clear or re-read fails, no
time is taken from the chip. DS3231/DS1307 are unchanged: a save's hours
byte already selects 24-hour mode there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ZephCore writes the year as two BCD digits with no century, as y % 100.
A time before 2000, accepted by a node whose clock had no time yet, was
stored as a year near the end of the century and restored at the next
boot above the 2025 floor: `time 900000000` (1998) came back as 2098.
rtc_time_block() now fails outside 2000-2099, and zephcore_rtc_save()
then logs and writes no time, for every chip; the chip keeps the time it
has. An RV3028 write retry stops if its run-on time leaves the range.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rtc_time_block() wrote the weekday as a number 0-6 (Sunday 0) for every
RTC. The RX8130CE's WEEK register (13h) is one-hot: Epson ETM50E-10
14.1.2, Table 15, p. 27, Sunday 01h to Saturday 40h, with no more than
one bit set. So each sync wrote Sunday as no day, Monday as Sunday, and
Wednesday, Friday and Saturday as two days at once. ZephCore never
reads the weekday back, so its time and date were unaffected.
A new boolean descriptor property, weekday-one-hot, is set on the
RX8130CE node in rtc-i2c.dtsi, and rtc_time_block() writes BIT(dow)
where it is set. That is the encoding of Zephyr's rtc_rx8130ce.c. The
DS3231, PCF8563 and RV3028 keep 0-6, as Zephyr's drivers for them
write it. The binding and comments no longer call the whole block BCD.
meshtracker_x1's YSN8900 sets it too. The board already uses the RX8900
register map for it, and the RX8900's WEEK register (03h) is one-hot:
Epson ETM45E-11 8.2.2, Table 15, p. 16. The YSN8900's own data sheet
has no register map, and its programming guide is not available.
The same defect and fix as meshcore-dev/MeshCore#3550.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`sensor list <start>` parsed start with _atoi(), whose uint32_t goes
negative as an int: a start of 4294967295 is -1 and 2147483648 is
INT_MIN. Both passed the `start >= end` guard, so the loop asked the
sensor manager for a negative index and passed the NULL it returned
to "%s=%s", undefined behaviour that picolibc happens to print as
"(null)". Rejecting a negative start closes it: ZephyrSensorManager
has one setting, so only index 0 is ever requested.
The same defect as meshcore-dev/MeshCore#3433.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The RAK4631 overlay declared no zephcore,rtc-i2c candidate, so a fitted
RAK12002 (an RV3028 at 0x52) was never probed or written, and every
power cut started the node at 1970. Declaring it is not enough: the
RV3028 ships with backup switchover disabled (EEPROM 37h BSM = 00), so a
power cut still loses the time.
The overlay now declares the RV3028 with the zero-mask rtc-i2c.dtsi uses
for it, and a new optional rv3028-eeprom-config descriptor property
holds (register, mask, value) triplets for its EEPROM configuration
registers 35h-37h. The RAK4631 sets direct switchover with the trickle
charger on (TCE, BSM = 01, TCR 3 kOhm), as the RV-3028-C7 Application
Manual (Rev. 1.4) 7.3 prescribes for a capacitor backup, FEDE set as
3.15.6 says it always should be, and CLKOUT off. The configuration has
to live in EEPROM: the chip reloads its configuration RAM from EEPROM at
power-on and every midnight (4.6.2).
The store runs only when a read identified the chip (RTC_FOUND, not an
adoption on a failed second read) and a BSF-checked time read just
before it showed no switchover. It follows 3.15.6 (BSM 00 or 10 for any
EEPROM access): EERD held, switchover off in RAM, each configured byte
compared against the EEPROM with a single-byte read and written only if
it differs, and a closing Refresh that restores the stored switchover
mode, read back on every boot. 36h, the factory trim, is never written.
Switchover is turned back on only once EEbusy reads 0; otherwise it
stays off until a retry or the daily refresh. A failed store sets the
config in RAM (also only once EEbusy reads 0). A failed or skipped store
is retried every 10 minutes, at most 3 times per boot, each retry
identifying the chip again first.
A switchover disables the chip's I2C mid-transfer (4.2), so at a node
with rv3028-eeprom-config the time is read at boot and written on each
sync with BSF cleared before and checked after. A read that saw a
switchover gives no time. A write sets year 00h first and the real year
last, so a power loss cutting it between its first and last byte leaves
year 2000 and the next boot takes no time from it, and a write not
confirmed is repeated every 5 s, up to 12 times, with the time run on.
All of the RV3028 code is compiled only when some node carries
rv3028-eeprom-config, so other boards, thinknode_m1 among them, build
the same RTC object as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rtc_probe() decided from one read whether the device at a declared
address was the RTC its descriptor expects: a valid BCD block including
hours and year, or the power-loss flag set or unreadable. That skipped
real RTCs whose year byte or hours another firmware left out of range
(a year byte of A0 or more; 12-hour mode reads PM hours with bit 5 set),
ruled nothing out by identity, and adopted an erased EEPROM through its
0xFF "status".
- An optional zero-mask descriptor property holds the time-block bits a
part's data sheet shows as 0. rtc-i2c.dtsi sets it for 0x68 (DS3231
19-5170 Rev 10 and DS3232 19-5337 Rev 5, Figure 1, p. 11; DS1307 Rev
3/15, Table 2, p. 8; 00h bit 7 left out, the DS1307's clock-halt bit),
the RV3028 (App Manual Rev 1.4, 3.2, p. 12) and the RX8130CE
(ETM50E-10, 13.2.1 Table 12, p. 22). The PCF8563 gets none: its data
sheet (Rev 11.1, Table 4, p. 10) marks unused bits "not relevant".
- One read rules a device out if a masked bit is set, or if seconds,
minutes, date or month are out of range while the power-loss flag does
not read as set. The year and hours are no longer identity checks.
- A device is passed over only when two reads each rule it out. A failed
first read means nothing is there; a failed second read does not count,
and with no clean read the chip is adopted for write-back but gives no
time. An all-0xFF second read rules the device out.
- An all-0xFF first read is skipped, and the first save probes once more,
since a real RTC can power up that way.
- A time is restored only from a clean read whose fields, hours and year
are all valid.
The decision is one function, rtc_identify(). The same rules, with a
few agreed differences, are in meshcore-dev/MeshCore#3544.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>