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>
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>
This is a consolidation of my changes for BME680 on RAK4631 nodes.
I will close my other PRs related to this and link back to this one.
*Background on change:*
This change replaces the Adafruit BME680 driver on RAK4631 with the
Bosch BSEC library. Other boards continue to use the existing
Adafruit path via ENV_INCLUDE_BME680.
This makes the IAQ portion of the sensor functional, and more accurate.
It also contains the math and/or CayenneLPP fixes from my other PRs.
The Bosch code also appears to handle calibrating sensor aging as well,
whereas the Adafruit code is just looking at blind values that can drift
with time. Pretty cool to see this shooting out useful data!
RAK4631 platform.io is set to override to ENV_INCLUDE_BME680_BSEC while
leaving the Adafruit code for other node types. (If this becomes applicable
for other node types in future, awesome! I just don't have hardware
to test against.)
Using the BSEC library introduces IAQ sensor calibration, and saves
the calibration state periodically so it does not have to calibrate
again later.
At startup the IAQ sensor takes 30 minutes to heat and to hit a baseline,
then starts calibrating. Once calibrated, it will save those settings
and will only write settings again if calibration falls back and restores
back to state 3.
This fix also has the gas resistance math fix that was in
[pull 2146](https://github.com/meshcore-dev/MeshCore/pull/2146) so
the adafruit path also can at least show accurate values instead
of looping negative.
Also includes the fix from [pull 2149](https://github.com/meshcore-dev/MeshCore/pull/2149) so the pressure output isn't truncated to 1hPa steps.
*Fixes/Changes:*
- Add bsec_config_iaq[] with the 3.3V/3s-LP/28d calibration profile
- BSEC init applies setConfig() for voltage-correct heater targeting
- IAQ, heat-compensated temperature/humidity, pressure, and altitude
reported over CayenneLPP
- IAQ accuracy reported as analog input over CayenneLPP (0,1,2,3)
- Calibration state persisted to /bsec_state.bin on nRF52 internal
flash; written only when iaqAccuracy improves to >= 2, should
keep write frequency well within flash endurance over device lifetime
- Fix non-BSEC query_bme680: float pressure division, addGenericSensor
for gas resistance (was addAnalogInput, overflows at > 327 Ohm)
- loop() correctly gated for both GPS and BSEC-only builds
- Add fix_bsec_lib.py extra_script to resolve nRF52840 hard-float ABI
mismatch in Bosch's PlatformIO packaging, silly Bosch
One general note outside of this code change: I noticed while BME680
_functions_ in companion nodes, since companion nodes run Bluetooth,
BLE preempts the CPU, and can do so mid-I2C-transaction.
This can cause the BME680 to see an anomaly and drop calibration and
start a recalibrate. This is behavior that will exist (and has existed)
regardless of using the Adafruit or Bosch paths.
This particular companion behavior does not seem to occur in sensor
or repeater nodes since their BLE is off. Probably affects other
I2C devices as well.
*Tests:*
- RAK19003
- RAK19007
- RAK19001
- repeater, sensor, companion
- 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>
In GxEPDDisplay::print(), the 0xDB fallback block was drawn at
cy - 8*scale, which assumed the Lemon baseline convention
(cy = original_y + 8*scale). For the default GFX font fontAscender
returns 0, so cy = original_y (top of cell) — the block landed
16 px above the character on e-ink (scale=2).
Fix: draw at cy (top of cell) for the non-Lemon path.
Also remove the unused forceNextRefreshFull() virtual from
DisplayDriver — it was added based on a wrong hypothesis (partial
refresh ghosting) and was never wired into endFrame().
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>
Adding the public DataStore::openWrite(const char*) member shadowed
the file-static openWrite(FILESYSTEM*, const char*) helper inside
DataStore.cpp's method bodies, so every internal call broke ("no
matching function" with two args). Qualify each internal call with
::openWrite so name lookup picks the static helper. The new public
member's own implementation already does that.
Trail.h's gpxPoint template called gmtime unqualified; ADL couldn't
find it from the template definition because no argument was
template-dependent. Include <time.h> and call ::gmtime — both passes
the dependency rule and avoids the -fpermissive-only acceptance.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an "Export saved" item in the action popup (shown when /trail
exists). Streams GPX directly from the saved file to USB Serial,
skipping the RAM ring entirely — useful for dumping an old recording
after reboot when the live ring is empty (or busy with another
recording you don't want to overwrite via Load).
Refactors exportGpx into three helpers (gpxHeader, gpxPoint, gpxFooter)
shared by the RAM and file paths so the markup stays consistent. The
file path validates the same TRAL magic + version it accepts on Load.
handleExport and the new handleExportSaved both route through
showExportAlert so the "GPX N B (USB)" / "GPX N B - disc. app" hint
keeps reflecting the BLE-app-collision state described last time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Blocking the export when BLE isn't connected also blocked the no-app
case (user attached only a terminal). Dump unconditionally and let the
confirm alert tell the user what just happened:
- BLE connected → "GPX N B (USB)" — USB receive is parked, no collision
- BLE absent → "GPX N B - disc. app" — reminder that the same USB
pipe the app would use just carried raw XML, so
reconnect/restart the app if you had one running
User can also reach the "no app, terminal-only" workflow naturally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Wio L1 DualSerialInterface multiplexes BLE + USB on the same frame
protocol: when BLE is connected, the USB receive path is ignored, so we
can safely push raw GPX bytes to USB without confusing the companion
app. When BLE is *not* connected the app is talking over USB itself —
the dump would land mid-frame and the app would parse garbage.
Block the export when hasConnection() (BLE link to the app) is false
and show "Connect BLE or disconnect app". When it's true the dump runs
and the confirm alert reads "GPX N B over USB" so the user knows which
pipe to read from.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single-slot persistence as agreed. Action popup grows three entries
(conditional on what makes sense for the current state):
- "Save trail" — writes the current RAM ring to /trail in LittleFS.
- "Load trail" — only shown when /trail exists; replaces the live ring
with the snapshot (any active session ends first).
- "Export GPX" — dumps the trail as a minimal GPX 1.1 document over
Serial so the user can capture it from a USB host.
Plumbing:
- TrailStore gains writeTo / readFrom (template on the file handle, so
the platform-specific File flavour stays out of Trail.h) plus
exportGpx(Stream&) for the GPX serializer. Header carries magic
"TRAL", a version byte, point count, and the accumulated active time
so elapsed survives the round trip.
- DataStore::openWrite is exposed publicly so callers outside DataStore
don't have to duplicate the platform open-mode dance.
- MyMesh gains a public getDataStore() accessor.
After Load, _pending_seg_break is set so any subsequent Start opens a
new segment instead of joining the loaded last point.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>