Every screen redesigned to the Oct 2026 mockups, on one design system:
palette, type roles (generated Montserrat faces with Lucide line icons on the
LV_SYMBOL codepoints), a theme apply-cb that styles every LVGL widget, button
kinds, list rows, sheets as action lists, toasts and key-glyph avatars.
Rebuilt: status bar, Chats (filters, + and menu), conversation (route header,
hop meta, "Heard by N repeaters", delivery states, pill composer), lock screen
(mark, clock, 24 h activity, unread cards), Constellation screensaver, Home
launcher and launcher sheet, Status page, grouped Settings home, animated boot
splash, and the message glance as a notification card.
New: optional PIN lock (salted SHA-256, keyboard / touch pad / number pad per
board, backoff that survives reboot), screensaver on battery with its own
duration, T-Deck text sizes, trackball hold to switch Scroll and Cursor (Scroll
default, prefs v69/v70), hold Settings tab for the control panel. M9 bottom
notices removed. Every screen measured for the Large and Huge text sizes.
Fix: adverts over 2/3-byte path hashes never reached the recently heard table
(packed path_len compared as a byte count); ADVERT_PATH replies now carry the
whole multi-byte path.
English placeholders for the 204 untranslated keys in every .lang file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The logo is painted before SPIFFS/SD mount, so it read rotation from the
legacy NVS settings copy, not the real file-backed settings. Any device
whose two copies disagreed (the Attaky after its v68 landscape reset, or
anyone who changed orientation since settings moved to files) showed the
logo in the old orientation until the UI took over.
Keep a raw-NVS boot copy of the rotation (touch/boot_rot): written by
touchPrefsSetUiRotation() and re-synced after touchPrefsReload() every
boot, so schema resets and restored backups catch up too. The logo reads
it, falling back to the old read when absent (first boot after update,
or Launcher installs without NVS). On the Attaky any landscape value is
treated as rotation 1, as UI init does.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Mesh Deck UI was pinned to landscape (LV_DISP_ROT_90), and the
build without a keyboard is held upright, so it needs portrait too.
- CAP_ROTATABLE on the Attaky: Settings > Display > Orientation toggles
landscape (ROT_90, panel rotation 1) and portrait (the panel's
DISPLAY_ROTATION=2, no touch transform, as on the V4).
- Landscape stays the default: DEFAULT_UI_ROTATION is 1 on this board,
and schema v68 (no new field) resets the stored value once, since the
UI ignored it until now. The pre-LVGL boot logo already follows the
setting, so it is upright in both.
- The front D-pad is named for landscape, so in portrait a press turns
with the screen (right -> up, down -> right, left -> down, up -> left).
- The terminal (Console Mode) keeps the panel's build orientation,
whatever the UI orientation is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019M5VJ43X3nUULPqVcgrAH2
The wheel is the Pager's only way to move the highlight, which is slow
and imprecise. Bring the T-Deck's keyboard navigation to it:
- With no text field focused, programmable letters move the highlight
(default I up, K down, J left, L right) and scroll (U / O). Select
and Back default to no letter, since Enter and Backspace-hold (the
wheel's click / long-press) already do them. On the Map the letters
move the highlight; W/A/X/D stay the map pan.
- The tab keys are programmable like the T-Deck's, defaulting to the
Pager's M/C/H/A/S, and the bar's labels show whatever is bound.
- Settings > Keyboard gets the same "Keyboard navigation" switch (on by
default here) and the same tap-a-row-then-press-a-key remap rows; the
T-Deck's rows move into settingsNavKeyRows() so both boards share them.
The Pager's switch only turns the letters on or off; it never empties
the focus group, which the wheel always needs.
- A remap refuses letters the Pager uses for fixed actions (Q/E slider
nudge, W/A/X/D map pan; A stays allowed as a tab key).
- In wheel edit mode on a slider, J/L preview like a turn and the
select key closes the edit, matching the wheel.
Settings: the Pager's kbd_nav and key bindings were never shown on that
board, so they held the T-Deck defaults. Schema v67 (no new field) puts
the Pager defaults in once on that board only. Two new strings,
translated in every deploy/apps/lang file; i18n_builtin.h regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019M5VJ43X3nUULPqVcgrAH2
On short landscape panels (the T-Pager is 480x222) the bottom nav bar
spends a full-width strip on five icons. New Settings > Display switch
"Condense Nav", off by default, applies after a restart:
- The nav bar becomes a rail on the right edge (LV_DIR_RIGHT tabview).
Pager cells show the key letter under the icon; the Tanmatsu keeps its
coloured F-key shapes; the T-Deck / P4 key hints sit under the icons.
- Home's launcher column (Advert, Terminal, Files, Apps, Control) shows
icons only; the buttons keep their height and spacing, and the status
text, chart and info card widen into the freed space.
- The active-tab marker turns vertical beside the active cell, the
unread and update badges move to the top-right of their cells, and the
rail stays solid (and the marker visible) on the Map.
- Content that kept the bottom bar visible now stops at the rail
instead: tabContentW() subtracts the rail, and the map canvas, Settings
landing grid, chat-list rows, Mentions and the app drawer use it.
Swipe-left on the rail opens the drawer (swipe-up on the bottom bar).
Portrait layouts and the M9 are unchanged: the mode is decided once in
buildUiTree from the pref and the orientation at boot, the switch is
greyed out in portrait and absent on the M9.
TABBAR_H becomes a runtime value (0 with the rail) next to a new
TABBAR_W. The pref is a new trailing field, condense_nav (schema v66);
the schema test's layout asserts now count the v64 fields they missed.
Strings added to every deploy/apps/lang file and i18n_builtin.h
regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019M5VJ43X3nUULPqVcgrAH2
- New crowpanel_35 variant: display, SD, board and target files, plus board JSON
- Portrait mode is drawn rotated 180°; landscape stays at rotation 3
- Share the GT911 touch driver between Wio Tracker L2 and CrowPanel
(WioTrackerL2Touch.cpp -> src/helpers/input/LgfxGt911Touch.cpp)
- platformio envs, flasher/site entries, release and merge-bin support
- Tests for the CrowPanel SD codec and merge-bin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132MkJ7RUV32mV9F7o4PJMm
The T-Display P4 keyboard expansion (TCA8418 + its own XL9555, ported from
camillia-mt) now works. It is looked for every 1.5 s, so it can be clipped on
or off while running, and is routed like the CardKB and Bluetooth keyboards:
it types into the focused field and keeps the on-screen keyboard down. The
external-keyboard routing was gated on Bluetooth, which the P4 lacks, so it
now follows a new CAP_EXT_KEYBOARD; the Bluetooth-only parts stay on
CAP_BLE_KEYBOARD.
- Keys: Shift/Sym/Alt one-shot, Caps Lock lights the expansion LEDs, arrows
and Backspace repeat, Alt+Backspace backs out, F1-F5 open the tabs left to
right, and F11 steps the keyboard light through off/low/medium/high (saved
across reboots).
- Wire1 is shared with the ES8311 codec on other pins; P4I2c1 switches it
under a lock, and the codec's lazy bring-up now goes through it.
- With the keyboard attached the tab bar labels F1-F5 beside each icon, and
Enter on a chat bubble opens its action menu.
Also, on any keyboard build, Enter runs the command in the terminal and the
repeater admin console (the admin console's Enter hook sat on its Send button,
where nothing raised it). And on the P4: the status-bar name shrinks to fit the
bar, the Discover buttons are spaced out, contact names clear their icon in
landscape, and leaving an app returns the focus to its tile in the app drawer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backup carried node settings but none of the touch UI's own
preferences. Add an "app_settings" block: the active Wi-Fi connection
(SSID, password, radio/BLE switches) and every touch preference key as
["key","type","value"] triples: the packed cfg blob (display, theme,
keyboard, map, UI scale, notifications, advert timers...), sound and
DND settings, tile server, lock wallpaper, notification sounds, quick
replies, channel scopes, favourites, ignore lists, channel mute and
emoji, repeater passwords, the last radio preset and saved Wi-Fi
networks.
Import restores them as section bit 0x20 (the UI's Import now applies
it). Only keys and types this build writes itself are accepted. The cfg
blob is merged field by field and keeps what belongs to the unit:
battery calibration, GPS baud, clock floor, FEM/antenna, the paired BLE
keyboard, remote/console boot modes and beta-report counters. Storage
choice, the setup flag and the discovered-nodes cache are not exported.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The M9 gets the V4-R8's text-size presets verbatim, because it is the same
240x320 panel and two tables for one screen size is two things to keep in step.
Fonts grow, geometry does not: rows, cards and controls keep their dimensions
and stay reachable, which is why the labels say "Large text" rather than a
percentage.
⚠️ The migration matters more than the feature. ui_scale has been in the saved
blob since v8 and every M9 carries the large-screen default of 1 that this board
never applied, so exposing the selector without resetting it once would bring
every M9 back from the update rendering Large text nobody chose. Prefs go to v65
with that one-time reset, which is the same correction v63 had to make for the
V4-R8 after it happened to them.
The P4 can run landscape. Its MIPI-DSI panel has no MADCTL to rotate, so LVGL
rotates each flushed area into panel coordinates, exactly as the Tanmatsu
already does on the same silicon; the difference is that the P4 reads the saved
preference instead of pinning one rotation. Portrait stays the default and the
existing Orientation button does the rest.
The rotated branch of the P4 touch mapping had never run: it was carried "for
parity" while the panel was portrait-only, and it clamped rotated coordinates
against the portrait bounds, so every landscape touch past x=284 would have
pinned to the screen edge. It clamps against the swapped space now.
Landscape is UNTESTED on hardware: Kaj did not have his P4 with him, and it
ships that way deliberately, with the release notes saying so and asking P4
owners to report smoothness and touch accuracy near the edges.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settings, About gains "Report this build": works or a problem, five tick
boxes for what you actually exercised, how long you have run it, an optional
line. It posts to the report service and lands on wadamesh.com/beta, which is
what the next promote is decided on. Ticking matters: "it works" from somebody
who watched it boot and "it works" from somebody who messaged on it all week
are not the same claim, and a board only goes green on two of the second kind.
"Report a bug" draws a QR for a prefilled GitHub issue form. The issue is filed
from the reporter's phone under their own account, so no write token has to
exist in a public firmware image and the reporter gets the replies.
"Count this device" is off by default. It lets the update check say which board
and version it is running, so a board with no reports can be told apart from a
board nobody owns. Those need opposite responses and nothing could tell them
apart before.
Mechanics: the POST rides the existing core-0 tile/update worker and its
WiFiClient/HTTPClient, like the version check, because a second pair on that
~8 KB stack overflows it. The device id is a salted SHA-256 of the public key
truncated to 64 bits, so a second report replaces the first without saying who
sent it. Sending is two taps and the second lists every field that leaves.
Prefs v64 appends report_ping + report_done_n at the tail, which the schema's
trailing static_assert now checks.
Also here: scripts/build/matrix-check.sh prints what testers reported for a tag,
and release.sh runs it before a --promote. It never blocks; a board nobody owns
can never go green. With no reports at all it now says so rather than reporting
a clean bill of health, which is the habit this is meant to replace.
Built on all eleven S3 envs and the T-Display P4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the freeform seconds field with practical duration stops from 30 seconds through one hour and Never, normalizing legacy values to the nearest stop.
Refs #560
E-paper adaptations for the Max, all behind HAS_TDECK_MAX: no fades, the
page repainted before power-off so no black frame is left on the glass, a
sleep banner, tap to wake, and the keyboard-backlight chord.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bluetooth serves either the phone app or a keyboard (Settings > Bluetooth).
Pairing lists keyboards in pairing mode and pairs with or without a code;
the paired keyboard reconnects by itself.
Keys are read the way phones and computers read them: the keyboard's Report
Map says which report carries the keys and how, so media keys and touchpads
are left alone, and the boot protocol is only the fallback. Key positions are
translated with the chosen layout (US, UK, German, French, Belgian), with
AltGr and dead keys.
While a keyboard is connected:
- text goes into the focused field, and touchscreen-only boards keep the
on-screen keyboard down (a second tap on a field brings it up anyway)
- the arrows, Tab, Enter and Page Up/Down drive the focus highlight
- Esc is the back button; a "Back key" setting picks another key for
keyboards whose Esc key types a character
- tab hotkeys and their hints over the tab bar are switched on
- Command tapped alone opens the emoji picker in a chat
- the status bar shows a keyboard instead of the Bluetooth glyph
The Bluetooth page is now a flex column with the choice on top, and the
settings page refit keeps it scrollable when its content grows.
Settings: prefs v61 (mode, layout, paired keyboard) and v62 (Back key).
Tests: test/test_hid_report_map.cpp, test_touch_prefs_schema.cpp.
Not on the P4 boards (Tanmatsu, T-Display P4).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#464 (PD0RCM, T-Deck Plus): "I can hear the sample sound and the volume change,
but not incoming messages." That board has the hardware, and previews and
arrivals go through the same playback function on it, so the audio path was
never the problem.
Every preview on the Sound page played UNCONDITIONALLY: the sample, the volume
steps, and each per-type toggle. An arriving message checks the master Sound
switch and Do Not Disturb. So with the master off the page chimes at you the
entire time you configure it while nothing ever sounds on arrival, which reads
exactly like a broken notification path and is almost certainly what he hit.
A preview is now silent precisely when a real notification would be, and says
which of the two silenced it rather than just going quiet. The master switch
keeps its own confirmation chime, because that fires exactly when you turn it
ON, which is the one case where a sound is the right answer.
Telemetry position (v59, honza_87628, via Discord): the advert displacement of
#399 was extended to telemetry answers because a privacy setting a telemetry
request walks straight around is a hole rather than a feature. He then made the
fair counter-argument that an answer is not a broadcast: it is encrypted to one
contact who already holds the permission, and it only goes out because they
asked. Losing the ability to give trusted contacts a real fix is a real loss.
So make it a choice instead of an assumption. New tail field telem_loc_exact,
OFF by default, so the private behaviour is what you get unless you say
otherwise, and the broadcast advert stays displaced either way. The switch sits
directly under "Share my location when asked", where the decision belongs.
Schema 58 -> 59. The host test enumerates every appended field and caught the
change, as designed; all three of its migration assertions are updated and it
passes.
Reported-by: PD0RCM, honza_87628
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#415: a Lua app was covered by the app drawer whenever a message arrived.
Reported by jadestarwatcher, reproduced by mysterywavi, both on T-Deck. Mine,
from the #393 badge fix. Apps launched from the drawer deliberately leave it
alive underneath (appTileCb says so outright), and my badge refresh rebuilt it on
every unread-count change, with openAppDrawer() ending in a move_foreground that
threw it on top of the running app.
The refresh now runs only when nothing is drawn in front of the drawer, tested by
sibling order rather than by listing the tools, so a tool added later is covered
without anyone remembering to update a list. The status bar is excluded, since it
legitimately floats above the drawer at all times and treating it as covering
would have stopped the badges refreshing at all. Leaving the signature stale is
what makes it self-healing: the check runs again each tick, so the count is right
by the time the drawer is back in front, with no need to hook every close path.
Scroll position is now preserved across the rebuild too, which my change had also
been resetting.
#410: on the M9 the accent variants appeared but could not be selected. Also mine,
from the #387 work. The picker was handled below m9HandleArrowKey, which takes
LEFT and RIGHT for the caret and returns, so the arrows never reached it: the box
was drawn and nothing could touch it. Lifted above that call, as its own function
rather than a second copy of the logic.
#399, requested by @Danie10: an option to advertise a position near you rather
than your address. Settings, GPS, cycling exact / 100 m / 250 m / 1 km.
The displacement is derived from the node identity, so it is the same offset
every time. That is the whole point rather than an implementation detail: a fresh
random offset per advert would scatter points around the true position, and
averaging a night of them would recover the centre exactly. A fixed displacement
instead looks like a node that sits somewhere else, which is what a manually set
location already looks like. It applies only to our own adverts; the map and the
GPS page keep the real fix, because the aim is to tell other people less, not to
lie to yourself.
Schema v57. The host test caught the new field the moment it was added, which is
what it is for.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oumike. A firmware-wide appearance setting: Night stays the default and renders
exactly as before, Day is a low-glare light palette with its own semantic roles
for text, panels, fields, borders, controls, focus, charts, status colours and
chat surfaces. Selecting a mode restarts, so every LVGL object is rebuilt from
one coherent palette rather than half-repainted. There is a Night/Day selector
in Settings, Display, the Control Center Theme chip is now a direct toggle whose
sun/moon icon shows the active mode, and the standalone console UI has a
matching Day palette.
Main already carried the palette scaffolding for this, pinned to Night behind a
comment saying it was waiting on this branch, so the merge mostly replaces those
stubs with the real thing.
The prefs schema needed care. The branch appended theme_mode as v54, but v54 and
v55 were taken by boot_wifi_time/boot_wifi_open and loud_alerts before this
merged, so the field moves to the tail behind them and the version becomes v56,
with the trailing-field assert and the migration step moved to match. A packed
struct read back at the wrong offsets is the failure this schema's asserts exist
to prevent, so the invariants from both sides are kept rather than one replacing
the other.
That also surfaced a stale test: the v53 case asserted that v53 plus exactly two
bytes was the whole struct, which stopped being true when loud_alerts landed and
was not caught because the host test was not run then. It now counts every byte
appended since and asserts the new fields come back at their defaults. The
schema host tests pass. While there, the size comment said "about 500 bytes";
the struct is 114.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Buko84 asked whether the M9's notification sounds can be made louder. There is
no volume setting because there is nothing to turn: the buzzer on these boards
is a bare piezo on a GPIO driven by tone(), a fixed-duty square wave, so
amplitude is simply whatever the part does at that pitch.
Frequency is the lever that does exist. A piezo is far louder near its mechanical
resonance, typically around 4 kHz, than at the 1 to 2.6 kHz the chime uses, so
"Loud alerts" in Settings, Sound plays the same three-note shape shifted into
that band. Same chime, so it still reads as the same alert rather than a new one,
and toggling it plays it so the difference is audible while the switch is still
under your finger.
Off by default and opt-in, because the exact resonant peak varies by part and
this has not been measured on an M9. Schema v55, field appended at the tail.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
s_ign_names_migrated was set before the work, so a failed re-open for write
(the same NVS failure ignWriteAll returns false for) latched a fold that never
happened. From then on ign_nm2 does not exist and ign_nm is never consulted
again, so ignNamesReadAll returns 0: every name block silently stops firing for
the rest of the boot. Worse, the next Block tap writes a fresh ign_nm2 holding
only that one entry, and the next boot's migration sees ign_nm2 present, skips,
and the old list is orphaned for good.
Latch on success instead, and remove ign_nm only after the new key actually
holds the list -- keeping it costs nothing, since nothing but this function
reads it and this function reads it at the correct old stride.
The "nothing to do" case still latches on the first call, so the steady-state
cost the latch exists for is unchanged.
MeshCore names run to 31 characters (ContactInfo::name[32]), but
UIMessage::sender held 24, so every longer name was cut on the way in.
d304d3f made the cut visible instead of letting it push the author into
the message body; this makes it stop happening. MAX_SENDER_NAME goes to
31 — exactly the wire width, so nothing is lost.
Truncation could never fix three of these. "Ack" and "Mention" insert
@[<sender>] into the composer, and textMentionsMe needs the whole name
followed by ']', so a mention of a long-named node never reached them.
Block-by-name compares the stored name against the sender on the RX
path; for ROOM posts those two came from different widths already — the
check sees the untruncated 31-char author while the stored entry came
from the 24-char field — so a room author with a long name has simply
been unblockable. Info and the chat-list preview showed a cut name.
The obvious change — widening UiHistoryMsg::sender — would have been
quietly destructive, so the on-disk width is frozen at 25 instead and
the overflow is APPENDED to UiSegMsg, after seq. sender sits between
thread and text, so widening it in place shifts text for every record
already on disk, and nothing would have caught that: the segment store
is what actually holds messages now, and uiSegOpenValidated checks only
magic/version/rec_size — it never consults k_ui_history_version or
k_ui_history_min_version, which guard the legacy files alone. An old
segment would have passed validation, prefix-read into the new offsets,
lost the first 8 characters of every message body and read seq as 0 (so
loadMsgsFromSegments renumbers the lot), and the next compaction would
have written that back permanently.
Appending at the tail is the evolution path UiSegHeader already
documents, and it is correct in both directions: an old 233-byte record
zero-fills the new field and yields its original short name, and older
firmware reading a new 240-byte record copies the first 233 bytes and
ignores the tail. That second half is why k_ui_seg_version STAYS 1 —
bumping it would make older firmware reject the file outright and
quarantine a history it could have read. So there is no migration, no
version bump, and no wiped chat history. static_asserts now pin the
offsets an already-written record depends on.
TOUCH_IGNORED_NAME_LEN has to move in lockstep (28 -> 32) or blocking
breaks: a slot narrower than a sender stores a cut name the full-width
check can never match, and the block sits in Settings doing nothing.
Its NVS blob has no header — the entry count is its length divided by
the slot width — so re-slotting in place would mangle every entry after
the first and the next write would make that stick. The key is renamed
ign_nm -> ign_nm2 and the old blob folded in once at first read, which
is unambiguous in a way that sniffing the blob length is not (28 and 32
share multiples at 224 and 448, both inside the 16-entry cap). The
invariant the header only stated in prose is a static_assert now.
Split/rejoin is a host-tested header next to ChannelSenderSplit, with
the field-width bounds spelled out: a corrupt record can leave the name
field unterminated, and the message body is what follows it on disk.
Signed-off-by: Ruben Laban <ruben@tun0.nl>
Vybo reports no change in beta_73. The runtime demotion I added is not firing on
his device, and I still do not have the one serial line that would say why. So
rather than guess a third time, give him a switch.
Settings > Keyboard > "Older keyboard protocol" skips raw detection entirely and
uses the protocol every T-Deck controller understands. The point is that it is
reachable BY TOUCH: when detection guesses wrong the keyboard types the wrong
letters, so any remedy that needs the keyboard is not a remedy. It applies
immediately, without a reboot, because the driver re-reads the flag every poll
and will drop out of raw mode on the spot.
The cost of turning it on is modifier latching, which a controller that needs
this switch cannot do anyway.
Schema goes to v53, appended at the tail with the existing static_assert
updated to point at the new last field.
This does not replace finding the real cause; it stops people being stuck while
I do. Automatic detection is unchanged and still the default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Options > Display previously only had "At a glance while locked"
(opt-in, off by default). Add a master "At a glance" switch above it
(on by default, matching the previously-unconditional behavior) that
gates the whole feature, and move the while-locked switch out of
Options > Lock screen to sit under it.
The while-locked switch's own saved preference is left alone when the
master is toggled off -- only its editability follows the master, so
re-enabling the master always restores its true prior state rather
than resetting it.
Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
Incoming messages now appear in the console as they arrive, for every known
channel and DM, without opening anything. On by default, persisted (v52
console_monitor), toggled with 'monitor on|off'.
The unread requirement drove the design. The previous version returned early
from newMsgImpl and printed, which skipped ALL the bookkeeping: no store, no
unread, no filters. Now console mode allocates the same chat store the UI does
(allocMessageStore(), extracted from begin() so both paths share it) and the
message takes the NORMAL path — block list, 1-character spam filter, sender
parsing, appendMessage with mark_unread = true. The console print happens AFTER
that append.
So watching a message scroll past is not reading it. It is stored, it is
counted, and it stays unread until the thread is actually opened:
unread what is waiting, per thread. Listing does not clear it.
read <name> clear one thread, deliberately.
That also means the monitor inherits the spam and block filtering for free, and
history survives a switch back to the graphical UI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Prefs v51 adds console_mode. It goes at the ACTUAL tail of the struct, after
lang_file. I first appended it after retry_echo, which is mid-struct and
exactly the v44 mistake the schema comments warn about; the existing
static_assert caught it at compile time. Added a second assert pinning
console_mode immediately after lang_file, so the next person to append is told
by the compiler rather than by a field of corrupted installs.
* The pref fails safe by construction: only the value 1 means console, so a
corrupt or unreadable byte boots the graphical UI. The <51 migration forces it
to 0 rather than inheriting whatever was in that position.
* UITask gains a headless personality rather than #if through main.cpp:
begin() decides once from the pref and returns before lv_init() and before the
draw buffer is allocated, loop() runs consoleLoop() instead of the graphical
path, and showAlert() becomes a console line so main.cpp's ~20 Wi-Fi and
Bluetooth reports keep working untouched.
* 'ui' or 'exit' in the console clears the pref and reboots. That is one of the
three ways out in CONSOLE_MODE.md; the boot-key escape is next.
Also Phase 1's on-screen key grid, since the V4 cannot use console mode without
one: three layers (lower/upper/symbols) rather than a shift key that changes
every glyph, so a key's label is always what it types. Compiled out where
CAP_KEYBOARD is set.
All 9 S3 envs, the Tanmatsu and the T-Display P4 build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
The Lua Store is now a segmented three-tab page. Apps keeps the catalog and
sideloaded scripts; Built-in holds the show/hide switches; Languages is new.
Opening is instant: the card re-listing that ran synchronously on open now
runs on the net worker (request/done flags like the catalog), and the app
cache pre-warms at boot. Card text no longer runs under the action button —
title and description both stop at the button column.
Languages as files: every translation column of the i18n table exports to a
.lang file (scripts/build/gen-lang-files.py -> apps/lang/<code>.lang on the
firmware host + langs.json catalog). The device downloads them from the
Languages tab into <data>/lang/, and at boot the active file loads into
PSRAM and overlays TR() lookups via binary search — the file wins, then the
built-in ui_lang column, then English, so a partial or hand-edited file
degrades gracefully. The file's '# base:' header names that fallback column.
Users can drop their own .lang files on the card (or edit a downloaded one)
for languages the firmware never shipped; switching reboots to apply, same
as the Settings picker (which now also clears the file overlay). Prefs v48
appends the active file code, trailing per the schema law.
This is stage one of un-baking translations from the image: the mechanism,
catalog and files ship now while the built-in table stays as the fallback;
the flash win lands when the table columns are dropped after field proof.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two reasons it still showed. The settings landing is built ONCE at boot, so
the build-time skip never re-ran after a toggle; each category card is now
remembered and shown/hidden live (flex closes the gap), applied from the
Store switch, the long-press hide and the build itself. And the 'hidden by
default' only fired for blobs older than v46 — anyone already on v46 (every
current tester) kept app_hide=0, so a v47 one-time flip sets the MQTT bit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Long-pressing a hideable built-in tile (Spectrum, Discover, VNC, Remote,
Web, Terminal, Files, Signal, Mentions) now offers Hide with the same
confirm sheet installed Lua apps use for Remove; hiding sets the same bit
the Lua Store's built-in switches toggle, so the two views agree. Core
tiles (Chats/Contacts/Map/Settings/...) stay unhideable.
Store cards: name and version share the title line and the description
gets its own full-width wrapped line — it was previously squeezed beside
the version badge into an unreadable sliver.
The MQTT bridge joins the hideable list (it is a Settings section, so the
bit gates that section) and ships hidden by default on new installs and on
the v46 migration: it is experimental and forwards message content to a
broker, so it should be opt-in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Store (new drawer tile) lists the VPS catalog (apps.json on
firmware.wadamesh.com, fetched on the net worker like the version check),
installs/updates/removes Lua apps (two files under <root>/apps/, .sav kept
across reinstalls), shows sideloaded apps, and hosts show/hide switches
for the built-in drawer tiles (app_hide bitmask, prefs v46 appended under
the schema's trailing assert). Installed apps appear as drawer tiles and
launch through the host. Catalog + snake 1.0 staged under out/firmware/apps
and live on the VPS (nginx /apps locations added).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PR inserted LANG_HU at slot 1 (shifting every stored language choice)
and dropped LANG_RO from the enum while the table still carries all 14
columns, so it did not compile as-is. Romanian is restored to the enum in
the table's column order, a one-time v45 remap keeps every existing user's
language selection, one row had its Hungarian cell pasted twice, and the
enum gained an append-only warning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per user feedback on beta_57: the retry default is the user's choice to
make. Applies to the v45 migration line, the fresh-install default, and
the pre-UI mesh state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Beta 57 inserted retry_echo into the middle of the packed TouchCfg blob, shifting the remote-mode and later fields during migration. Define a v45 layout with retry_echo appended at the true tail and recover ambiguous v44 suffix bytes to safe defaults while preserving the stable prefix. Add a host regression test for v43, broken v44, and v45 blobs.
Signed-off-by: Pixel Perfect <me@pixp.cc>
'Retry sends until heard' switch under Radio & Mesh gates mikecarper's retry
train (#207): OFF stops new retry trains (an in-flight one finishes its
schedule), applied live and persisted (cfg v44 retry_echo, default ON).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>