1290 Commits

Author SHA1 Message Date
taco
e449af1723 bypass auto-shutdown delay for e-ink devices 2026-06-02 18:37:18 +10:00
Scott Powell
83ad3a6dc0 * direct/zero hop by default 2026-06-02 15:32:28 +10:00
Scott Powell
f66c1d0258 * simplified the new flood.max.unscoped pref 2026-06-02 14:15:33 +10:00
ripplebiz
09349c5b28 Merge pull request #2661 from mrzarquon/mrz/flood_max_unscoped
Implement flood.max.unscoped to allow selective repeating of unscoped messages
2026-06-02 14:00:33 +10:00
Liam Cottle
94063f6833 Merge pull request #2636 from sefinek/fix/cmd-device-query-typo
fix: rename CMD_DEVICE_QEURY to CMD_DEVICE_QUERY
2026-06-02 12:41:57 +12:00
Chris Barker
a33f3011a5 Feat: Adds flood.max.unscoped setting
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.
2026-06-01 21:30:17 +01:00
taco
3ce1cf404e NRF52: disable autoshutdown if powered, add auto-shutdown warning for oled 2026-06-02 00:55:25 +10:00
Scott Powell
760bb5951d * Bug fix: adding neighbor when path_mode != 0 2026-06-01 23:08:39 +10:00
Scott Powell
bc5d648204 * contacts sync fix
* fix for reusing transient/anon contacts only
2026-06-01 19:04:42 +10:00
Scott Powell
d9df8307ca * first draft 2026-06-01 16:49:31 +10:00
ripplebiz
d8686b224d Merge pull request #2643 from disq/companion-display-keep-on-when-powered
companion_radio: Add optional `KEEP_DISPLAY_ON_USB` flag to keep display on while powered 🤖🤖
2026-06-01 13:18:34 +10:00
Jakub
43d989821a fix(ui/channels): unread badge no longer outlives evicted history
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>
2026-05-31 16:23:44 +02:00
Scott Powell
b259fbd01b * power saving: hasPendingWork() now also checks if there's a pending contacts write 2026-05-31 18:52:42 +10:00
ripplebiz
54c111609a Merge pull request #2286 from IoTThinks/MCdev-PowerSaving-for-nrf52-companion-202604
Added Power Saving for NRF52 companions to have extra 30% battery life
2026-05-31 18:49:46 +10:00
Jakub
5ab807a02a fix(trail): map grid no longer silently disappears on elongated trails
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>
2026-05-31 00:19:40 +02:00
Jakub
e578300eff fix: smaller audit items — strnlen, narrow-display guard, SNR precision
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>
2026-05-31 00:11:43 +02:00
Jakub
9113b7f12e fix: guard findChannelIdx == -1 + scope trail_units reset
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>
2026-05-30 23:57:12 +02:00
Jakub
684e2b6439 fix(ui/nearby): lock ping popup highlight and dedupe Ping labels
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>
2026-05-30 23:35:07 +02:00
vanous
8c3622b879 Nearby Nodes: Add Ping 2026-05-30 16:32:46 +02:00
Jakub
cb37d23eb0 fix(prefs): skip channels with empty secret in loadChannels
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>
2026-05-29 19:56:25 +02:00
Jakub
fb700bbaad docs+ci: pre-merge polish — README, doc accuracy, workflow
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>
2026-05-29 12:46:29 +02:00
Kemal Hadimli
bb781ff10a companion_radio: opt-in KEEP_DISPLAY_ON_USB to keep OLED on while powered
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>
2026-05-29 11:25:48 +01:00
Jakub
c7009140ce refactor: rename firmware from Plus to Solo
- FIRMWARE_VERSION: v1.15-plus.1 → v1.15-solo.1
- FIRMWARE_PLUS_BUILD → FIRMWARE_SOLO_BUILD in all platformio.ini and UITask.cpp
- docs/plus_features/ → docs/solo_features/ (all sub-docs moved)
- README: update all doc links plus new Documentation section
- GitHub Actions: extract GIT_TAG_VERSION in each build job (was missing,
  causing empty FIRMWARE_VERSION in builds); rename output files to
  solo-VERSION-oled.uf2 / solo-VERSION-eink.uf2; release title → "Solo VERSION"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:25:34 +02:00
