Reported on Discord (benanderson, M9 beta_100): with the history chart open
the d-pad could not reach the Request button until the history was cleared.
The chart started 26 px down the card, inside the 32 px row of the Request /
gear / X buttons, and overlapped them horizontally, so it counted as the
nearest control left of the gear (and of the X). Left from the gear went to
the chart, Up from the chart back to the gear; Request was never chosen.
The chart now starts below that row.
The spatial ranking navMoveDir uses moved unchanged into NavSpatial.h
(navSpatialMetrics / navSpatialPick on plain rects) so it can be tested on
the host: test/test_nav_spatial.cpp lays out the M9 telemetry window and
shows Request unreachable from every control before, reachable from every
control after, plus the existing band rule (Send advert).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A background picture was always toned down by a fixed amount (night: x107/256,
then every pixel held to a luma of 64; day: lifted 60% toward white, held up to
200), and the lock-screen wallpaper always sat under a black veil of 105/255.
On a dark theme that left a picture barely visible. Both now take a step: Off,
Light, Medium or Strong.
- Settings > Theme > Background: "Picture dimming" under the picture's button,
shown while Image is picked; part of the page's pending choices, saved by
Apply and restart (bgImageFit applies it once at boot).
- Settings > Lock screen > Look: "Wallpaper dimming", applies the next time the
lock screen shows (veil 0 / 40 / 72 / 105).
Strong is the default and an absent key reads as Strong, so nothing changes for
anyone until they pick a step: PictureDim.h holds the per-pixel step, and
test/test_picture_dim.cpp checks Strong against the old formula on 281k
samples, plus range, ordering and the per-step readability bounds. New keys
bg_dim and lock_dim are standalone uchar keys in the settings backup list.
Three new strings, rows in every .lang, i18n_builtin.h regenerated.
V4 (no picture option there): +1624 B flash, the string table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Pro family was left out of USB Files from the start because e-paper
refresh during a transfer was untested. They run the T-Deck's serial driver
(ARDUINO_USB_CDC_ON_BOOT with HW-CDC), the one USB Files is proven on, so the
open question was the panel. Two things kept it refreshing during a transfer,
and every update blocks the loop that also serves the transfer for ~0.65 s:
- the USB Files screen set its status line and bar every 300 ms tick; on
e-paper the bar now moves in quarters, nothing repaints more than once every
3 s while bytes move, and a change of state shows at once (UsbFilesPaint.h,
host-tested). LCD boards repaint every tick as before.
- the status bar's SD activity light, two panel updates per 180 ms blink for
every card write. It stays off on e-paper (CAP_EPAPER).
With that, CAP_USB_FILES covers the Pro and Max, the session reports them by
name, and files.wadamesh.com lists them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Discord (gadgeteerza, ThinkNode M9, beta_100): no Central Africa zone, the
clock often half an hour behind on a Custom UTC offset, and "Sync clock from
system" with Wi-Fi on not always setting it.
Zones: the saved setting is an index into the zone table, and Custom used to
be "one past the list", so appending zones would have moved it. Custom now
keeps its id (16) forever and the new zones (Central/South/East/West Africa,
Egypt, Eastern/Western Europe, Moscow, Turkey, Arabia, Iran, Gulf, Pakistan,
Nepal, Bangladesh, Indochina, Singapore, Perth, Korea, Adelaide, Brisbane,
New Zealand, Argentina, Mexico City, Newfoundland) sit after it. The picker
shows its own west-to-east order and opens on the current zone.
Half an hour behind: not the offset. WadaNmeaLocationProvider::begin() kept
the parser's pre-rest sentence, so after a GPS saver rest the first loop pass
saw a "valid" fix, the 30-minute re-sync was due, and the clock was set to
the moment the receiver went to rest. begin() now clears the parser, and
UITask::setGpsPower(true) calls syncTime() so the core provider (V4 and
others) gets the same. test_gps_time_sync runs the real provider against a
receiver sending true UTC: the pre-fix provider set the clock 1801 s behind
after a 30-minute rest; now every set is within 1 s.
Sync from system: on the M9 the system clock is the device clock, so the
button copied a clock onto itself. And the Wi-Fi loop wrote only the FIRST
SNTP result into the mesh clock: SNTP's hourly re-syncs set the system clock
only, the PCF8563 kept its old value, and the hourly chip resync (#583)
pulled the clock back to it. Every completed sync is now written through
(SntpClockPush.h, test_sntp_clock_push), and the button with Wi-Fi up
restarts SNTP and says "Clock synced" when the answer lands.
test_time_zones: the legacy ids keep their zones, every zone's offset in
January/July/October, Egypt's DST edges, and all 65536 saved
(tz_zone, time_offs) byte pairs through the exact prefs path to localtime().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
beta_100 downloaded the bin into PSRAM and wrote it to the card fine, then
judged each attempt with f.size() on the still-open File. On arduino-esp32
File::size() of a written file is stat(path), and FAT writes the size into
the directory entry only on sync/close, so a fresh file reads 0 bytes. The
check failed all three attempts on every card and board, right after the
progress reached 100%, and each "failed" copy was deleted.
BufferedFileCommit::FsSink now closes the file and reopens it to compare
the size (closeAndCheckSize), which also catches a tail lost in close()'s
final flush. The streamed fallback (not enough PSRAM for the whole image)
uses the same check instead of trusting close(), and its write error now
says how far it got ("SD write failed at N KB") so the two paths can be
told apart in a report.
Host test: test_buffered_file_commit drives FsSink over a fake FS that
follows VFSFileImpl's size()/stdio-buffer rules. The same sequence was also
run against real FatFs (ESP-IDF ff.c) behind a transcribed VFSFileImpl on
16 MB FAT12/FAT16 and 4/32 GB FAT32 images: the old check sees 0 0 0, the
new one commits and reads back identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
begin() stamps the last input when it starts building the UI. A boot that
takes longer than the screen timeout (30 s by default; a full chat history on
internal flash with no SD card can do it) timed out on the first loop pass,
with the boot splash still covering the screen. On the T-Deck Pro and Max the
timeout is a lock, and the lock paints its frame and powers the e-paper down:
the splash sits on lv_layer_sys above the lock view, so the frame it kept was
the logo, and nothing redrew the panel afterwards. The device ran on (ui ready,
splash done) while the glass stayed on the logo, on every boot.
The idle clock now stands still while the splash is up (ScreenIdleClock.h,
host-tested), so the timeout counts from when the UI is shown. And the Pro's
lock takes the splash down before it paints, so a lock during the boot (the
BOOT button) shows the lock view, not the logo.
Reproduced in the host build with a 31 s begin(): before, the timeout fired on
the first pass with the splash up; after, 30 s after "splash done".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tile that no offline source holds (SD folders, PMTiles, the tile cache) while
Wi-Fi is down used to leave the dark placeholder, so zooming in one level past
a pack showed nothing at all. renderMapTiles now cuts the covering square out of
the parent, grandparent or great-grandparent (z-1..z-3) and scales it 2x/4x/8x
into the slot. The ancestor is decoded into the slot's own pool buffer and
blown up from a crop copy of at most 32 KB, so the pool cap and the 2 MB V4's
PSRAM budget are unchanged. Absent ancestors go in a 16-entry miss ring (PSRAM)
cleared on every source change and finished download, so pans do not re-probe
the card. Online the map still waits for the real tile; a stand-in is dropped
as soon as Wi-Fi is back. The +/- buttons step up to three levels past the
deepest level on storage (one at a time on the way back out). App map views
(wada.map) get the same stand-ins. Pure geometry + scaling + ring in
MapOverzoom.h with test/test_map_overzoom.cpp. CAP_MAP_OVERZOOM (default 1).
V4: +1368 B flash, +8 B RAM.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Wi-Fi switch checks before the radio's first start after boot that 50 KB of
internal RAM is free (and a 20 KB block), the reserve the boot co-init and a
cold Bluetooth start use. With Bluetooth on, the T-Deck Pro had 50676 free,
524 bytes short, so Wi-Fi only came up with Bluetooth turned off first.
The Pro and the Max kept their LVGL draw band (240 x 24, 11.5 KB) in internal
DMA RAM, but the e-paper flush only copies it with the CPU into the driver's
PSRAM canvas, so it never needed that RAM. It now lives in PSRAM like the
T-Deck's, which leaves the check over 10 KB to spare instead of a hair under.
New CAP_EPAPER names the e-paper boards for this and the screensaver gate.
The check itself moves into WifiColdStartGate.h with a host test that runs
the issue's numbers. The Pager keeps skipping it: its main loop orders the
start and reports a failed allocation itself. That handoff is not a memory
fix (it pauses Bluetooth traffic, NimBLE stays resident) and the other boards'
loop has no fallback when esp_wifi_init fails, so it is not ported here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wada.sys.random full range no longer divides by zero, wada.ui.input Enter
submits on touch boards with an external keyboard, the OTA download ends
its HTTP connection when no buffer could be allocated, an accepted clock set
lifts the floor (a chip stuck at it no longer keeps the stale time), the host
build refuses removes with '..', and 10 new host tests plus headless scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
# scripts/run-host-tests.sh
# src/helpers/esp32/TouchPrefsStore.cpp
# test/clock_floor_fakes/helpers/AutoDiscoverRTCClock.h
newMsgImpl's chime/vibration decision moves into src/ui-touch/NotifyAlert.h
(same rules, a static_assert ties its slots to the prefs' TOUCH_SND_* and
TOUCH_CHMUTE_*); the P4's vibrate task takes its beat count from it.
test/test_notify_alert.cpp: Do Not Disturb silences both for every
event, the master Sound switch only the chime, Vibrate off only the buzz;
the per-event switches and channel mutes gate the buzz as they gate the
chime; a DM with its switch off does not fall back to the message slot; a
muted mention is a plain message; 1, 2 and 3 beats for message, DM and
mention.
test/test_p4_haptic.cpp runs the production P4Haptic.cpp against a
register model of the AW86224 on a fake Wire1 (test/p4_haptic_fakes),
each case in its own process: first use loads the 711-byte waveform at
0x0800 and verifies it, the FIFO thresholds match the vendor formula, a
retained SRAM is not rewritten and one differing byte reloads it; a buzz
plays waveform 1 looped with auto brake at the given gain for exactly its
duration and is stopped; 0 or negative durations touch nothing; an absent
chip stays off the bus after the first probe; a busy bus skips the buzz;
a chip that never idles does not hang; every transaction happens with the
I2C_2 bus held.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 273 baked RGB565+alpha emoji images in src/ui-touch/emoji_data.c are
208,908 B of the Tanmatsu's application.bin, which may not exceed
50 x 64 KB pages. Deflated as one stream they are 105,053 B.
The Tanmatsu's IDF build now leaves emoji_data.c out and compiles
variants/tanmatsu/emoji_deflated.cpp, which provides the same
emojiGlyphLookup() / kEmojiGlyphCount. Its header is generated at build
time from emoji_data.c by scripts/build/gen-emoji-deflated.py (a CMake
custom command into the build dir, nothing committed), so emoji_data.c
stays the one source and add-emoji.py keeps working unchanged. The script
inflates its own output and checks every image and glyph mapping against
the source before writing, so a bad blob fails the build.
On the first lookup that hits a known emoji the blob is inflated with the
ROM tinfl (no flash cost) into one 208,908 B PSRAM block plus 273
descriptors; lookups that miss never inflate. An allocation failure gives
"no glyph" and is retried on a later lookup; a blob that will not inflate
is not retried per draw. Same pixels, same headers, same sharing of one
image by several codepoints (U+FE0F, U+200D, ...).
S3 boards, the web build and the other P4 builds do not compile anything
here (variants/tanmatsu is only globbed by tanmatsu/), so their images do
not change.
Host test test/test_emoji_deflated.cpp compares the deflated lookup with
the raw emoji_data.c for every codepoint up to U+10FFFF and drives the
out-of-memory and corrupt-blob paths (zlib stands in for the ROM tinfl).
run-host-tests.sh gains per-test prep (generate the header under $OUT)
and link flags.
Measured with WADA_FW_TAG=beta_99: 3,179,904 -> 3,076,400 B.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The main loop's Wi-Fi retry ran "every 10 s while not connected", with a
timer that only moved when it fired. After a long connected stretch it was
ancient, so the very pass that saw the link drop ran a disconnect + begin
on top of the reconnect the driver had just started for the same loss
(Arduino's auto-reconnect on the S3 boards, the C6's own AT+CWRECONNCFG on
the T-Display P4). And the S3 retry's disconnect erased the stored network
(disconnect(false, true)): that is the retry that stayed "disconnected",
while an apply after a scan (the same disconnect without the erase, then
begin) joins.
When to rejoin is now src/helpers/WifiRejoinPolicy.h, a pure state machine
shared by src/main.cpp (every S3 board) and the T-Display P4 loop:
- a link that drops gets a 10 s grace for the driver's own reconnect;
- a running scan, a join in flight or a C6 reset restarts the window;
- six fast tries 10 s apart, then every 30 s, so an AP that is gone all
night is searched about a third as often (964 vs 2881 attempts in 8 h);
- the screen coming back on asks for one prompt attempt (not within 3 s
of the last one), so a device picked up after sleeping does not wait
out the back-off.
The rejoin itself leaves the BSS without erasing it, waits 50 ms and joins
again, as the scan apply does. On the hosted P4 a scan now also holds the
retry off, as it already did on the S3 boards.
test/test_wifi_rejoin_policy.cpp drives the policy pass by pass with a
fake clock: switch-on, link loss with and without the driver recovering,
scan and join busy windows, an 8 h overnight on the battery saver's 50 ms
loop then the wake, Wi-Fi off and on, and the millis() wrap. The runner
also gets the include path test_c6_link_health needed (it failed to build
from scripts/run-host-tests.sh).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Host tests that compile the production variant files (TLoraPagerBoard.cpp,
PagerHaptics.cpp, PagerImu.cpp from #748-#751) against register-level fakes of
the BQ25896, BQ27220, DRV2605 and BHI260AP (test/pager_hw_fakes/):
- test_pager_power: REG0B decoding (USB host, adapter, power-good alone, OTG not
counted as USB, the 1 s cache), the temperature guard with its 3 C band, user
limits and implausible readings, battery care 80/75 %, both holds through the
one charger switch, charge limits rounded to the chip's 64 mA / 16 mV steps,
the gauge capacity written once per target (a sealed gauge is left alone on
later boots), and ship mode refused on USB, read afresh, BATFET_DLY cleared
and BATFET_DIS set on battery.
- test_pager_haptics: the waveform slots of every pattern and strength, all
eight slots rewritten, standby only after GO clears, no bus traffic without
the chip.
- test_pager_imu: one firmware upload per boot at 1 MHz with the bus clock put
back, scaling to g, stale samples refused, the 3 s idle stop and restart, no
retry storm after a failed upload.
Each was checked against a mutated copy of its subject (hysteresis removed,
care resume moved, ship mode on USB, OTG as USB, stale waveform tail, standby
mid-pattern, capacity written every boot): all eight mutants fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#756 lets getCurrentTime() take the system clock when the hardware clock
reads behind the floor. A chip stuck at the seed or frozen reads exactly
AT the floor, though: the first read sets the floor to its value, and a
Wi-Fi sync whose chip write does not take left the floor there, so every
later read returned the stale time (the #746 "nodate" backups on a
device with no persisted floor above the chip's value).
An accepted setCurrentTime() now also lifts the floor to the time it set
(the >10 min backward pull-down is unchanged). A chip that took the write
reads that time and is used as before; one that did not reads below the
floor and the system clock the set started is followed. On boards without
a chip the clock read is the system clock, so nothing changes there.
test_clock_floor: the sync-then-stuck-chip case fails before this change
(returns the stale seed) and passes after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test/test_clock_floor.cpp runs src/helpers/ClockFloorRTC.h over a
settable hardware clock (test/clock_floor_fakes) with settimeofday /
gettimeofday faked, so the machine's clock is never touched. A chip
behind the floor or asserting 2043 gives way to the system clock when that
is ahead and plausible, else the floor or the core's seed as before; a
negative or implausible system clock is never used; a healthy chip whose
write takes is followed; a 20000-step random walk of both clocks never
steps back or past the plausible window; garbage sets are refused, a
>10 min backward set lowers the floor, a poisoned persisted floor is
refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
882a2046 computes lo + esp_random() % (uint32_t)(hi - lo + 1) in 32-bit
Lua integers (LUA_32BITS). hi - lo + 1 overflows for spans past 2^31, and
for random(math.mininteger, math.maxinteger) it is 2^32, which is 0: the
ESP32-S3 compiler at -O2 emits sub / addi 1 / remu, and remu by zero is an
IntegerDivideByZero exception, so any app could panic the device with one
call. UBSan flags the same expression as signed overflow on the host.
The range math moves into src/ui-touch/LuaRandomRange.h and is done in
unsigned 32-bit arithmetic; a span of 0 means every value. random(),
random(n) and random(lo, hi) behave as documented otherwise.
test/test_lua_random_range.cpp (clean under UBSan) covers 1..n with every
value seen, (1, 1), reversed and negative ranges and both ends of int32.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up on Martin Kremers' (@kremers) PMTiles reader, behaviour unchanged
where it was right:
- CAP_MAP_PMTILES (CAP_MICROSD minus the Tanmatsu) gates the reader, the Map
file picker and the zoom limit. The Tanmatsu has a card too, but its AppFS
image has under 1 KB left and this is ~11 KB.
- The directory walk and the gzip inflater move from UITask.cpp into
PmTiles.cpp (checkArchive, gunzip, locateTile over a read callback), so the
whole lookup runs natively. UITask keeps the File glue and the UI.
- Bounded allocations: a directory may hold at most 32768 entries (768 KB
decoded) and only blocks of 16 KB or less fall back to internal RAM, so a
hostile entry count or a big leaf can't take the internal heap Wi-Fi needs.
- Uncompressed directories (internal compression 1) are valid v3 and now
load instead of being listed as "not a PNG raster archive".
- A corrupt directory logs once per archive instead of once per tile.
- The Map file row only shows with Tiles from SD card on, like the zoom row:
picking an archive did nothing with server tiles.
- test/test_pmtiles.cpp now runs from scripts/run-host-tests.sh (it could not
find PmTiles.h there) and checks the full lookup against archives that
test/pmtiles/make_fixtures.py writes from the spec: gzip and plain
directories, leaf directories, runs, unclustered data, gzip header fields;
vector, JPEG, gzip-tile and brotli archives refused; v2, cut short, bad CRC,
lying gzip sizes, hostile counts, out-of-section entries and a leaf that
points at itself all fail cleanly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two ways the clock read hours off while the zone setting looked right.
The ESP32-P4 esp-hosted boards started SNTP with configTime(0, 0, ...),
which also sets TZ to UTC, so from the first Wi-Fi join every displayed
time was UTC until the zone was picked again. #756 fixed this on the
T-Display P4; the CrowPanel P4 10.1 still had it. It now starts SNTP with
the user's zone (configTzTime) and, as on the T-Display, only pushes a
COMPLETED sync into the clock instead of the stale boot time.
Settings > Clock & time fills "By hand (local time)" with the clock's
reading when the page opens. Picking another zone (or stepping the custom
offset) left those digits in the old zone, so "Set clock" right after a
zone change read them in the new zone and moved the clock by the
difference. The field now refills whenever the zone is applied.
The zone table and its POSIX strings move to helpers/TimeZones.h (same
strings, same order) so test/test_time_zones.cpp can check every zone
through the C library's localtime(): January, July and today's offsets,
the US and EU DST edges to the minute, the Custom offset's sign for
UTC-12..+14, and ClockFloorRTC with the zone in force (a later clock set,
like the GPS re-sync every 30 minutes, moves the time and never the zone;
a chip that reads behind the floor or garbage-future falls back to the
system clock).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test/test_keyboard_layouts_hw.cpp compiles the production
KeyboardLayouts.cpp (LVGL names from test/kb_layouts_fakes) with the T-Deck
matrix decoder: Sym+W/E/R decode to 1 2 3 and type as themselves under
Italian, Spanish and Romanian, as do all ten digits and ) ! @ # $ % ^ & * (.
It reads the accent box's tables out of UITask.cpp and checks every letter
the old digit rows gave (à è ì ñ ă â î ş ţ and capitals) is offered after
its base letter, and that the box is on by default. Latvian keeps ā..ū on
the digits, Russian and Bulgarian map as before. It fails on the parent of
256b6b79.
A full dump of keyboardLayoutMapHwKey (14 layouts x 95 keys x shift)
before and after 256b6b79 differs only in the ES, IT and RO digit and
shifted-symbol entries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wifiSwitchRejoinService's decision moves into src/ui-touch/WifiSwitchRejoin.h
(same order of checks, same 4 s, same single shot); the service gathers the
Wi-Fi state and asks for the apply when it says so.
test/test_wifi_switch_rejoin.cpp drives it every 20 ms: the T-Deck case
(driver up after 300 ms, first join fails) rejoins once, 4 s after the
driver came up, never again; a join at 2.5 s or 3.98 s is left alone; a
scan from 1 s to 9 s holds it and the count restarts after (and nothing
fires if the scan's apply joins); switched off, no saved network, or a
driver that never starts: nothing; millis() wrap: still 4 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test/test_tdeck_keyboard_detect.cpp compiles the production
TDeckKeyboard.cpp against a scripted keyboard controller on a fake I2C
bus. Probe: 71 71 71 71 71 goes legacy with the saved-setting evidence
(taken once), padded 71 00 00 00 00 and an 8-bit burst go legacy unsaved,
a key in column 1 goes raw. Raw path: the repeated 'q' and a repeated
space go legacy and type the one character, padded types 'q' unsaved,
q+e (01 01), 21 21, space+z (20 20) and 23 01 stay raw and type their
keys, 8 keys at once go legacy typing nothing, 7 stay raw, high-bit frames
are ignored, the setting forces legacy. Exhaustively over all 35 matrix
cells: no 1- or 2-key chord reads as a reply; the 25 3-key chords that do
are all column 0 with space or the row-6 key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The speed rules behind touchPrefsGetGpsBaud / touchPrefsSetGpsBaudFound
move into src/helpers/esp32/GpsBaudPrefs.h (same results, same single
write per change), so the test runs the real rules.
test/test_gps_baud_probe.cpp compiles the real WadaNmeaLocationProvider.h
over a fake UART whose receiver talks NMEA at one speed and reads as noise
at every other, with a fake NVS that counts writes. T-Deck default 38400
with a 9600 receiver: found on the first probe step, stored once as the
found speed (never as the user's choice), no reopen or write over two more
minutes or a GPS saver rest; the next start opens at 9600 and writes
nothing. M9 default 115200 with a 38400 receiver: 9600, 19200, 38400. A
picked speed is never probed past and nothing is stored; a swapped
receiver rewrites the found speed once; no receiver or an unlisted speed
never stores anything; a stop mid-probe still finds the speed; the
CAP_GPS_BAUD_AUTO path stores to the setting instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Constellation is not heading-up: it places nodes by true bearing, but
for burn-in the whole picture turns slowly about you (half a degree every
5 s, a turn an hour, following the time since boot), so north and its N
marker go round with it.
Settings > Display > Screensaver gets "Keep north up" (off by default):
the turn stays at 0 degrees, so north is at the top. The few-pixel drift,
your own node's pulse and the status changing corners still keep pixels
moving. Stored as a standalone key (ssaver_north) like the other
screensaver settings: no schema bump, no migration. The row greys out with
the screensaver off, like the others.
CAP_SSAVER_NORTH_LOCK gates it, 0 on the Tanmatsu for its AppFS ceiling
(its screensaver keeps turning, as before). nl, de, fr, es, it and pt-br
translated; the other languages carry the English for now.
test/constellation_render's render takes CST_UPTIME_MS and
CST_NORTH_LOCK=1, to render the turn 45 minutes in, with and without it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before the Range resume (cbeb5184) otaWorkerRun called http.end() after
the copy unconditionally. The resume loop moved it inside the loop, which
does not run when neither the 4 KB nor the 1 KB internal buffer could be
allocated, so on a starved heap the update failed ("short read 0/N")
with the connection left open on the worker's HTTPClient, which the next
job (tiles, the SD download) reuses. End it in that case too.
test_ota_resume fails on the old code (connection still open) and passes now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The keyboard controller (Elecrow's ThinkNode-M9-KB firmware on the S2)
scans the matrix every 10 ms with no debounce on the matrix keys and
latches a key byte on every released-to-pressed edge, letters on the press
itself. A contact that chatters as a slow key comes up is a second edge, so
the same byte arrives again about one hold time after the first: 100 to
250 ms on a deliberate press. #628's 70 ms same-key guard caught the stale
latch and a bounce on the way down, not this, which is why it was worse
when typing slowly.
The controller has no key-state register, so the host cannot see the key
go up; the guard goes by time, now in a pure M9KeyGuard.h:
- The same printable character again within the window is the same press.
The window is three fifths of the usual gap between two different
characters (the typing pace), 70 to 250 ms. A chatter repeat comes after
about one hold, a letter typed twice on purpose after a whole keystroke.
- Arrows, Delete, Enter and the function keys keep 70 ms, so quick taps
down a list and a held Delete's 100 ms repeat still get through.
test_m9_keyboard simulates the controller's scan and latch at 1 ms
resolution: with #628's rule a slow "hello" with release chatter types
"hheelllloo"; with the guard it types "hello", and "bookkeeper" keeps its
doubles at slow and fast pace. A key held past 250 ms that then chatters
still doubles, and the test says so: only the controller could fix that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
otaWorkerRun's copy loop moves into src/ui-touch/OtaResume.h as a
template over HTTP client, stream, Update and a small platform struct
(millis, vTaskDelay, Serial), same statements in the same order, so a host
test can drive it. The firmware instantiates it with HTTPClient, Update and
::millis / vTaskDelay / Serial.
test/test_ota_resume.cpp runs it against a fake server with a virtual
clock and a 1,234,567-byte image: clean download, a drop at 40% resumed
with bytes=<offset>- and 206, a drop before the first byte, a stall over
15 s, a 10 s gap that is not a stall, 3 drops then success, 4 drops
(fails short), a 200 / wrong-length 206 / 404 answer to the Range request
(nothing appended), a failed flash write and a failed begin. Every
completed run compares the written image byte for byte.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A name that opens with emoji, such as a flag and an island, drew an empty
avatar. The chat icon taken from the first emoji in a name (#640) took the
first code point with ANY row in the emoji table, and the table maps the
invisible joiners (U+200D, the U+FE0E/FE0F variation selectors) to an empty
2 x 2 image so text never shows them. The island's U+FE0F won, and the icon
was that empty image. A ZWJ sequence such as man + laptop did the same
through its U+200D.
- The emoji pick skips the empty joiner rows; the real emoji after them
still becomes the icon. A picked chat icon that is a joiner, or a text
symbol the font cannot draw, keeps the plain avatar instead of a blank.
- Initials take whole code points and only letters or digits: emoji,
flags, symbols and combining accents are skipped, so the flag name shows
"TR" instead of two flag halves, "ana" with a tilde shows its capital. A
letter the font only has as the '*' stand-in becomes its plain letter, or
the next letter; "?" when the name has none, never blank.
- The logic lives in src/ui-touch/AvatarText.h (pure) with a host test
(test/test_avatar_text.cpp) that first reproduces the blank pick with the
old rule. The section-label uppercase table moved there unchanged.
- web demo backend: WADA_DEMO_CONTACTS="a|b|..." adds contacts for a
scripted headless run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 45 s expiry moves into src/helpers/UiRequestSlot.h, shared by
MyMesh::uiRequestTick and the web stand-in, so one constant and one rule
cover both (firmware behaviour unchanged).
test/test_ui_request_slot.cpp: the boundary (free at 45000 ms, not 44999),
millis() wrap, an unanswered ask holding the admin app's STATUS until the
expiry, an answered one clearing at once, a late reply matching nothing,
the deferred login's own 45 s, and a source check that every slot taker in
MyMesh.h and WebMesh.cpp stamps its time.
web/scripts/ui-slot-expiry.wada: the Discord report end to end in the host
build. WADA_DEMO_SILENT_TELEMETRY=1 makes the demo repeater lose its
telemetry reply; the contact sheet's Telemetry (no deadline without SD)
takes the slot, the admin app logs in and Status stays blank until 45 s
after the ask, then the queued STATUS goes out and Status fills. With the
expiry disabled the same run stays blank.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bin was written to the card chunk by chunk as it arrived, on the core-0
tile worker next to the Wi-Fi driver. A FAT write that fails there leaves the
file failed for good (FatFs keeps the error on the file object), so the
per-chunk retry could never recover and the stream could not be replayed.
Map tiles never hit this: they are downloaded first and written after.
When the image fits in PSRAM (with 512 KB to spare) it is now downloaded
first, then written through an internal DMA bounce buffer, up to three whole-
file attempts (helpers/BufferedFileCommit.h, host test included). Boards
without the room stream as before.
The SD health tick also guarded the download with s_sdfw_request, which the
worker clears the moment it starts, so a probe or remount could SD.end()
under the open file. The guard and sdRuntimeLifecycleBusy() now use
s_sdfw_state == 1 as well.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Offline PNG raster maps can now come from PMTiles v3 archives on the microSD
card, stored as /maps/<name>/<name>.pmtiles, besides the existing z/x/y tile
folders.
- PMTiles reader (PmTiles.h/.cpp): header and directory parsing, tile lookup;
gzip directories are inflated with the ROM tinfl and CRC-checked. Only
uncompressed PNG tiles are accepted. Unit tests in test/test_pmtiles.cpp.
- Map options -> Map file picks the archive or "Folder tiles only"; the choice
is saved by name (map_pmt). Without a saved choice "osm" is used, else the
first archive found. Tiles missing from the archive still load from the
folder layouts and the Wi-Fi fallback.
- Archives that can't be used are listed in the picker with the reason:
file not named after its folder, not PMTiles v3, not a PNG raster archive,
or cut short while copying.
- Map options -> "Zoom: map levels only" (shown only with Tiles from SD card,
saved as map_zfit): the slider range, +/- and every other zoom change stay
on the levels the map holds - the selected archive's header range, or the
numeric z folders of /maps/osm and /tiles for folder tiles. The detected
range is shown next to the toggle.
- Picking an archive moves the zoom into its range if it lies outside.
- README documents the SD layout and both options.
Rename the Settings "Auto-add" section to "Contact Management" and add a
"Remove old contacts" card: an on/off switch plus a days field (1-999,
default 30). When enabled, a background pass (3 min after boot, then
hourly) removes contacts not heard from in that many days.
Safety: favorites are never removed, the pass only runs with a synced
clock, both lastmod and advert timestamps must be stale, and the open
DM contact is skipped. Prefs schema v72 adds ct_purge_on/ct_purge_days.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The contact sheet's Admin button (repeaters, and now room servers) opens a
full admin app instead of a password box and a raw console. Login as admin
or guest (remembered password logs in at once), then an overview with the
node's status (GET_STATUS), firmware and owner info (GET_OWNER_INFO),
neighbours (GET_NEIGHBOURS, paged, newest or strongest), telemetry, the
access list with role changes (GET_ACCESS_LIST + setperm), settings pages
(identity and location, radio, repeating and limits, adverts, passwords and
access, regions) and actions (adverts, clock sync, discover neighbours,
reset counters, reboot, firmware update). The old CLI console stays as
Advanced > Console.
Every CLI line carries the "wX|" correlation prefix MeshCore echoes back, so
a reply always lands on the field that asked; one request is in flight at a
time, reads retry once, writes never, and a write is followed by a re-read.
Radio changes go through a confirm that shows old and new values, with a
10 minute tempradio trial before a saved change, and a reboot offer after.
MyMesh gains uiSendBinaryRequest (UiReqKind::Binary -> onBinaryReply) and
uiLastLoginInfo (the LOGIN_OK ACL byte and firmware level). The repeater
ping toast read uptime from the airtime field (offset 16, not 20); fixed.
Parsing lives in RepeaterAdminProto.h with a host test. Translations for all
14 languages. -DDOC_CAPTURE -DDOC_AUDIT_FOCUS -DDOC_ADMIN_AUDIT captures the
app against a node in range as a guest.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On units whose C6 runs LilyGo's factory ESP-AT image, Wi-Fi died after
about an hour: scans came back empty, Connect did nothing, and only a
reboot helped. The AT worker brought the SDIO link up once at boot and
never again, so a C6 that wedged mid-session left every command timing
out for the rest of the session.
Link health: AT commands now report OK / ERROR / TIMEOUT / BUSY. A
timeout or busy reply asks for a plain "AT" ping; a ping that times out,
or three command timeouts in a row with no answer between them, is a
stall. ERROR replies are answers and never count. Connects to dead hosts
(CIPSTART) and missing CIPSEND prompts only ask for a ping. The decision
logic lives in c6_link_health.h with a host test.
Recovery: on a stall the worker takes the link down at once (queued
requests fail fast, links read closed so the TCP/WS companion servers
prune them), then runs sdio_bring_down + the XL9535 C6 reset +
c6at_begin, the same path as boot. Resets back off 30 s, 60 s, 120 s,
240 s, then every 5 min; 10 min of a healthy link resets the ladder.
After a successful reset it rejoins the last network and the kept
listener want-flag re-arms AT+CIPSERVER, so port 5000 comes back without
app-side changes. The Wi-Fi status reads "reconnecting" meanwhile, and
main.cpp no longer treats the reset as "radio off".
Join/disconnect: one latest-wins slot instead of the 6-deep queue, so
the user's Connect can no longer be dropped behind 10 s retry pairs and
a stale CWQAP can no longer undo a fresh join. c6at_join_busy() gates
main.cpp's retry while a join is pending or running.
Bring-up now also sends AT+SLEEP=0 and sets AT+CWRECONNCFG=5,0 when the
AT build supports it (query first; ERROR on old images is ignored).
The esp-hosted build is unchanged: everything is in the c6_at component
or behind !TDP4_C6_HOSTED.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the clock unset, Export backup named the file after uptime seconds
(meshcore-backup-86.json), which read as a broken date and repeated after
every reboot, so a later export could overwrite an earlier backup.
Every timestamped backup is now meshcore-backup-YYYYMMDD-HHMMSS.json (was
meshcore-YYYYMMDD-HHMMSS.json), so the date always ends the name. Without a
set clock it is meshcore-backup-nodate-N.json; the Backups page picks the
first N not already on the card or in internal storage. The web download
uses the same pattern without the storage probe. Older names still list
and restore (the filters match meshcore-*.json).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjAKa5hXgyBykCLqnwcjEy
The SX1262's NSS, RESET, BUSY and DIO1 sit on the TCA9535 (P0.0..P0.3)
while SCK/MISO/MOSI are real GPIOs. IndicatorRadioHal derives from
ArduinoHal, keeps hardware FSPI at 4 MHz and maps the virtual pins
0x40..0x43 onto expander transfers; every other pin goes straight to
the Arduino pin and interrupt functions, as ArduinoHal does. The
meshcomod core is used as is: CustomSX1262 and its wrapper run
unchanged on top of the HAL.
DIO1 has no GPIO edge. The expander's open-drain /INT on GPIO42 has an
IRAM ISR that only notifies a dispatch task, because the read needs
I2C and the bus mutex. That task reads both input bytes under the bus
lock: the TCA9535 releases /INT only when the port that changed is
read, and several port-1 inputs float, so a port-0-only read could
leave GPIO42 low for good. Every port-0 byte feeds a small edge
detector (Dio1Edge) through the expander's port-0 observer, which
calls the callback RadioLib attached to DIO1 once per rise. That
callback is the core's setFlag, so TX-done and RX-done reach the core
exactly as they would from a DIO1 interrupt.
A BUSY poll is a port-0 read as well, and it clears /INT. Sampling DIO1
in those reads means a rise that lands while RadioLib waits on BUSY
fires the callback from that read instead of being lost; the dispatch
task's next read sees the level already high and does not fire again.
The task also re-reads every 50 ms, so a rise whose /INT was missed
still fires the callback, at most 50 ms late. MESH_RADIO_DIO1_POLLED is
not set: polling the IRQ register would add an SPI transfer, and with
it expander writes for NSS, on every pass of the receive path.
Floating port-1 inputs can keep /INT toggling, each toggle an I2C read
at high priority. The task counts ISR wakes and timeouts and logs them
for each of the first six 10 s windows, then only above 500 wakes/s
and at most once a minute, so the idle wake rate is visible at
bring-up without flooding the console later.
TCXO or crystal comes from the radio strap read at boot: the env's
SX126X_DIO3_TCXO_VOLTAGE calls indicatorTcxoVoltage(), which returns
2.4 V for the TCXO radio and 0 for the crystal one. radio_init() starts
the radio with the strap's setting and, if that fails, with the other
one, because std_init only falls back from TCXO to 0 V and a TCXO board
whose strap misread as crystal would never start otherwise. main.cpp's
one retry runs both again. The log says which voltage was requested and
which one the radio came up with.
The board reports GPIO42 (indicator::kExpanderIntGpio, shared with the
HAL) as its IRQ line and never light-sleeps: the inherited ESP32Board
versions would treat the virtual DIO1 pin as a GPIO, and /INT is active
low while the light-sleep wake source is high-level.
test_indicator_dio1 covers the virtual-pin routing and the
once-per-rise rule across dispatch and BUSY reads; run-host-tests.sh
gets its include path.
Signed-off-by: Confituurke <9595028+Confituurke@users.noreply.github.com>
Add the Seeed SenseCAP Indicator D1L as a new touch target. This step
builds and boots to a blank screen; the panel, touch and radio drivers
follow in their own commits so each can be reviewed against the
hardware it drives.
The SX1262's NSS, RESET, BUSY and DIO1, the LCD CS/reset and the touch
reset all sit on a TCA9535 at 0x20 on the SDA39/SCL40 bus, not on ESP32
GPIOs, so everything after this commit depends on the expander. The env
gives the radio lines virtual pin numbers 0x40..0x43 for a radio HAL to
map onto expander bits; until that HAL exists the radio is a plain
Module that links but cannot drive the chip.
The expander driver is header-only so a host test can cover it:
- its output and config shadows are seeded from the chip, because a
warm reset or the previous firmware may have left pins driven and
writing the power-on defaults back would glitch the radio, panel and
RP2040 resets;
- a failed write rolls both shadows back to the last state written in
full, and the next write brings the chip back in line with them;
- polarity inversion is cleared when a previous firmware left it set,
since it would invert BUSY, DIO1 and the strap; it is written only
when it is not already 0;
- every read of input port 0 is reported to an observer, so a DIO1 edge
is not lost when another reader clears /INT.
A five-sample majority of the P1.3 strap selects the TCXO (2.4 V) or
crystal radio at run time, so one noisy read cannot pick the wrong
oscillator.
The expander, the touch driver and the display init serialise on one
recursive mutex over the shared Wire bus (IndicatorIo::BusLock). It is a function-local static, so the compiler
guards its creation when two tasks reach the first lock together.
IndicatorIo::begin() logs the registers it inherited and names the
step that failed, since a dead expander takes the panel and radio down
with it and is otherwise silent.
The board has 8 MB of flash. The partition table has one 4.5 MB
factory app slot, a 3.375 MB SPIFFS (as on the 16 MB boards) and a
64 KB coredump, with no map tile partition. The factory slot is larger
than the 0x3E0000 OTA slots of the 16 MB boards, so an image that fits
those fits here. CAP_OTA is off: with one app slot there is no in-place
update. CAP_BATTERY is 0: no battery is fitted.
Montserrat 18/20/24 for the UI-size presets are the generated faces; the
board joins their gate in ui_font_18/20/24.c, gen-touch-fonts.sh,
hwFitFont and luaHostFigureFont. LVGL is built at -O2 with the Arabic and newlib-memcpy
settings the other envs use.
run-host-tests.sh builds every test/test_*.cpp, so the new expander
test gets its include path there in the same commit.
build-upload-monitor.sh gets --sensecap-indicator.
Signed-off-by: Confituurke <9595028+Confituurke@users.noreply.github.com>
fcb392a merged two neighbouring segments at the 24-file cap and, when no pair
fitted in one file, retired the oldest segment. A host simulation showed that
fallback does fire for heavy users (many quiet chats next to a few busy ones):
up to 626 old messages dropped over 60 reboots on 3 of 8 seeds. Now 24 is a
soft cap: at 24 pending compactions run first, then the best merge, and only
when no pair fits may a new segment open, up to a hard cap of 48. Below 41
files some adjacent pair of the 5000 ring records always fits, so the hard
cap never stops a save and nothing is dropped; files only pass 24 in that
heavy case, so older builds still read every file for almost everyone.
test/history_segments/run.sh lifts the scheduler out of UITask.cpp verbatim
and runs it against a simulated card: mixed, many-quiet, one-busy and
reboot-heavy workloads plus power cuts between a merged write and the
absorbed file's removal. 60k messages per run, every live record must
survive every reboot with no duplicates. beta_95's code loses ~48k of 60k in
the same run; this passes every mode (max 29 files).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>