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>
Tables for all seven sections (Display, Sound, Home Pages, Radio, System,
Contacts, Messages) with values, LEFT/RIGHT vs Enter interaction, and
e-ink-only flags. Notes: buzzer Auto mode, battery % anchor from low_batt,
Melody 1/2 link to Ringtone Editor, placeholder support in quick replies.
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>
- Describe 2×3 portrait vs 3×2 landscape grid layout
- Navigation: edge keys fall through to page nav
- Unread badge detail (ellipsis + badge coexistence)
- (gone) label for removed contacts
- Two pinning paths: empty tile picker and DM context menu
- Pin picker order: favourited contacts first, then recent DMs
- Auto-move when contact already pinned elsewhere
- Unpin via DM context menu
- Reordering via Settings › Home Pages
- Describe DM/Channels/Rooms mode-select
- Describe MSG_PICK flow (keyboard vs quick replies Q1-Q10)
- Complete placeholder table — remove {batt} (shows in picker only
when a voltage sensor is connected, not from board battery)
- Separate contact and channel context menus with full item tables
- Document LEFT/RIGHT cycling for Notif/Melody/Fav
- Describe Pin to dial with slot picker detail
- Clarify short Enter (fullscreen) vs Hold Enter (context menu)
- Add fullscreen navigation and scroll description
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>
The previous decoder always applied the rotation=1 (landscape) transform,
which caused three visible artefacts on a portrait (rotation=0) device:
- Content rotated 90° — wrong phys_x/phys_y mapping
- Content doubled/skewed — stride = buffer/log_width = 4000/122 = 32
instead of buffer/HEIGHT = 4000/250 = 16
Fix: infer rotation from aspect ratio (portrait: log_height > log_width).
portrait → direct mapping: phys_x=lx, phys_y=ly
landscape → rotation-1 map: phys_x=log_height-1-ly, phys_y=lx
Derive phys_stride from buffer_size // max(log_w, log_h) — the physical
HEIGHT is always the longer axis regardless of rotation, giving 16 bytes/row
for GxEPD2_213_B74 in both orientations.
No panel-specific constants remain; works for any GxEPD2 panel/rotation.
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>