Pull repeated UI idioms into DisplayDriver and remove duplicated logic:
- DisplayDriver: add drawScrollArrows() and drawInvertedHeader(); replaces 11
copy-pasted scroll-arrow blocks across 5 screens and 3 inverted title-bar
blocks (NearbyScreen x2, NavView). Standardises the detail-view separator.
- useImperial(): single source on UITask; NearbyScreen/TrailScreen delegate
instead of each re-reading NodePrefs.units_imperial.
- NearbyScreen: extract saveSelectedWaypoint() (two identical blocks -> one);
drop the redundant local label buffer (WaypointStore truncates).
- UITask::shutdown(): power GPS off through LocationProvider::stop() instead of
poking PIN_GPS_EN directly — centralises enable/reset-pin + active-level logic.
Net -58 lines. Verified: GAT562 (SSD1306) and WioTrackerL1Eink (GxEPD) solo
builds compile clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- UITask::shutdown(): power GPS off using !PIN_GPS_EN_ACTIVE instead of hardcoded
LOW, matching MicroNMEALocationProvider::stop() (still LOW on current active-high
devices, but correct for a future active-low GPS).
- buzzer _noteFreq(): clamp octave to 8 instead of 7 so the parser's accepted range
(4-8) is fully consumed; previously an octave-8 digit was clamped away and could
leak into the stream as the next note's duration.
- Waypoint::readFrom(): check read() return for version/reserved/count header bytes
so a truncated file is rejected instead of using garbage count.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two independent, default-off toggles under Settings › Radio.
Pwr save: hardware RX duty-cycle (SX126x SetRxDutyCycle via
startReceiveDutyCycleAuto). The chip cycles RX↔sleep and wakes on a preamble —
no MCU state machine; recvRaw reads the packet exactly as in continuous RX.
Falls back to continuous RX on non-SX126x. (Replaces an earlier software-CAD
state machine that fought the hardware: polling a warm-sleeping chip gave a
phantom-busy channel that stalled TX ~4 s and dropped ACKs in the scan gaps.)
Auto pwr: Adaptive Power Control. tx_power_dbm becomes a ceiling; actual TX
power tracks the reverse-link SNR margin (measured above the per-SF demod floor,
EWMA-smoothed, proportional step with a deadband). Feedback comes from direct /
room-server ACKs and, for channels (no ACK), from hearing a repeater rebroadcast
our own flood; a lost confirmation ramps power back up so channel sends can't get
stranded below what the repeaters can hear.
Prefs schema 0xC0DE0009 (rx_powersave, tx_apc). Radio page / name bar show the
live TX power; noise floor reads n/a while duty-cycling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverts onDiscoveredContact to the upstream 4-param signature, adding a
separate virtual onDiscoveredAdvert(bool was_flood) with a default no-op
implementation. Only MyMesh overrides the new virtual; simple_secure_chat
and any future implementors need no changes on upstream merges.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
USB client presence IS detectable on nRF52 after all: (bool)Serial ==
tud_cdc_n_connected() (DTR — a host has the CDC port open). Add
isClientConnected() = BLE-bonded OR USB-CDC-open; DualSerialInterface
overrides it, base defaults to isConnected() (single-transport unchanged).
Wire it so each consumer gets the right signal:
- hasConnection() ← isClientConnected(): Auto buzzer mute + message-wake now
trigger on BLE or an open USB port (PR #14's intent, done correctly), but
not on charging-only (no host → DTR low).
- BT status indicator + pairing PIN stay on isBLEConnected() (BLE-specific).
Caveat: a plain serial monitor also asserts DTR, so it counts as connected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a tall portrait e-ink panel the clock and lock screens wasted most of
the vertical space on a small inline "HH:MM". Render HH and MM on two
lines in a new size-4 font (built-in GFX scaled 7×, ~42×56 px) so the
digits roughly double in height and fill the narrow width. Wide panels
(OLED, landscape e-ink) keep the classic single-line size-2 layout.
- New shared drawClockTime() helper used by both the Clock home page and
the lock screen; returns the y below the time block so the date and
dashboard rows flow beneath it.
- GxEPDDisplay: size 4 = built-in font × BIG_TEXT_SCALE (7); getCharWidth /
getLineHeight / setTextSize handle it; fontAscender stays 0 (built-in is
top-left origin).
- Centre the big digits on their visible glyph width — the built-in font
advances 6 px per char but the glyph is 5 px wide, so getTextWidth over-
reports by one trailing column and the digits would sit ~half a column
left of centre. AM/PM rendered one size larger (size 2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
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>