Commit Graph
583 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 5 e2d07d8428 fix + docs: "Save update bin to SD" on every board with a card, and list the boards we actually ship (#289)
oumike's gap list. Three of the four are closed here.

"Save update bin to SD" was gated on a list of board names that had grown to
three entries and still left the T-Lora Pager out, despite it having both a card
and OTA. It is now gated on the capability that actually decides it, CAP_SD &&
CAP_OTA (plus the ESP32 / companion conditions it already carried), which is what
device_caps.h exists for: a new board with a card no longer has to remember to
add itself to a list.

Both halves were gated separately - the helper block and the button that uses it
- so widening only the button broke the pager build on undefined symbols. They
are now character-for-character identical, which is the point: they cannot drift
apart again.

Docs were further behind than the issue said. DEVICES.md listed five boards and
we ship ten: the T-Lora Pager, Heltec V4-R8, T-Display P4 and Attaky Mesh Series
were all missing from the support matrix, and the Pager was still sitting under
"Requested boards" with an open issue link. README listed five envs and claimed
the repo covers "the two boards". Both now match what release.sh actually builds.

Still open from #289: the static deploy/flasher/ page carries only two manifests.
Left alone for now - flasher.wadamesh.com redirects to the site flasher, which
generates its manifests from gen-flasher-meta.py and already covers every board,
so that directory may simply be dead. Worth confirming before adding files to it.

All 8 S3 boards build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:08:29 +02:00
Kaj SchittecatandClaude Opus 5 888e66cc09 fix: map tiles in /MAPS were never found, only /maps (#286)
w7aaf put tile packs in MAPS/osm/z/x/y and the device drew nothing. pisti87 could
not reproduce it on his own card, which is the shape of a case-resolution
difference rather than a wrong path.

It is our inconsistency. The card bootstrap CREATES "/MAPS" and the README we
write onto the card says "MAPS/ offline map tile packs", but both the tile loader
and the zoom-availability probe only ever asked for "/maps". FAT is nominally
case-insensitive, but whether that lookup resolves depends on the card's
long-filename entries and the FATFS build, so a user who followed our own
instructions to the letter could end up with a directory the loader never looked
in — while the same layout worked fine for someone else.

Both spellings are now tried, upper first since that is the one we create, in
tileFromSd() and in tileExistsAt(). The second one matters as much as the first:
it drives zoom reachability, so leaving it behind would have advertised zoom
levels as available and then drawn nothing.

Documentation left as MAPS/, which is what the device itself creates.

All 8 S3 boards build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:57:56 +02:00
Kaj 78f18bcc3f Merge pull request #287 from cvhviz/m9-audit-pass
ThinkNode M9 + Heltec V4-R8: 2026-08 audit passes (nav soft-locks, storage, deep sleep, Spectrum at spec)
2026-08-21 16:51:03 +02:00
Kaj SchittecatandClaude Opus 5 1c686553e7 i18n: pisti87's Hungarian, plus the compiled-in table I forgot to regenerate (#277)
pisti87 pointed at a commit of his (dc94f44) with 64 Hungarian rows, including
translations for the strings added over the last few releases.

He put them in src/ui-touch/i18n_builtin.h, which says "DO NOT EDIT" at the top
because it is GENERATED from deploy/apps/lang/*.lang. That turns out to be my
fault rather than his: yesterday I added 29 keys to the .lang files and never
ran gen-lang-builtin.py, so the compiled-in fallback still had the old table and
a device that has not downloaded a pack still showed English. Editing the
generated file was a reasonable guess at where the strings lived.

So: his rows are merged into deploy/apps/lang/hu.lang, the canonical source, and
the builtin is regenerated from it (13 languages, 12514 rows). Both paths agree
again.

Four of his rows would not have compiled - two used a tab where a comma belongs,
and two left the quotes inside the English unescaped, one of which had pasted
"Nincs válasz." into the middle of the English key. Reconstructed rather than
dropped; the intent was unambiguous in each.

A fifth pointed at something real: he translated "Battery life: geathering
data..." and no such key exists. Ours has no typo, but more importantly it was
never wrapped in TR() at all - a bare literal snprintf'd straight into a
user-visible buffer, which the unwrapped-literal scanner does not look at since
it only inspects LVGL setters. It is wrapped now, along with the one literal the
scanner did flag (the UI size dropdown), so the scan is at zero and both strings
are translatable for the first time.

Audit: 916 keys covered in all 13 languages. Published as language v13 and the
store is live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:35:00 +02:00
Christopher Van HooseandClaude Opus 5 7060a38dba Fix V4-R8 crash on Wi-Fi association with BLE on; widen companion ATT MTU
Wi-Fi power save: remove the preference, always use DTIM modem sleep.

An R8 with saved credentials and Bluetooth enabled aborted every time Wi-Fi
associated. Coredump: crashing task "wifi", abort() inside pm_set_sleep_type,
with nimble_host and btController both live.

WIFI_PS_NONE is not a legal sleep mode while the BT controller is running --
modem sleep is precisely what yields airtime to BLE -- and the Wi-Fi PM blob
aborts rather than refusing. 844d8c3 made power save a preference defaulting
OFF on the V4-R8, which is why only that board crashed; the comment above the
apply site already stated the invariant it broke ("gives BLE coexistence
airtime").

The latency it was buying does not exist either. Profiling the companion link
on hardware: an app sync is ~40 CMD_GET_CHANNEL round trips (the device reports
MAX_GROUP_CHANNELS=40 and the app reads every slot individually), 165ms each,
with this firmware accounting for 28ms of 8264ms -- 0.3%. Sync cost is
round-trip COUNT, not link latency, so there was nothing to trade away.

Guarding the setting was considered and rejected: it would have to be silently
inert whenever Bluetooth is on, which is a confusing thing to leave in a
Settings screen. Removed instead -- toggle, prefs accessors and R8 default.
The stored wifi_ps NVS key is simply never read again; no migration needed.

Companion BLE: ATT MTU MAX_FRAME_SIZE -> MAX_FRAME_SIZE + 3.

A notification payload is ATT_MTU-3, so SerialBLEInterface::begin()'s
setMTU(MAX_FRAME_SIZE) left a full-length frame 3 bytes too big and NimBLE
silently dropped the overflow (ble_att_truncate_to_mtu). MyMesh::queueMessage()
clamps message text to exactly MAX_FRAME_SIZE, so maximum-length messages lost
their last 3 bytes -- over BLE only; USB/TCP/WS were unaffected. Applied from
the vendored transport rather than the core: setMTU() is a public static, so
MeshCore needs no change. Verified on an M9, which now negotiates 179.

All 8 environments build. R8 flashed and confirmed to be the booting slot
(otadata seq 1 -> ota_0); its boot log is not observable over USB because
opening the port drops that board into ROM download mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:53:41 -04:00
Christopher Van HooseandClaude Fable 5 844d8c3357 V4-R8 perf pass + M9 UI polish (prefs v49/v50)
Heltec V4-R8 — "are we getting full performance?" audit. Compute side was already
at spec; six gaps closed (record: variants/heltec_v4/R8_AUDIT.md "Perf pass"):
- FEM LNA defaulted OFF on a KCT8103L board: prefs v49 defaults it ON on the R8
  (one-time flip of existing installs, no new field). Toggle stays in Radio & Mesh.
- Display SPI 40 -> 80 MHz (LGFX_SPI_WRITE_HZ; -D to 40000000 steps back).
- Async DMA band flush (LGFXDisplay::flushBandRGB565): swap into a 12 KB internal
  DMA buffer, one no-convert DMA per band, lv_disp_flush_ready at once so LVGL
  renders band N+1 while band N drains; lv_disp_flush_is_last closes the frame
  transaction so the shared micro-SD gets the bus between frames. Sync fallback.
- Wi-Fi modem power save is a pref (wifi_ps; toggle in Wi-Fi settings, live once
  associated). Default ON everywhere (unchanged), OFF on the R8.
- micro-SD 4 -> 20 MHz after the proven mount, read-verified (probe-file head
  captured at 4 MHz and byte-compared after the raise; falls back). Wired at all
  three mount sites; no-op without SD_SPI_FAST_HZ. Note: SDFS::begin returns true
  if already mounted, so every clock change goes through SD.end() first.
- R8 env boots at ESP32_CPU_FREQ=240 (setup() no longer runs at the V4's 80 MHz).
- Settings -> About "Perf:" row on the R8 (no serial console on that board):
  "CPU 240 · TFT 80 MHz DMA · SD 20 MHz · LNA on" is the all-green reading.

ThinkNode M9 UI:
- Spectrum page root gets NAV_SKIP_FLAG: nothing on it is actionable, so the
  d-pad no longer highlights the chart / scale box; Back closes it as before.
- Map "Show tile z/x/y" line defaults OFF (prefs v50, one-time flip; toggle kept).
- The tab bar is bypassed entirely on the M9: the accent "glow" indicator lived on
  the screen, not in the bar, so hiding the zero-height bar left it glowing over
  the content's bottom edge. No bar styling / gestures / key hints / indicator
  are built on the M9 now.

Cross-compiled: R8, V4, M9, T-Deck. Flashed + booted on the user's R8 and M9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 13:51:11 -04:00
Christopher Van HooseandClaude Fable 5 16e883187c M9 + all boards: persist the companion app-sync ring across power cycles
User report: the M9 "does not remember chats or sync them to the app" while
the V4-R8 does. The companion sync ring (MyMesh::history_ring + per-client
cursors) was RAM-only on every board — invisible on a USB-powered V4 that
rarely loses power, fatal on the M9 whose only off is the hard-cut slider.

Shared fix: the ring is now an append log (/synchist) plus client cursors
(/synccur) on DataStore::getHotDataFS(), restored in MyMesh::begin() and
flushed by persistSyncHistoryNow() on the paths that lose RAM (deep sleep,
power-off, explicit save). Record + bench-test recipe: M9_PORT.md Deferred #13.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 13:50:48 -04:00
Christopher Van HooseandClaude Fable 5 e56ac117b4 M9: Spectrum brought to LR1110 spec (STDBY_XOSC between bins, span-wide image cal, TX-safe seize)
Spec-check of the sweep on the LR1110 (two analysts + adversarial
verify): per-bin standby() parked in STDBY_RC and re-paid the ~5 ms
DIO3-TCXO startup every bin (~10 ms/bin, ~2x the budget) — bins now
park in STDBY_XOSC via the raw 0x01 byte (RadioLib's STANDBY_XOSC
define is bugged identical to STANDBY_RC); one CalibImage over the
whole swept span per open (begin() only calibrates mesh_freq +/-4 MHz,
not the +/-12 span the old comment claimed), mesh cal restored exactly
on close; sweep clamps corrected to the driver-enforced 150-960 MHz on
BOTH driver families and a failed retune now skips the bin instead of
mis-attributing the previous bin's RSSI; failed-read 0 dBm sentinel no
longer wins the peak-hold (one SPI hiccup squashed the auto-scaled
trace for ~30 s); config block enters standby first; opening Spectrum
mid-TX now waits (bounded, dispatcher's own airtime budget, 6 s cap)
for the packet to finish instead of truncating it on the air; stale
SX126x-era comments refreshed, dead gain readout removed, bin-pitch
vs RBW coverage (42%) documented as the deliberate trade-off it is.

On-device verification wanted: sweep timing (micros() log), mesh RX
sensitivity after a Spectrum session, mid-TX open completing the send.
All four touch envs compile clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 09:18:27 -04:00
Christopher Van HooseandClaude Fable 5 1b7d793ceb M9: audit pass 2 — 29 verified fixes (nav soft-locks, gate parity, deep sleep, build hardening)
Second 7-dimension adversarially-verified audit over the 08-19 tree.
Headliners: SUB_MAP over a display-only Lua app orphaned the app page
(key-only soft-lock); Back-ladder z-order redesign (CC/power always-
frontmost, confirm-modal deference, Lua key-forward suppressed while a
confirm is up — the send-permission dialog was unanswerable); map pan
flag could go stale across tab jumps/popups; null-close progress rows
now genuinely block the registry dismiss (shared fix); terminal RX
mirror, fullscreen title, wallpaper caption, storage-error guidance
widened to M9; accent/@-mention pickers suppressed (dead chrome on a
touchless board); kb-backlight cache only latches ACKed duties (0xFF
sentinel == duty 255 skipped the first write every boot); deep sleep
actually drops the rails now (display refcount, LEDC pin re-route,
RTC/digital holds) and powers off radio+GPS; TCXO fallback no longer
codifies the disproven 0.0f; RadioLib old-FW patch fail-closes at
link; ENV_SKIP_GPS_DETECT + CORE_DEBUG_LEVEL=0 added to the env.

Full round log in M9_PORT.md 'Audit pass 2 (2026-08-20)'. All four
touch envs (M9, T-Deck, V4-R8, pager) compile clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 08:07:37 -04:00
Christopher Van HooseandClaude Fable 5 68e3b45932 M9 + V4-R8: 2026-08-19 audit passes
ThinkNode M9 (see M9_PORT.md audit section): restore nav-mode HW Back,
Lua-app key forwarding + d-pad support, popup-registry gates widened,
SD-backed data store + chat history, ADC2 battery sample filter,
keyboard poll throttle + backlight, map tile cache to SD, spectrum
sweep LR1110 fix, power-menu cleanup for buttonless hardware.

Heltec V4 R8 (see R8_AUDIT.md): chat history/battery log follow SD
adoption, reinsert watch no longer unmounts live store, tile cache
prefers SD, CAP_ROTATABLE reboot trap defused, SD CS parked at boot.

Shared: atomic threads-index write (tmp+rename), recursive
scroll-into-view during keypad nav, textarea focus highlight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 07:25:18 -04:00
Kaj SchittecatandClaude Opus 5 4709c8197d i18n: cover the 29 strings that had drifted out of the language files (#277)
pisti87 listed the untranslated text he was seeing. Running the audit tool from
#254 found 29 keys missing from all 13 languages, which is a superset of his
list: the position-sharing strings and the note under them, the app-permission
prompts and page, the 1-character spam filter, the map "Max dots" and "%d of %d
on map", the room "Join w/ password" and its escalation prompt, and the new
path-hash hint. Most of them are mine from today.

All 13 are now complete: the audit reports 914 keys covered with nothing
missing. Untranslated rows carry the English on both sides, which is the
existing convention in these files (49 rows already looked like that), so a
translator sees the row and knows what to fill rather than the key simply not
existing.

Hungarian gets the one real translation: pisti87 supplied the telemetry-note
text in the issue, so it ships translated rather than English.

Published as language v12 for every language, and the store is live. Note the
canonical files the audit checks are the FLAT deploy/apps/lang/*.lang; the
numbered directories are published snapshots, so both were updated.

Also removes an em dash from the position-sharing note. It was written before
that rule landed and had no translations yet, so the key could be corrected for
free. The remaining em dashes in older strings are deliberately left alone:
changing those keys would orphan their existing translations in all 13 files,
which is a decision worth making on purpose rather than as a side effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:21:48 +02:00
Kaj SchittecatandClaude Opus 5 6d7ef3ff1f fix: a login that times out now names multi-byte path hashing (#278, #280)
Two reports of remote management, telemetry and ping all failing against a
repeater or room server while the same operations work from other MeshCore
clients, with no error anywhere: "Logging in..." disappears and nothing happens.

Multi-byte path hashing fails exactly like that. sendFloodScoped() stamps
path_hash_mode + 1 bytes of repeater hash onto unicast floods, which is the
login / status / telemetry / ack path specifically, while channel traffic takes
a different overload. A repeater that predates the feature, or does not
implement it, drops 2- and 3-byte-hash packets SILENTLY, so the request never
arrives and nothing on either side reports a failure. randelreiss said outright
he is running 3-byte hashes (#280).

It defaults to 1 byte and is opt-in, so this is behaving as designed. What is
not reasonable is that someone who turned it on has no way to connect that
choice to "remote management stopped working" weeks later. The timeout already
had the operator's attention and was spending it on password, server and clock;
it now names the path-hash setting and where to change it when the setting is
not on its default.

Does not change any send behaviour. This is diagnosis, not a fix for the drop
itself, and it is deliberately worded as a thing to try rather than a verdict:
neither reporter has confirmed the cause yet.

All 8 S3 boards build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:11:28 +02:00
Kaj SchittecatandClaude Opus 5 40145f6907 fix: stop polling the BME280 over I2C on every home-screen refresh (crash)
From a field coredump. Identified as beta_65 on a Heltec V4-R8 TFT by matching
the app ELF SHA256 the dump carries (486cd81c7ccb9f45) against the released
images, since the reporter did not know the version.

    assert failed: xQueueGenericSend queue.c:832
      (... || pxQueue->u.xSemaphore.xMutexHolder == xTaskGetCurrentTaskHandle())

    panic_abort <- __assert_func <- xQueueGenericSend
      <- TwoWire::beginTransmission
      <- Adafruit_I2CDevice::write(stop=false) <- write_then_read
      <- Adafruit_BME280::read24 <- readTemperature <- readPressure
      <- query_bme280 <- EnvironmentSensorManager::querySensors
      <- UITask::getLocalEnvSnapshot <- getLocalEnvSummary
      <- buildHomeEnvSummary <- refreshStatusLabels <- UITask::loop

getLocalEnvSnapshot() ran a live I2C conversation with the BME280 every call,
and refreshStatusLabels calls it on the ~1 Hz home refresh. So a V4-R8 with the
Expansion Kit held a repeated-start I2C transaction every second, forever, on
the UI thread.

Adafruit reads the sensor with write_then_read, which puts Arduino's TwoWire in
nonStop mode holding its lock across two calls. If that sequence does not
complete — a slow or unhappy sensor is enough — nonStop stays set, and the next
beginTransmission takes its "release the lock" branch for a lock this task no
longer holds. FreeRTOS asserts and the device panics. The defect is in TwoWire's
lock accounting, which is not ours to fix; polling it at 1 Hz is what turned a
rare bus hiccup into a reboot.

The queried half is now cached and refreshed at most every 3 seconds. These are
room temperature and pressure — nothing needs them re-measured every frame. GPS
and battery stay live, being cheap. A failed query stamps the cache too, so a
sick bus is not retried in a storm.

Also takes a blocking I2C round trip off the UI thread's per-refresh path.

All 8 S3 boards build. Not reproduced on hardware: it needs an Expansion Kit and
a sensor unhappy enough to stall mid-transaction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:59:01 +02:00
Kaj SchittecatandClaude Opus 5 b3d2cea55b ui: remove the chat-store chip from the home screen
It reads "Save FAIL" permanently on the Tanmatsu, so on that board the one
indicator meant to make a sick store impossible to miss was instead crying wolf
at every user on every boot. An indicator that is wrong on a whole board is
worse than no indicator: it trains people to ignore it, and it is the first
thing they ask about.

Removed rather than board-gated. Everything it showed, and more, already lives
on Settings > About > Chat store: the backend in use, the segment count and byte
total, when history last saved, and the exact stage plus errno of a failing
save. The chip was a shortcut to a question most people never need to ask.

Also removes the now-unreachable homeStoreChipText() and the per-tick refresh
that ran on every home-screen frame.

Docs updated in the same commit: the user guide had a whole section explaining
how to read the chip, which would otherwise describe something nobody can see.
Replaced with a pointer to the About panel that holds the real answer.

All 8 S3 boards build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:41:24 +02:00
Kaj SchittecatandClaude Opus 5 77aef76c90 fix: the map stopped drawing contacts at 31 while the counter kept counting
Reported as new contacts never appearing on the map even though the number in
the bottom right was right.

Both halves of that are literally true. The marker array was 32 entries with
slot 0 reserved for self, so 31 contact dots was the ceiling, and the loop gave
up silently at that point. The bottom-right label counts every contact with a
position and had no cap at all. So past 31 the two disagreed permanently, with
nothing on screen to say why. Contacts are appended to the table and the loop
walks it in order, so the dots that never got drawn were the newest ones, which
is exactly how it looked from the outside.

Ceiling is now 256 rather than 31. Markers are only built for contacts inside
the viewport, so that is how many can be visible at once, not a limit on the
contact list. It costs 8 bytes of static RAM per slot.

The self->contact link LINES keep their own separate ceiling of 32. Each line
holds a persistent 2-point array LVGL does not copy, so they are more expensive
per slot, and a screen carrying hundreds of dotted lines is unreadable long
before it is slow. Raising the dot ceiling deliberately does not drag that up
with it.

New setting, Map options > "Max dots", cycling All / 200 / 100 / 50 / 25.
Default All. The lower steps are there for a board that struggles with a crowded
viewport, not because the map should be quietly limiting itself.

And when it does clip, the label now says "31 of 45 on map" instead of "45 on
map". A number that disagrees with the screen should explain itself rather than
leave the operator to work out that the map is lying.

All 8 S3 boards build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:31:58 +02:00
Kaj SchittecatandClaude Opus 5 a814e174ac feat: Settings > Radio & Mesh > "Ignore 1-character messages"
Most mesh spam right now is a 1-byte payload, because that is the cheapest
message there is to send, and it still costs every receiver in range a chat
entry, a notification and a chime. Nobody sends a single character on purpose,
which is what makes this safe to drop outright where a general length threshold
would not be.

Off by default. It filters other people's traffic, so it is the operator's
decision rather than ours.

Two details that matter for it to actually work:

It tests the parsed BODY, not the raw text. A channel post arrives on the wire
as "SenderName: body", so the raw string is never short even when the message
is; filtering `text` would have missed every channel spam, which is where most
of it lands. `body` is the parsed message for a channel and the whole text for
a DM.

It sits with the block filters and BEFORE the chime, so a dropped message makes
no sound either. A spam filter that still beeps has not filtered anything.

Leading and trailing whitespace is stripped before measuring, so " x " counts as
one character rather than three.

Placed in newMsgImpl, the single point every arrival funnels through, so it
covers DMs, channels and room posts without three separate guards.

All 8 S3 boards build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:18:10 +02:00
Kaj SchittecatandClaude Opus 5 19a7ca85ce fix(p4): report the fuel gauge's state of charge, not terminal voltage (#273)
@D-Melhede saw a P4 claim 71% and then die, with Meck-P4 reading 11% on the same
pack; a full charge never quite reached 100%; and "calibrate 100% here" made it
permanently worse. @wb6zsu watched it read 51%, then 100% the instant USB went in.

All of that is one cause. The P4 carries a BQ27220 fuel gauge, which coulomb-counts
against its learned pack profile and therefore knows the actual state of charge, but
we only ever read its Voltage() register and pushed that through the generic
3.30-4.20 V linear curve. Terminal voltage is charger-driven, so it pins at 100% the
moment USB is connected however empty the pack is, sits under 100% on a rested full
pack, and calibrating "full" while charging captures a charger rail as the reference
and bakes the error in for good. The old comment conceded the limit in as many words:
"the real cell % isn't observable here". On this board it is.

Now reads StateOfCharge() and uses it directly, falling back to the voltage curve
only when the gauge does not answer or reports out of range, so a dead gauge is no
worse than today rather than a made-up number.

The register is REG 0x2C, taken from LilyGo's own SensorLib (GaugeBQ27220.hpp
"StateOfCharge; // % - REG2C") rather than from memory, since guessing a register
here would produce confident nonsense.

Does NOT address the charging/LED half of that report: whether the pack charges
correctly and what drives the red/green LED is the BQ25896 charger, which we still
do not talk to. Tracked separately in the issue.

P4 builds; T-Deck and V4 unaffected (the gauge path is board-gated).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:01:13 +02:00
Kaj SchittecatandClaude Opus 5 5223ba165c fix: language downloads no longer fail silently, and stop failing on memory (#274)
@AtomAlex12 reported a pager where app installs work all day and a language pack
has never once downloaded: /apps healthy with 12 files, /lang empty forever, and
nothing on serial to explain any of it.

The discriminator is size. An app download asks for a 64 KB buffer and fetches a
few KB; a language download asks for a CONTIGUOUS 128 KB PSRAM buffer while the
Store page is up and fetches ~60 KB. Both of the ways that can fail returned a
bare false with no logging and no message, so the two are indistinguishable from
the outside, which is why the report could only say "it never works".

Two changes:

Allocation now steps down 128 -> 96 -> 72 KB and then falls back to internal
heap, the same ladder the reader already uses for its own large buffer, so a
fragmented or busy PSRAM heap no longer means no languages at all.

Every failure path now names itself, and the toast says which: not enough
memory, server or connection failed, empty reply, or could not write to storage.
A ~60 KB transfer dropping mid-body fails where a 3 KB app does not, and the old
generic "Download failed" hid exactly that difference. The reason is cleared at
the start of each attempt so a stale one can never be reported against a fresh
try.

Verified the server side first: both URL shapes the worker builds, versioned
/apps/lang/<ver>/<code>.lang and flat /apps/lang/<code>.lang, serve 200. This is
device-side, as the reporter concluded.

All 8 S3 boards build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:47:30 +02:00
Kaj 89e8ccf008 Merge pull request #279 from oumike/267
fix(touch): escalate a failed room re-login to a passworded Join (#267)
2026-08-19 14:34:53 +02:00
Kaj 6b7a432c64 Merge pull request #275 from oumike/272
T-Lora Pager: fix Remote Mode black screen and add a way to exit it (#272)
2026-08-19 14:34:49 +02:00
Michael A. Cojocari fafc616cf4 Removing an artifact. 2026-08-17 20:29:40 -04:00
Michael A. Cojocari a08f69a118 Revert "Tweak to the header bar."
This reverts commit 894218c0bb.
2026-08-17 20:27:19 -04:00
Michael A. Cojocari 9a34524c9a Fixes from proposal. 2026-08-15 14:08:06 -04:00
Michael A. Cojocari fa0f426f5b Fixed. 2026-08-14 15:57:08 -04:00
Michael A. Cojocari 894218c0bb Tweak to the header bar. 2026-08-13 20:38:05 -04:00
Kaj SchittecatandClaude Opus 5 b014865ada feat: share your position with chosen contacts, on request (#266)
Requested by janmashat: sharing position currently means putting it in the
advert, which broadcasts it in the clear to every node in range, forever — poor
privacy for something as sensitive as where you are.

Almost all of the alternative was already built and unreachable. MeshCore answers
telemetry on REQUEST, encrypted to the contact that asked, with three independent
permission categories (battery, location, environment) each of which can be denied,
allowed for everyone, or allowed only for contacts carrying a permission bit in
contact.flags. The GPS emitter exists (EnvironmentSensorManager::querySensors adds
LPP GPS when the requester has TELEM_PERM_LOCATION), and the receiving end has
worked since #27 — an LPP_GPS field in a response is persisted with uiSetContactGps
and the contact appears on the map.

The one missing piece was any way to grant it: nothing in the touch UI ever wrote
telemetry_mode_loc, so location telemetry was permanently denied, and the
per-contact ALLOW_FLAGS mode was unreachable because nothing set those bits. The
existing switch is all-or-nothing over battery+environment only, with a comment
noting location "keeps its own separate setting" — a setting that was never built.

So: Settings > Mesh gains "Share my location when asked" (Never / Chosen contacts
only / Anyone who asks, ordered to match TELEM_MODE_*), and a contact's own menu
gains "Share my loc", which sets that contact's location bit and flips to the
per-contact mode if sharing was off entirely — otherwise the grant would silently
do nothing. Revocable from the same place.

Two behaviours made explicit rather than left to surprise people: nothing is
broadcast (it is sent only when that contact asks, encrypted to them), and
MeshCore only answers requests at all when base telemetry is allowed, so enabling
location implies answering — the note under the dropdown says so, and the setter
turns the master switch on rather than leaving a control that does nothing.

No protocol change, so this interoperates with the MeshCore app and other MeshCore
nodes rather than forking behaviour. All 8 S3 boards build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:08:06 +02:00
Kaj SchittecatandClaude Opus 5 fee132425c fix: recover from "LoRa radio not detected" on the first boot after an update
Reported on a T-Deck straight after an OTA: the boot dead-ended on the fatal
"LoRa radio not detected" screen, and a manual reboot cleared it.

On boards with a peripheral power gate the LoRa module has no power until that
pin is driven high in Board::begin() — GPIO10 on the T-Deck — a few milliseconds
before the radio is probed. A cold start survives that because the rail was
already settled. A SOFTWARE reset does not, and an OTA ends in exactly one:
ESP.restart() releases the pin, the rail collapses, the app re-drives it and
probes the SX1262 while it is still powering up. The existing 150 ms retry does
not help because it never touches the rail — the module is latched in a bad
state, not merely slow, which is why only a real power cycle cleared it.

So do what the error screen would otherwise ask the user to do: bounce the rail
(LOW, drain, HIGH, wait out the power-on reset and crystal startup) and probe
once more. Guarded on PIN_PERF_POWERON, so boards without a gate are untouched,
and only reached when the alternative is halting on the fatal screen — a board
that probes normally never executes any of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:46:28 +02:00
Kaj SchittecatandClaude Opus 5 17643435ea fix: leaving an app page by the "<" no longer also opens the status bar
Reported by Istvan on a T-Deck: back out of an app and it goes back correctly,
but the top dropdown opens at the same time.

beta_49 widened statusBarReaderBackCb from the Reader page to EVERY app page, so
the bar now closes a page on touch-DOWN (the cap-touch swipe detector can abort
the CLICKED, which used to trap people on touch-only boards). The comment claimed
the CLICKED that follows was then "a no-op" because close() clears
s_apppage_close. It is not: statusBarTapCb merely skips its app-page branch and
falls through every remaining branch to the control-center toggle at the end. So
the same tap went back AND popped the dropdown. Harmless while this was
Reader-only; wrong for every app page since beta_49.

Swallow the CLICKED that belongs to a press already used to go back, the same way
s_sb_shot_done suppresses the click after a screenshot hold. Timestamped rather
than a plain flag: the entire reason for closing on touch-DOWN is that the
matching CLICKED sometimes never arrives, and a sticky bool would then eat the
next genuine bar tap — a stale timestamp just expires.

Also adds scripts/deploy-apps.sh, which should have existed all along. The device
reads the app and language catalogs from firmware.wadamesh.com/apps/, a tree that
neither release.sh (out/firmware/) nor deploy-site.sh (deploy/site/) ships, so it
was only ever updated when someone remembered to rsync it by hand -- and twice
nobody did. Istvan is on hu.lang v8 while the repo has v11, and the SDK Test app
published earlier today never appeared in the store at all. The script validates
both catalogs first (parse, and every version a catalog points at must exist)
because a malformed one leaves a device with an empty store and no explanation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:40:56 +02:00
Kaj SchittecatandClaude Opus 5 763229b9c9 core: verify in-place contact writes by reading them back (#222)
The in-place patch replaces a whole-file rewrite with positioned writes through
fopen("r+") + fseek. That is a much less travelled path than the rewrite it
replaces, it behaves differently per filesystem (SPIFFS / FAT / LittleFS), and
the data at stake is the operator's contact list — worth more than the freeze
this fixes. I could not exercise it on real SPIFFS from the bench (the V4's USB
is the ROM serial/JTAG peripheral, so the companion protocol is unreachable and
the app's own console goes to UART0), so rather than ship it on the strength of
a code read, it now checks its own work: after patching a slab, read those bytes
back and compare. Any disagreement at all returns false and the caller falls
through to the full atomic rewrite, which is the known-good path.

A filesystem that accepts a positioned write and silently drops it is exactly
the failure that would otherwise corrupt a contact list while reporting success.
The test harness now simulates one and asserts the fast path refuses it.

Also adds the save cost to About > System info ("last save: in-place, 1 rec,
12 ms" vs "FULL rewrite, 431 rec, ..."), so which path ran is observable in the
field instead of only inferable. Confirmed rendering on a V4: 431/2000 contacts
(~63 KB), internal flash 338/3169 KB (10%) — which also corrects the earlier
assumption that these volumes run near-full; at 2000 contacts it projects to
roughly 60%. The mechanism is the size of each write, not the fullness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:06:13 +02:00
Kaj SchittecatandClaude Opus 5 a96900d7ee core: patch changed contact records in place instead of rewriting the table (#222)
Yoss101 reported the beta_62/63 fix helped but did not cure it: the freezes got
rarer AND longer. Rarer is the blob-delete queue working; longer is what was
left, and it was always the bigger half.

MAX_CONTACTS is 2000 and a contact record is 152 bytes, so saveContacts was
rewriting a 304 KB file on every change, with a same-sized .tmp alongside it for
the atomic swap. On a card-less V4 that is ~608 KB of peak churn on a 3.375 MB
SPIFFS volume that is already carrying one blob file per contact. SPIFFS GC cost
scales with how full the volume is, and GC suspends the flash cache, which stalls
BOTH cores — so the fuller the table, the longer the device is simply gone.

The table never actually changes in bulk: eviction replaces contacts[oldest] in
place (the array is unsorted and never shifts) and an advert refresh touches one
entry. So compare each record against what is already on disk and write back only
the ones that differ. An eviction now writes 152 bytes instead of 304 KB, an
unchanged table writes nothing, and there is no .tmp and no free-space spike.
Reads never trigger GC, so the compare scan is cheap.

Falls back to the full atomic rewrite whenever the mapping is not provably safe
(no live file, ragged size, or a table that shrank — there is no truncate here),
and never truncates or renames, so a fallback always leaves a valid list on disk.
Verified against the real function source with an in-memory FS harness: steady
state, eviction, growth, shrink, filtered anon slots, missing file, ragged file,
and 167 scattered changes all match a full rewrite byte-for-byte.

Also: the blob-delete queue silently ORPHANED a blob on overflow — nothing else
ever deletes it, so it leaked flash permanently on the one metric that drives GC
cost. Depth 8 -> 32, and overflows are now counted and surfaced.

New About > diagnostics block "Contact store": contact count, approximate on-disk
size, internal-flash used percentage, and orphaned blobs. The used percentage is
the number that predicts these freezes, so a reporter can photograph it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:36:37 +02:00
Kaj SchittecatandClaude Opus 5 6518ebf391 feat(lua): app.on_message, behind its own permission
The receive half of wada.mesh.send. An app can now be handed incoming channel
messages as { channel, sender, text }, which is what lets one hold a
conversation rather than just talk: auto-responders, bots, relays, games played
across the mesh.

READING is a SEPARATE permission from sending, and that is the point. They are
different risks -- one speaks in your name, the other sees your conversations --
and an app that already has http_get plus unrestricted read could quietly ship
your chat somewhere. So permissions became a bitmask (1 = send, 2 = read) and
the settings page grew a switch per permission rather than one per app.

The on-disk format is unchanged for existing installs: the value was already
written as "1" for send, which is the same as the new mask. Entry PRESENCE still
records "we asked", so "not asked yet" stays distinguishable from "refused".

Read permission raises NO prompt. Unlike send, it would fire on someone else's
traffic arriving, so a dialog would appear unbidden with no action to attach it
to. An app asks for it in Settings -> App permissions instead, which is also
where it is taken back.

Permission is rechecked PER MESSAGE, not at subscribe time, so revoking it stops
delivery immediately rather than at the app's next launch.

All 8 S3 envs build. One linker error caught in the process: luaAppMessage was
defined inside the file's anonymous namespace, giving it internal linkage --
moved out beside the other public entry points.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:00:12 +02:00
Kaj SchittecatandClaude Opus 5 8a555226e7 feat: Settings -> App permissions (review and revoke what apps may do)
You could GRANT a Lua app permission to transmit but not review or take one
back, except by deleting /apps/perms.kv by hand. That is acceptable on a bench
and not in a release: "which apps can send as me?" has to be answerable, and
answerable without knowing a file path.

Lists every INSTALLED app plus anything already recorded in perms.kv, each with
a switch and its state in words.

Three deliberate choices:

"Not asked yet" reads differently from "refused". One is a decision the user
made, the other is not, and an off switch alone would collapse them.

Removed apps still appear if a decision exists for them. Otherwise reinstalling
silently inherits an old yes, and the grant about to be used would be invisible.

The switch grants as well as revokes, so a permission can be given ahead of time
instead of only in reply to a prompt.

Compiled out entirely where CAP_LUA_SDK_EXT is off -- no dead settings entry on
a board where nothing can request a permission.

Verified end to end on a T-Deck: cancel at the prompt -> app shows "refused" and
send fails -> toggle on in this page -> send returns true and transmits. All 8
S3 envs build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 15:46:31 +02:00
Kaj SchittecatandClaude Opus 5 27d89ccb60 fix: the Store no longer throws you back to the top while you scroll
Reported on device: open the Store, start scrolling, and it jumps back to the
top a second later "when the status of the buttons update".

luaStoreRebuildList() does lv_obj_clean() and rebuilds every row, and it is
called from three ASYNC completions -- the catalog fetch, the SD card scan and
the language catalog -- which land a second or two after the Store opens. That
is exactly when someone is scrolling it.

It now records the scroll offset and restores it after the rows exist, so the
value is clamped against the NEW content height rather than the old one.

This also explains a second report that looked unrelated: an app near the BOTTOM
of the catalog appeared to have no Update button. The button was correct; the
rebuild was scrolling it off screen before it could be seen. Verified on a
T-Deck -- the app in question updated and now runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:37:49 +02:00
Kaj SchittecatandClaude Opus 5 cb044e490d feat(lua): hardware keys reach Lua apps (on_input type="key")
Lua apps could see touch and trackball direction, and nothing else. On the four
boards with a keyboard -- T-Deck, T-Lora Pager, ThinkNode M9, Attaky -- that made
most app ideas impossible: no text entry, no shortcuts, no game controls, on the
devices whose whole point is having keys.

Keys now arrive through the EXISTING on_input callback as type="key" rather than
a second callback, so an app keeps one input model and one branch. `key` is a
one-character string for printable input and a name ("up", "enter", "backspace")
otherwise, so an app writes ev.key == "w" without knowing scancodes; `code`
carries the raw value for anything unmapped.

Hooked once in handleHwKey(), which is the shared dispatch for all four
keyboards, so no per-board work and no per-board drift.

The dismiss key is deliberately NOT forwarded. An app must never be able to trap
the user by swallowing its own exit, by bug or by design -- the same reason the
send permission is a user decision rather than a manifest claim.

luaAppKey() returning false leaves the firmware's own handling untouched, so an
app without on_input behaves exactly as before. All 8 S3 envs build, including
the V4 which has no hardware keyboard at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:25:45 +02:00
Kaj SchittecatandClaude Opus 5 8df7fa3b12 feat(lua): wada.mesh.send with per-app user consent (gated)
The first WRITE path a store app has into the mesh, and the one API where a bad
app costs other people rather than just its own device: LoRa is a shared channel,
and anything sent goes out under the user's own node name -- to a reader it is
indistinguishable from the user typing it.

So it is consent-gated, and the grant is bound to ONE app id. Approving a beacon
app grants nothing to any other app; re-installing under a different id asks
again. Grants live in /apps/perms.kv, a plain text file the user can inspect or
delete to reset every decision.

The app cannot bypass or pre-empt it. It never sees a prompt API: wada.mesh.send
simply fails until a grant exists, and the FIRST refusal raises the dialog. So
consent is always something the user did, never something the app asked for at a
moment of its choosing.

Deny-by-default and crash-safe: the refusal is written BEFORE the dialog opens,
so a power cut mid-prompt cannot leave a grant behind, and the app cannot
re-prompt in a loop -- its next call reads -1 and fails silently.

The dialog says what actually matters. "Send messages" understates it, so it
names the node the messages will carry and states they cannot be told apart from
messages the user typed.

Two further limits, since consent is not a blank cheque: 5 s minimum between
sends per app (airtime is shared, and far scarcer than flash), and a 180-char
cap so one call cannot occupy the channel. The rate clock only starts on a real
transmission, so failed sends cannot be used to game it.

Channels are matched BY NAME, not by a cached slot index -- a stale slot
transmits on the WRONG key, which is the bug behind the composer's channel-send
fix and part of #260.

A review caught one defect before commit: the original "one prompt at a time"
guard was only cleared on Allow, so a cancelled dialog would have blocked every
OTHER app from ever prompting. Removed -- the on-disk refusal already prevents
re-prompting.

All 8 S3 envs build; meshSend/luaHostMeshSendChannel verified absent from the V4
image and present on T-Deck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:13:00 +02:00
Kaj SchittecatandClaude Opus 5 5353d3a899 feat(lua): wada.fs -- scoped, rate-limited file access (gated)
Apps had wada.store, a key/value table flushed once on exit. Anything larger --
a log, a track, an exported CSV -- was impossible. wada.fs adds read/write/
append/list/remove behind CAP_LUA_SDK_EXT.

Two things make this safe rather than just possible.

SCOPE. Every app gets exactly one directory, /apps/<id>.d/, and cannot address
anything outside it. Store apps are user-submitted, so the name check is a
security boundary: no '/', no '..', no leading dot, strict [A-Za-z0-9._-], 32
chars. It REJECTS rather than sanitises -- a sanitiser that "fixes"
../../identity into something valid is how these go wrong. Checked against a
traversal list including '../identity', '/etc/passwd', 'sub/../x' and an
embedded NUL.

RATE LIMIT. This is the API where an app can do the thing that has twice taken
this firmware down: on internal flash a burst of small writes triggers garbage
collection, and a GC pass suspends the flash cache and stalls BOTH cores (#222,
and the beta_25 bootloop). deploy/apps/README.md already asks authors not to
write in a loop; asking is weaker than enforcing, so writes are capped at one
per second per app and return `false, "too fast"` otherwise. The write itself
runs under WdtHeavyGuard, so a pass that DOES trigger GC stalls briefly instead
of rebooting. Reads are capped at 32 KB so an app can never be handed an
unbounded buffer.

All 8 S3 envs build. Gate re-verified by symbol: fsWrite/fsWriteCommon exist
only in the T-Deck image, not the V4 TFT one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:52:52 +02:00
Kaj SchittecatandClaude Opus 5 ad31f2e581 feat(lua): CAP_LUA_SDK_EXT gate + sys.caps/battery/gps
First slice of the extended SDK, and the gate the rest hangs off.

The BASE SDK (drawing, timers, store, read-only mesh, http_get) stays on every
board. The EXTENDED SDK -- live device state, file access, mesh writes, richer
input -- is gated by CAP_LUA_SDK_EXT, which is OFF on the 2 MB Heltec V4. That
board already runs at ~95% internal RAM with Wi-Fi up; it is the same headroom
problem that gates CAP_WEB_BROWSER and forces CAP_BUILTIN_LANGS there.

A future low-resource board opts out with ONE line in its own block, either
WADA_LOW_RESOURCE_BOARD (also gates future extras) or CAP_LUA_SDK_EXT 0.

In this slice:
  wada.sys.caps()     -> { sdk_ext, keyboard, touch, sd }   feature detection
  wada.sys.battery()  -> { mv, pct, charging }              gated
  wada.sys.gps()      -> { lat, lon, sats } or nil          gated

caps() ships on EVERY board including the V4 -- an app has to be able to ask
before it branches, so the one call that reports absence can never be absent.

gps() returns nil without a fix rather than the last known position, which
wada.mesh.self() already gives: a track logger has to tell those apart.

Two things deliberately NOT shipped rather than shipped broken:

  wada.sys.sensors() -- the only environment-sensor source in the firmware is
  LocalEnvSnapshot, which is #if defined(HAS_EXPANSION_KIT), i.e. the Heltec V4
  Expansion Kit. That is exactly the board this gate excludes, so the call would
  have returned nil on every board able to make it. Sensor access belongs on a
  HARDWARE gate, not this memory gate.

  GPS altitude -- the LocationProvider holding it is a private UITask member with
  no public accessor, unlike getGpsFix()/getGpsSats(). That header change is
  worth making deliberately, not in passing.

Verified by symbol, not by eye: luaHostBattery/luaHostGps are ABSENT from the
V4 TFT image and PRESENT in the V4-R8 and T-Deck ones -- which also proves the
same-family R8 carve-out resolves correctly. All 8 S3 envs build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:42:18 +02:00
Kaj SchittecatandClaude Opus 5 0ad28ce7db feat(lua): wada.sys.epoch, datetime and beep (#245)
pisti87 asked for time and buzzer access from Lua. wada.sys had millis, toast,
board and random -- an app could measure elapsed time but never say what time
it was, and could not make a sound at all.

  wada.sys.epoch()     -> Unix seconds, or nil
  wada.sys.datetime()  -> { year month day hour min sec wday }, or nil
  wada.sys.beep()      -> true if a sound was actually produced

Three decisions worth recording:

nil, not 0, when the clock has never been set. The device genuinely boots
without a clock until GPS, NTP or a mesh peer supplies one, and an app stamping
a log needs to tell "no clock yet" from "1970". Uses the same sane-clock floor
the mesh already applies.

datetime() is LOCAL time, in the timezone the user picked, because every use for
it is display. epoch() stays UTC seconds for arithmetic.

beep() is one chime, not the melody player the issue sketched. What exists is a
single per-board notification chime -- and some boards have no sounder. Routing
Lua through the same uiPlaySlot() the UI uses means an app cannot be louder or
different from the rest of the firmware, and it honours the user's sound setting
instead of overriding it. Returning whether sound actually happened lets an app
fall back to a visual cue rather than silently doing nothing, which a fake
melody API could not.

All 8 S3 envs build -- the beep path is per-board, so that matters here.

Requested by pisti87.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:26:40 +02:00
Kaj SchittecatandClaude Opus 5 7e0bed04ec fix: accented characters in text-field placeholders were boxes
pisti87's photos show "Halozat nev" as "H[]l[]zat n[]v" in the hidden-network
SSID field, and the MQTT username hint as a lone box. Typed text in the same
field is fine.

LVGL draws a text area's placeholder from LV_PART_TEXTAREA_PLACEHOLDER, and that
part does not inherit the LV_PART_MAIN font the creation sites set. So the
placeholder fell back to the theme's plain Montserrat, which has no accents.
27 placeholders in the UI; the part was styled in exactly none of them.

This is the same family as the beta_62 tofu fix but a part that sweep never
touched -- it covered ~79 labels, and a placeholder is a different LVGL part.

Routed every call through taSetPlaceholder(), which sets the text and then gives
the placeholder part the chained face resolved from whatever MAIN already
carries. It therefore cannot change how any field looks, only which glyphs it
can draw. Placeholders are frequently set BEFORE the site assigns MAIN's font,
so an unrecognised font falls back to g_font_14 -- the size those sites end up
using anyway.

Builds on V4, T-Deck and Pager. Reported by pisti87.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:13:38 +02:00
Kaj SchittecatandClaude Opus 5 dfcc07aafe ui: say that "Overwrite oldest" spares favourites (#178)
The setting already behaves this way — BaseChatMesh's eviction loop skips any
contact carrying the favourite flag, so a starred contact is never the one
dropped when the table fills. The label just never said so, which left people
roaming between regions unable to tell whether starring a contact protected it.

Renamed to the reporter's own wording, "Overwrite oldest non-favorite", and
registered in all 13 language files. No behaviour change.

Reported by mikecarper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:42:27 +02:00
Kaj SchittecatandClaude Opus 5 b5ed24cddd i18n: translate the Discovered/Contacts sort sheet and the traffic panel (#257)
First pass at pisti87's list. These were never wrapped in TR(), which is why
uploading a translation did nothing for them — there was no key to match.

  Sort by / Sort discovered, and the filter rows Peers, Favorites,
  Has location, Direct (0-hop); the home traffic panel's
  "Traffic (since boot) / Sent / Recv"; and the contact list's "Heard <ago>".

Keys registered in all 13 language files as empty rows. audit-lang.py reports
0 missing and 0 unsafe. The two new format strings are covered by the #258
placeholder guard.

Deliberately NOT done in this pass — the rest of the list needs a check I did
not want to rush:

  - the map option table (Show coordinates, Show tile z/x/y, Tile debug
    overlay, ...) is a static initialiser, so TR() cannot go in the table; it
    has to be applied where the rows are consumed.
  - "Name (A-Z)" / "Recent message" / "Nearest first" are dropdown options.
    Translating an option string breaks any code that maps a selection back by
    comparing text rather than index, and we have shipped that exact bug before
    (channel send matching by name). Each needs its consumer read first.

Reported by pisti87.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:58:27 +02:00
Kaj SchittecatandClaude Opus 5 e1db4f50f2 feat: let a companion app opt into the full RX log over BLE (#256)
Since beta_23 the per-packet RX log (PUSH_CODE_LOG_RX_DATA, 0x88) is kept off
BLE, because it floods a ~16 frame/sec link and starves chat and admin traffic
(#46, #54). That is still the right default and is unchanged here.

But it is also the ONLY frame carrying the transport codes and the full relay
path — RESP_CODE_CHANNEL_MSG_RECV_V3 has neither — so coverage and region
mapping apps have had no way to reconstruct either over BLE. @marcelverdult,
who writes KiekR, traced this through our source and asked for an opt-in rather
than a revert.

A companion can now request it per session:

    CMD_SET_CUSTOM_VAR   "ble.rxlog:1"     (and "ble.rxlog:0" to stop)

Chosen over a user-facing setting because the tradeoff belongs to the app, not
the user: an app that wants the firehose knows it wants it, and nobody else
should have to understand the question.

Deliberately NOT persisted. A stored flag would silently reinstate the #46/#54
flood for someone who tried a coverage app once and moved on — on the one link
that cannot absorb it. Asking again after each connect is cheap for an app and
is the safe default for everyone else.

The existing #94 one-shot (echoes of our own sends, so "Repeats heard" keeps
working) is untouched and still applies when the firehose is off.

Builds on T-Deck, V4 and Pager.

Requested by @marcelverdult.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:30:19 +02:00
Kaj SchittecatandClaude Opus 5 0fe3357dcb i18n + docs follow-up to #263: the new save-chip strings
@oumike's #263 replaces the save chip's floppy icon with words, which is the
right call — two people independently read the icon as ambiguous, and one of
them was the maintainer. Two gaps came with it, both fixed here rather than
blocking the merge:

  - "Saved %s", "Save FAIL x%u" and "Save migrating" were bare English
    literals. The chip previously had no words at all, so it was language
    neutral; as written it would have been permanently English in all 13
    languages and reopened the drift closed in d4ade2e. Wrapped in TR() and
    registered in every .lang file as empty rows. audit-lang.py: 0 missing,
    0 unsafe.
  - the user guide, published yesterday, describes a floppy-disk icon and lists
    the states as bare times. Reworded to match what the firmware now draws —
    docs and UI have to move together or the guide is worse than none.

"Save FAIL x%u" and "Saved %s" carry printf placeholders, so they are covered
by the format-safety guard added for #258: a translation that reorders or drops
them falls back to English instead of misreading the stack.

All 8 S3 envs build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:20:28 +02:00
Michael A. Cojocari 9a358954d1 Heltec tweaks. 2026-08-12 20:44:48 -04:00
Michael A. Cojocari 7152400e4c Keyboard shortcuts for the pager build. 2026-08-12 18:52:52 -04:00
Michael A. Cojocari 168ecebcdb Merge remote-tracking branch 'upstream/main' 2026-08-12 15:58:38 -04:00
Michael A. Cojocari eecc8219c4 Wifi icon matches theme. 2026-08-12 15:55:08 -04:00
Kaj SchittecatandClaude Opus 5 a2e18c1811 fix: the arrow glyph was never in the fonts (#261)
pisti87 found "Settings → Quick replies" drawing a tofu box, in English as well
as Hungarian. It is not a language bug and it is not the beta_62 fallback-chain
fix falling short — the glyph simply was not in any font we ship.

gen-touch-fonts.sh asked $noto_sans for the symbol set, and Noto Sans does not
contain U+2190-2193 or U+2260/2264/2265 (verified directly against the release
the script pins). lv_font_conv omits a glyph its source font lacks rather than
failing, so those seven characters silently never made it into extras_font_*,
in every language, since the fonts were first generated this way.

Scope is much wider than the one line reported: 137 uses of → in the touch UI
plus arrows in all 13 .lang files. pisti87 happened to open one of them.

Fixed by cutting the seven from Montserrat, which has all of them, is already
the primary UI face and already the first --font in this script — so no new
dependency, no new licence line, and the arrow matches the text beside it. They
had to be REMOVED from the Noto Sans symbol list as well: with the codepoint
claimed by a later font that cannot supply it, the Montserrat pass produced
nothing and the regenerated files came back byte-identical apart from a comment.

Noto Sans Symbols 2 was the obvious candidate and does NOT have them either —
it errors outright when asked, which is how that was ruled out.

Not visually confirmed: I have no way to see the glyph render from here. The
regenerated fonts grew ~390 lines per size and lv_font_conv accepted the request
(it hard-errors when the source font has none of the symbols, as Symbols 2 did),
so the glyphs are in. Worth a look on a device before the beta ships.

Reported by pisti87.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:20:35 +02:00
Kaj 874a65bc3e Merge pull request #234 from PixPMusic: stabilize virtualized chat teardown (#233, #251)
fix(touch): stabilize virtualized chat teardown and render scheduling
2026-08-12 11:33:10 +02:00
Kaj SchittecatandClaude Opus 5 d41bcd3c0f i18n: register the three new scope strings, and fix a key that could never match
The #259 popup change introduced TR("Scope "), TR("my region") and
TR("another region"). Two problems, both caught by re-running the audit rather
than by eye:

  - none of the 13 language files had rows for them, which would have reopened
    the drift closed in d4ade2e. Added to all 13 as empty rows (the format's
    own untranslated marker), so translators see the gap and English falls
    through meanwhile.
  - the key was "Scope " with a trailing space. TR() strips icon-glyph prefixes,
    NOT trailing whitespace, so the lookup would never have matched a "Scope"
    row and that label was permanently English in every language. The space now
    lives in the format string where it belongs.

audit-lang.py: 0 missing and 0 unsafe across all 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 09:16:38 +02:00