Before this commit, there was no way to set a different max hop count
for unscoped messages.
Now with this change, by defaul it tracks the flood.max setting, until
a user provides a flood.max.unscoped value, which tax precidence for
packets if ROUTE_TYPE_FLOOD is true.
The channel history is a single ring shared by all channels (was 32
entries), while unread counts live in a separate per-channel array.
Once 32 messages had accumulated across channels, the oldest entry was
evicted on each new message — but the matching _ch_unread[] counter was
left untouched. The Messages badge then claimed unread messages the ring
could no longer surface, so a channel showed "N unread" but opened empty.
(The companion app stores its own copy over the protocol, which is why
the phone could still read them.)
Two changes:
- addChannelMsg() now decrements _ch_unread[evicted.ch_idx] when an entry
is pushed out of the ring, keeping the badge consistent with what is
actually displayable.
- CH_HIST_MAX raised 32 → 96 (~14 KB RAM) so eviction is far less frequent
under normal multi-channel traffic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A very elongated trail (lat_span << lon_span or vice-versa) shares an
isotropic scale, so the visible window in the *short* direction can span
many grid_m. The previous code computed lat_n / lon_n once and bailed
with `return` if either exceeded 40 — leaving the user with no grid at
all on east-west or north-south runs.
Now: after picking the initial step via the MIN_GRID_PX and shorter/2
loops, also bump the step up further until both lat_n and lon_n fit
within MAX_GRID_LINES (40). Falls back to `return` only if even the
largest STEPS[] entry (100 km) can't produce a small-enough grid — which
shouldn't happen in practice on terrestrial trails.
Also fixed: comment claimed "~4 intervals across shorter dim" while the
code divides by 3.0f. Updated to "~3 intervals" to match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
M2: CMD_SET_DEFAULT_FLOOD_SCOPE used strlen() on the frame's 31-byte name
slot, which is not required to be NUL-terminated. Switched to strnlen()
so the search can't run past the field into the 16-byte key.
M4: NearbyScreen::renderDiscoverDetail computed strncpy(b64, ..., max_chars - 3)
where max_chars came from display width. On very narrow displays (width < ~28
at 6 px font) this became negative. Skip the pub-key line entirely when
max_chars < 4 so we don't risk a negative count and a bogus terminator.
L1: SNR was shown as truncated integer dB. Switched the detail view and
the 2-line discover cards to %.1f so they keep the 0.25 dB resolution
(consistent with the ping popup, which already used %.1f).
L4: Two fallback "?" sender placeholders used strncpy(buf, "?", sizeof(buf))
— functional but it memsets 21 unused bytes for a one-character string.
Replaced with strcpy.
M1 marked as not-a-bug after re-check: default_scope_name is char[31],
so the n < 31 guard correctly admits the max 30-char string + NUL.
FEATURES.md audit section updated with current status.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
C1/C2: onChannelMessageRecv and onChannelDataRecv both passed
findChannelIdx() through (uint8_t), so an unknown-secret packet
turned -1 into 255 and the message flowed into the offline queue,
UI history, notification and bot reply with a bogus channel index.
Drop such packets at the recv path; also harden addChannelMsg with
an MAX_GROUP_CHANNELS bounds check so a future caller can't poison
the channel-history ring buffer either.
H4: loadPrefsInt always reset trail_units_idx on sentinel mismatch,
even for jumps where the field was saved correctly (e.g. 0xC0DE0004
→ 0xC0DE0005). Scope the reset to sentinel == 0xC0DE0003, which is
the only case where the byte at that offset is actually the stale
sentinel low byte.
FEATURES.md: mark these three items as ✅ in the audit backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Ping popup mixed an action row ("Ping") with three read-only result
rows (RTT, SNR out, SNR back). UP/DOWN scrolled through all four, so the
highlight could land on a result row and ENTER did nothing useful.
- Swallow UP/DOWN while the ping popup is open; selection stays on the
action row so ENTER always triggers a new ping.
- Rename the action row "Ping" → "Send" and the RTT result prefix
"Ping: …" → "RTT: …" so the word "Ping" only appears once (in the
popup title), instead of three times.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
An all-zero secret in /channels2 means the entry is uninitialised or the
file format was corrupted by a previous firmware (e.g. different on-disk
layout from an older fork). Loading such a channel makes findChannelIdx()
match the wrong slot for incoming messages — they end up associated with
the bogus channel index and never display, even when the companion app
receives them fine over BLE.
Skip empty-secret entries during load and log how many were dropped.
Also defensively null-terminate the loaded name so callers can treat it
as a C string regardless of how the file was written.
Reported via user feedback: "only public visible, hashtag channels not;
public was empty; phone could read messages; works after wipe".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
README:
- Fix typo "offical" → "official"
- File names in Firmware Variants table now match workflow output
(solo-<version>-oled.uf2 / solo-<version>-eink.uf2)
- Un-comment the E-ink Display section (now fully supported)
- Clock Screen: "sensor values" → "data fields" (covers Batt%, Nodes, Msgs)
- Tools Screen: mention nearby nodes and auto-advert in the highlight
- Promote Screenshot Tool to its own top-level section (was buried under
Contributing); fix `uv run tools/screenshot.py` invocation path
Docs:
- favourites_dial: document the 3rd pin-picker tier (all chat contacts
fallback) added in 0e0e5b93
- clock_screen: Altitude source clarified — onboard sensor (GPS or
barometric), not always GPS
- message_screen: blank lines around image tables that were inlined
Code:
- UITask.cpp: rename leftover plus_y/plus_label locals to solo_y/solo_label
in the splash-screen Solo banner
Workflow:
- workflow_dispatch builds previously labelled firmware with the branch
name (GITHUB_REF_NAME). Resolve BUILD_VERSION to the tag when run on a
tag push, else dev-<short-sha> so manual runs produce
solo-dev-abc1234-oled.uf2 etc.
Cleanup:
- remove stray docs/solo_features/settings_screen/set_scr_1_eink copy.png
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AUTO_OFF_MILLIS is a power-save feature aimed at battery use. When the
board reports isExternalPowered() == true (USB or other DC source),
blanking the screen serves no purpose — there's nothing to conserve.
But OLEDs are vulnerable to burn-in with static content, so this
behaviour is gated behind a new build flag KEEP_DISPLAY_ON_USB. Default
is unchanged from upstream — the display blanks after AUTO_OFF_MILLIS
on USB or battery. Variants that ship with an LCD instead of an OLED
(e.g. heltec_t096) can opt in by adding -D KEEP_DISPLAY_ON_USB to
their env, gaining always-on-while-powered without exposing OLED users
to burn-in risk.
When the flag is enabled, the implementation refreshes _auto_off every
loop iteration while externally powered, so the timer naturally counts
a fresh AUTO_OFF_MILLIS window from the moment power is removed —
no instantaneous-blank-on-unplug.
Applied to all three companion_radio UI flavours (ui-new, ui-tiny,
ui-orig). Boards without an isExternalPowered() override use the
base-class default in MeshCore.h (returns false), so battery-powered
behaviour is unchanged everywhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
msgRead(0) was called when the companion app read the last message from
the offline queue, and it erased the entire _dm_unread_table. This wiped
Favourites Dial badges the moment the app synced — leaving a 3-second
window (the alert duration) where the badge was faintly visible behind
the incoming-message popup, then gone.
_dm_unread_table tracks per-contact UI state (Favourites Dial badges,
contact list counters). It should only be cleared by user action:
clearDMUnread (opens a DM) or clearAllDMUnread (mark-all-read). It has
no relationship to the offline queue drain state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the user presses + on an empty Favourites Dial tile, the picker
previously showed 'No fav contacts' if there were no starred contacts
(flags & 0x01) AND no recent DMs in _dm_hist (empty after reboot).
Add a third fallback tier: when the first two tiers yield nothing, list
all ADV_TYPE_CHAT contacts so the user can always pin anyone. Alert
text updated to 'No contacts' for the remaining edge case (no contacts
at all in the mesh).
Known limitation: per-contact unread badges on the dial reset on reboot
(in-RAM only). Persisted unread is a separate future improvement.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PopupMenu:
- _cap now uses max_by_height as a hard ceiling (never draw outside screen).
Previously max(_visible, max_by_height) let callers exceed the screen on
OLED 64px. Now: OLED→4 items, landscape e-ink→10, portrait e-ink→22.
QuickMsgScreen:
- Contact context menu: Notif and Melody cycle with LEFT/RIGHT in-place;
ENTER closes without re-cycling.
- Channel context menu: Notif, Melody and Fav same pattern.
RingtoneEditorScreen:
- Migrated from bespoke menu to PopupMenu (same pattern as everywhere else).
- Duration (1/4…1/32) and BPM (60…180) are now single rows that cycle with
LEFT/RIGHT; separate BPM+/BPM- rows removed.
- Fixed _menu_dur_label buffer too small for "Duration: 1/16" (14→16 bytes).
NearbyScreen:
- Removed redundant "Back" item from context menu (Cancel key navigates back).
TrailScreen:
- Grid toggle responds to LEFT/RIGHT in addition to ENTER.
- Export labels: "Export GPX (live/saved)" → "Export (live/saved)" to fit PM_BW.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
'Export GPX' and 'Export saved' were ambiguous. Renamed to
'Export GPX (live)' (RAM ring) and 'Export GPX (saved)' (flash file)
so the source of each export is immediately clear.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On a 250px-tall portrait e-ink panel the menu previously showed only 3-4
items and required scrolling. render() now computes the maximum items that
fit from display.height() and uses that instead of the hardcoded caller
hint whenever the screen is taller. handleInput() uses the same _cap value
so scroll tracking stays in sync.
OLED (64px): fits 4 items — unchanged behaviour.
Portrait e-ink (250px): fits up to 22 items (capped at PM_MAX_ITEMS=16),
no scroll needed for typical context menus (4-6 items).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds GxEPD2 framebuffer capture to the screenshot tool, extending the
existing OLED support to the Wio Tracker L1 e-ink variant.
Key changes:
- GxEPD2-patch: patched copy of GxEPD2_BW.h exposing getBuffer/getBufferSize
- GxEPDDisplay: override getBuffer/getBufferSize/getDisplayType/
screenshotWidth/screenshotHeight/screenshotRotation using GxEPD2's
own reported visible dimensions (WIDTH_VISIBLE, not physical WIDTH)
and live rotation value
- DisplayDriver: virtual screenshot interface so no C-style casts needed
- Protocol: 11-byte header with display_type, rotation, uint16 LE
width/height/chunk_idx/total_chunks (eliminates truncation for panels >255px)
- screenshot.py: full rot 0-3 decoder for GxEPD2 buffer layout; ERR frame
checked before length so device errors surface correctly; buffer-size
sanity check using panel-aware expected size
Conflict resolution: kept feat/eink-screenshot protocol and decoder
(11-byte header, e-ink decode) over the OLED-only 5-byte version on
wio-unified; battery and joystick fixes from wio-unified kept intact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
handleScreenshotRequest() reinterpret-cast every DisplayDriver* first to
SH1106Display* then to SSD1306Display*; both casts always produce a
non-null pointer, so the SSD1306 fallback was unreachable and on any
SSD1306 build we called SH1106::getBuffer() on a SSD1306 object — UB
that only happened to work because both backing classes share an
Adafruit_GFX layout at offset zero.
Replace the cast hack with virtual getBuffer()/getBufferSize() on
DisplayDriver, overridden in SH1106Display and SSD1306Display. MyMesh
no longer needs to know about either concrete type. const-correct the
buffer pointer and widen bufferSize to uint16_t while passing through.
Also fix the matching Python decoder: it sanity-checked length before
dispatching on resp_code, so a 2-byte RESP_CODE_ERR frame was reported
as "Frame too short" instead of the actual device error. Move the
length check after the resp_code dispatch and log the error code byte.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Clock-page Batt% field used a separate hardcoded linear mapping
(3000-4200 mV → 0-100%) that disagreed with the top-bar battery indicator,
which uses a piecewise LiPo discharge curve and the user-configurable
low_batt_mv cutoff (Settings → Low battery threshold) as the 0% anchor.
The two readings could differ by 25+ percentage points for the same
voltage, and the dashboard ignored the cutoff setting entirely.
Extract battMvToPercent(mv, low_mv) as a file-static helper so both the
top-bar indicator and the dashboard field share the same curve and the
same cutoff source. Drop the stray BATT_MIN/MAX_MILLIVOLTS #defines
that leaked from inside the dashboard code paths into the rest of the
TU.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Firmware (7-byte header):
Byte 7 = display->screenshotRotation() — GxEPD2/GFX rotation value (0-3).
DisplayDriver::screenshotRotation() defaults to 0 (OLED).
GxEPDDisplay::screenshotRotation() returns display.getRotation(), the
live GxEPD2 value (reflects runtime setDisplayRotation() calls, not just
the compile-time DISPLAY_ROTATION macro).
Python decoder:
- Parse 7-byte header; pass rotation to eink_buffer_to_image().
- Implement all four GxEPD2 drawPixel coordinate transforms:
rot 0: phys_x=lx, phys_y=ly
rot 1: phys_x=vis_w-1-ly, phys_y=lx
rot 2: phys_x=vis_w-1-lx, phys_y=vis_h-1-ly (180° flip)
rot 3: phys_x=ly, phys_y=vis_h-1-lx
- vis_w/vis_h derived from log dimensions + rotation parity (no constants).
- Print rot= in the status line so the user sees which rotation was used.
This fixes the 180° rotated image seen with rotation=2 (portrait inverted)
without hardcoding a flip — correct for any rotation value.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Firmware: add screenshotWidth()/screenshotHeight() virtual pair to
DisplayDriver (default: width()/height()). GxEPDDisplay overrides to
return display.width()/display.height() — the GxEPD2-reported values
that use WIDTH_VISIBLE (e.g. 122 for GxEPD2_213_B74), not the full
physical WIDTH stored in DisplayDriver (128). MyMesh uses these
instead of display->width()/height() for the screenshot header bytes.
Result for GxEPD2_213_B74 + DISPLAY_ROTATION=1:
header was 250×128 → now 250×122 (correct visible canvas)
Python decoder (tools/screenshot.py):
- Remove hardcoded EINK_PHYS_WIDTH=128 / EINK_VISIBLE_W=122 constants.
- Derive phys_stride from buffer_size / log_width (works for any panel).
- Fix phys_x formula: was (EINK_PHYS_WIDTH-1-ly = 127-ly),
now (vis_w-1-ly = log_height-1-ly = 121-ly for 213_B74).
Old formula addressed invisible columns 122-127; new formula correctly
maps logical rows 0..121 to visible physical columns 121..0.
- vis_w = log_height (no hardcoded trim; header now carries correct value).
This also works for square panels (e.g. 128×128 where WIDTH=WIDTH_VISIBLE):
header will carry 128×128, decoder produces a correct 128×128 image.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add lib/GxEPD2-patch/src/GxEPD2_BW.h: local patched copy of GxEPD2_BW.h
that exposes getBuffer()/getBufferSize() under ENABLE_SCREENSHOT guard.
Include guard (_GxEPD2_BW_H_) prevents double-inclusion of the installed
library version.
- GxEPDDisplay.h: use patched header via relative include when
ENABLE_SCREENSHOT so the patch takes precedence over the installed lib
(PlatformIO adds library paths before -I build_flags).
- DisplayDriver.h: add virtual getBuffer()/getBufferSize()/getDisplayType()
defaults (nullptr/0/0) under ENABLE_SCREENSHOT.
- SH1106Display.h / SSD1306Display.h / GxEPDDisplay.h: add concrete overrides;
getDisplayType() returns 0 (OLED) or 1 (e-ink).
- MyMesh.cpp/h: replace fragile C-cast with virtual dispatch in
handleScreenshotRequest(); extend 5-byte header to 6 bytes by appending
display_type so the host tool can decode the correct pixel layout.
- tools/screenshot.py: parse 6-byte header; add eink_buffer_to_image()
that decodes the row-major MSB-first GxEPD2 buffer with DISPLAY_ROTATION=1
(phys_x = 127-ly, phys_y = lx); dispatch on display_type.
- variants/wio-tracker-l1-eink/platformio.ini: add
[env:WioTrackerL1Eink_companion_dual_dev] with ENABLE_SCREENSHOT.
- variants/wio-tracker-l1/platformio.ini: unchanged (OLED env already exists).
Builds verified: WioTrackerL1Eink_companion_dual_dev SUCCESS,
WioTrackerL1Eink_companion_radio_ble SUCCESS (unaffected).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous fix set joystick_rotation=0 before reading from file, but
the subsequent rd() call overwrote it with whatever was stored (e.g. 2
from a prior e-ink firmware). Move the hardware-enforce block to after
rd() so stale values in flash are always corrected on OLED builds
(FEAT_JOYSTICK_ROTATION_SETTING=0). Remove the now-redundant pre-read
block.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Adds the possibility to capture the device screen and save it as a PNG
image
- Wrap the code behind ENABLE_SCREENSHOT build flag, as per instructions
in README
Add per-channel favourite marking and a Settings filter (all/fav) for
the channel list, symmetric with the existing DM and Room filters.
NodePrefs:
- ch_fav_bitmask (uint64_t): bit i = channel i is marked favourite
- ch_fav_only (uint8_t): 0=show all channels, 1=favourites only
- SCHEMA_SENTINEL bumped to 0xC0DE0005
DataStore: save/load both new fields before the sentinel.
SettingsScreen: new CH_FILTER item ('Channels: all/fav') in the
Contacts section, positioned between DM_FILTER and ROOM_FILTER.
QuickMsgScreen:
- buildChannelList() skips non-favourite channels when ch_fav_only=1
- Channel context menu gains a 4th option 'Fav'/'Unfav' that toggles
ch_fav_bitmask for the selected channel and rebuilds the list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UITask: when a key is pressed while the buzzer is playing, defer the
next screen refresh by 300 ms instead of triggering it immediately
(via _next_refresh = 100). The blocking e-ink endFrame() was extending
the first note by the full refresh duration (~150-200 ms). Subsequent
notes were unaffected because _next_refresh was already future-dated
after the first render.
DataStore: for builds where JOYSTICK_ROTATION is not defined and
FEAT_JOYSTICK_ROTATION_SETTING is 0 (OLED), always reset
joystick_rotation to 0 on prefs load. Without this, stale e-ink prefs
(which allow changing joystick rotation in Settings) could silently
leave a non-zero value that reversed all joystick directions on OLED.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add an optional dot/intersection scale grid to the trail map view:
- renderGrid() draws labelled grid lines anchored to display edges
- Grid step auto-selected for ~3 intervals in the shorter dimension
- MIN_GRID_PX=22 prevents density on small/OLED screens; clamp ensures
minimum 2 intervals on large trails
- Intersections inside the label bbox and north-arrow bbox are suppressed
- Toggle via Hold-Enter action menu (Grid: ON/OFF item in ActionId enum)
- Map title shows 'TRAIL MAP+' when grid is active
- README updated to mention grid toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous fix only evicted SHUTDOWN when cur_len == PAGE_ORDER_LEN (full).
If the array was not yet full but had SHUTDOWN + 2 missing pages (TOOLS and
QUICK_MSG), TOOLS would fill the last slot but QUICK_MSG would be left out.
Remove the cur_len guard: evict SHUTDOWN whenever any required page is
missing, regardless of array fullness. This frees one slot before the
append loop so all missing required pages can be added in a single pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous fix appended missing pages only when there was room (cur_len <
PAGE_ORDER_LEN). On e-ink builds with GPS+SENSORS the default order fills
all 11 slots; old saves that included SHUTDOWN instead of TOOLS/QUICK_MSG
had no room to append the missing entries.
Now, if the array is full and a required page is absent, SHUTDOWN is evicted
first (it is appended at runtime by buildVisibleOrder's fallback and does not
need to be in the explicit list). This frees a slot for the missing page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ensurePageOrderInit returned early after inserting FAVOURITES without
checking whether TOOLS/QUICK_MSG were present. Old page_order saves that
predated those pages had CLOCK+FAVOURITES but no Messages entry, so
movePageInOrder always found cur==-1 and silently did nothing.
Now recount valid entries after any insertion, then walk the full list of
required page bits and append any that are absent (if room exists).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four findings from a code review pass on the Trail feature:
- showAlert overlay was gated to `curr == home`, so feedback like
"Tracking started", "Trail saved", "GPX N B (USB)" never rendered on
the Trail screen (or QuickMsg / Settings / Auto-Advert, for that
matter). Drop the gate — the overlay shows on whichever screen the
caller invoked from.
- TrailStore::writeTo only verified the first write; subsequent point
writes could partially fail (full filesystem, mid-write power loss)
and the method would still return true. Check every write return
value so handleSave's "Trail saved" only fires on actual success.
- gpxPoint: clamp snprintf's intended-length return against the buffer
size and skip the point if gmtime returns null. Avoids any chance of
out.write reading past buf[120].
- TrailScreen::_act_map sized exactly to today's 8 actions — bump to 12
so adding another popup item later doesn't cause an OOB write into
the surrounding members.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>