Jakub
ce4d5ea1da Revert "fix(ui/favourites): DM unread badge cleared by companion app sync"
This reverts commit f6d10bb8b2.
2026-05-29 11:12:30 +02:00
Jakub
f6d10bb8b2 fix(ui/favourites): DM unread badge cleared by companion app sync
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>
2026-05-29 11:11:57 +02:00
Jakub
0e0e5b938a fix(ui/favourites): pin picker fallback to all contacts
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>
2026-05-29 10:59:34 +02:00
Jakub
db8c52ea9a fix(ui): standardize context menu cycling + PopupMenu height clamping
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>
2026-05-29 00:25:02 +02:00
Jakub
5caac0b693 fix(ui/trail): clarify export menu labels — live vs saved GPX
'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>
2026-05-28 23:43:45 +02:00
Jakub
95795a663c feat(ui): popup menu auto-expands on tall displays (portrait e-ink)
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>
2026-05-28 23:20:15 +02:00
Jakub
54367327f7 Merge feat/eink-screenshot — e-ink screenshot support
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>
2026-05-28 23:07:09 +02:00
Jakub
5dd035d309 fix(screenshot): drop C-style cast UB, fix ERR check order
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>
2026-05-28 22:58:26 +02:00
Jakub
6687d2b0e6 fix(ui/dashboard): Batt% uses same LiPo curve + low_batt_mv cutoff as top bar
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>
2026-05-28 22:57:50 +02:00
Jakub
a84cee0f57 fix(screenshot): widen header to uint16, sanity-check buffer, fix ERR path
Protocol header grows from 7 to 11 bytes: display_type, rotation, then
uint16 LE width / height / chunk_idx / total_chunks. Removes silent
truncation on panels >255 px (e.g. future 4.2" 400x300 e-ink).

Decoder now:
- checks RESP_CODE_ERR before length so error frames don't surface as
  "Frame too short"; logs the error code byte
- validates received buffer size against expected (OLED: w*h/8; e-ink:
  ceil(min(w,h)/8) * max(w,h)) so truncated transfers fail loudly
- initializes disp_rotation in outer scope and detects missed chunk 0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:03:05 +02:00
Sefinek
bbe41eb375 fix: rename CMD_DEVICE_QEURY to CMD_DEVICE_QUERY 2026-05-28 14:39:12 +02:00
Kevin Le
36a44de119 Reduced time drift from 60s/day to 7s/day for ESP32-based repeaters with power saving. 2026-05-28 15:13:14 +07:00
Kevin Le
a3d20d6209 Let ESP32-based repeaters to sleep immediately receiving and process a LoRa packet 2026-05-28 15:10:36 +07:00
Jakub
d1a88c8277 fix(screenshot): send GxEPD2 rotation in header; full rot 0-3 decode in Python
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>
2026-05-28 08:10:16 +02:00
Jakub
1332358ee7 fix(screenshot): use GxEPD2 visible dimensions; fix phys_x formula
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>
2026-05-28 01:19:22 +02:00
Jakub
7d9d5f847d feat(screenshot): extend e-ink screenshot support via GxEPD2 patch
- 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>
2026-05-28 01:03:47 +02:00
Jakub
c92264c21e fix(ui): OLED joystick rotation — enforce reset after prefs rd()
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>
2026-05-27 23:49:19 +02:00
vanous
3f98060c82 Dashboard Clock field: Add Battery percentage 2026-05-27 22:57:17 +02:00
vanous
7fc8b4b5a5 Screenshot tool and a build flag for conditional screenshot functionality:
- 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
2026-05-27 22:08:28 +02:00
Jakub
5b40e91df3 feat(contacts): channel favourites — filter in Settings + context menu
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>
2026-05-27 17:33:52 +02:00
Jakub
dd9c75d4f4 fix(ui/input): first melody note duration; OLED joystick reset
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>
2026-05-27 17:33:41 +02:00
Jakub
64b924a9c7 feat(ui): GPS trail — scale grid on map view
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>
2026-05-27 17:33:20 +02:00
Jakub
4c2da2f364 fix(ui): home pages — unconditional SHUTDOWN eviction when required page absent
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>
2026-05-26 16:18:17 +02:00
Jakub
7c8f5895cd fix(ui): home pages — evict SHUTDOWN from full page_order to fit missing pages
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>
2026-05-26 16:13:07 +02:00
Jakub
2e0263bdf9 fix(ui): home pages — append missing pages to stale page_order on load
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>
2026-05-26 16:08:01 +02:00
Jakub
439c726f1e feat(ui): v1.13 — collapsible Settings, Favourites Dial, GPX trail, schema fix
- SettingsScreen: collapsible sections (Enter toggles); all collapsed by default;
  vis[] filtered list drives render and UP/DOWN/Enter navigation
- NodePrefs: bump SCHEMA_SENTINEL 0xC0DE0003 → 0xC0DE0004; reset trail_units_idx
  in mismatch handler (was corrupted on upgrade from 0003 saves)
- UITask: shorten "No contacts available" → "No fav contacts" (OLED alert overflow)
- README: document Favourites Dial, GPS Trail + GPX download instructions,
  Mark-all-read, collapsible Settings; update USB/BLE export note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:49:50 +02:00
Jakub
755525761e fix(ui): post-Trail review — alert visible everywhere + writeTo error checks
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>
2026-05-26 08:15:53 +02:00