Skip to content

fix: URL-decode WiFi passwords before truncating to buffer size#180

Closed
muness wants to merge 22 commits into
masterfrom
fix/wifi-password-url-decode
Closed

fix: URL-decode WiFi passwords before truncating to buffer size#180
muness wants to merge 22 commits into
masterfrom
fix/wifi-password-url-decode

Conversation

@muness

@muness muness commented Mar 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • Fixes silent corruption of WiFi passwords containing special characters during device setup
  • A 63-char password from a password manager with special chars (e.g. !@#$) URL-encodes to potentially 189 bytes, but get_form_field() was truncating to 64 bytes before URL-decoding, producing a garbled shorter password → "Wrong Password"
  • Uses a 256-byte intermediate buffer for the URL-encoded form, decodes there, then copies decoded result to the caller's 65-byte output buffer

Fixes #179

Files Changed

  • idf_app/main/captive_portal.c — captive portal WiFi setup (GET handler)
  • idf_app/main/config_server.c — config server WiFi add (POST handler)
  • frame_app/main/captive_portal.c — frame app captive portal (GET handler)

Test plan

  • Flash firmware and configure WiFi with a short, simple password — should still work
  • Configure WiFi with a 63-character password containing special characters (!@#$%^&*) — should now connect successfully
  • Verify config server /wifi-add POST endpoint also works with long passwords

Summary by CodeRabbit

  • Bug Fixes
    • Fixed form-field decoding and truncation issues so submitted form data is decoded reliably and preserved.
    • Improved handling of stored configuration fields (Wi‑Fi credentials and bridge URL) to prevent accidental truncation and ensure proper termination, reducing corrupted or incomplete settings.

durch and others added 18 commits February 19, 2026 09:11
* feat: manifest-driven UI renderer (ADR-0003)

Add manifest_parse.c/h and manifest_ui.c/h — a fresh renderer that
builds screens from JSON manifest served by the bridge. Gated behind
USE_MANIFEST compile flag (default OFF, zero regression).

- manifest_parse: cJSON parser for full manifest, fast-only, sha-only
- manifest_ui: media/list/card screen renderers, screen manager,
  chrome (header, status dot, status bar), zone picker, volume overlay
- bridge_client: dispatch macros route all ui_* calls through
  USE_MANIFEST conditional, fetch_manifest() with SHA-based 304
- CMakeLists: USE_MANIFEST option, new source files
- Wired main_idf.c, app_main.c, platform_input_idf.c
- Added .cache/ to .gitignore

* fix: optimize build for DRAM fit and UI performance

- Switch from -Og (debug) to -O2 (IDF libs) + -O3 (our code)
- Exclude ui.c when USE_MANIFEST=ON, add compat shims in manifest_ui.c
- Reduce MANIFEST_MAX_SCREENS 8→4, MAX_LIST_ITEMS 64→16 for DRAM
- Enable SPIRAM BSS segment for overflow safety
- Add missing bridge_client.h include in platform_input_idf.c

* feat(manifest-ui): progress/status screens, art mode, volume gradient, circular artwork

- Add progress and status screen renderers (build + update + dispatch)
- Implement art mode: hide chrome/controls, full opacity artwork
- Volume arc gradient: blue → purple → red based on level
- Artwork fetched at full display size with bridge-side circular clipping (clip_radius param)
- Progress arc hidden when no track duration
- Remove scrollable flag for proper image centering
- UI_UPDATE noop in manifest mode (stops IP flash)
- Remove debug fetch_manifest logs
- bridge_client_get_artwork_url gains clip_radius parameter

* feat(bridge-client): UDP fast-path with HTTP fallback

Poll bridge via UDP (48-byte binary) instead of HTTP+JSON for fast
state. Falls back to HTTP on any UDP failure (timeout, bad response,
bridge without UDP support). SHA mismatch triggers HTTP manifest fetch.

Wire format: 54-byte request (magic+SHA+zone_id), 48-byte response
(flags+SHA+volume+seek+transport). Zero parsing — memcpy into struct.

* feat(ui): smooth arc transitions for volume and progress

Replace instant lv_arc_set_value jumps with 300ms ease-out animations
using lv_anim_t. Volume arc interpolates gradient color during animation.
Optimistic volume change (encoder turn) stays instant for snappy feel.
Progress arcs (media seek + standalone progress screen) also animated.

* polish(ui): volume suffix dB/%, smoother 500ms ease-in-out arcs

- Show 'dB' suffix for negative-min zones (Roon), '%' for percentage zones (LMS)
- Bump arc animation from 300ms ease-out to 500ms ease-in-out for smoother feel

* tweak: arc animation 550ms

* feat: manifest UI is now the default

USE_MANIFEST option flipped to ON. Plain 'idf.py build' builds
manifest mode. Pass -DUSE_MANIFEST=OFF for legacy UI.

* feat: UDP fast-path logging for verification

Firmware logs 'UDP fast-path OK' on first success and every 30th poll.
Logs 'falling back to HTTP' when UDP fails. Quiet during normal operation.

* fix: suppress polled volume during optimistic guard window

After encoder volume change, ignore polled volume updates for 1.5s.
Prevents jitter where fast UDP poll returns stale server value before
the volume command has been processed.

* quiet: remove per-poll manifest_ui_update log

* feat: UDP volume commands + remove optimistic guard

Volume changes sent via non-blocking UDP sendto instead of blocking
HTTP POST. UI thread never blocks on volume. HTTP fallback if UDP
socket not ready.

Removed volume guard window — poll shows truth, not optimistic cache.
With UDP commands, bridge knows real volume almost immediately.

* feat: OTA update UI for manifest mode

Full-screen overlay with progress arc, percentage, status text.
Tap overlay to start update (disabled during download).
Thread-safe via platform_task_post_to_ui callbacks.
Replaces the no-op stubs.

* fix: reduce ART_SIZE 510→336 — less crop, less waste

Old 510px was based on incorrect geometry (D×√2). 336px gives
minimal overflow past the 360px display circle while preserving
more of the album art. Transfer drops from 520KB to 226KB per image.

* fix: address CodeRabbit review findings

- Fix critical nav index bug: find_screen_index returned screens[] index
  but current_screen is a nav index. Now searches nav.order[] directly.
- Add upper-bound clamp to zone picker scroll selection.
- Null-terminate s_mgr.sha after strncpy.
- SHA fields in UDP structs: uint8_t→char (text, not binary).
- Fix misleading comments: CMakeLists default, sdkconfig -O2, parse_fast doc.
- Reduce ART_SIZE 510→336 for less crop waste.

* fix: mDNS race — don't query before init completes

Bridge client poll loop was calling mDNS discovery before
platform_mdns_init() ran, burning through all 10 retries with
ESP_ERR_INVALID_STATE. After that, it permanently gave up.

Add platform_mdns_is_ready() gate — discovery attempts are
skipped until mDNS service is initialized.

* feat: UDP broadcast bridge discovery — no mDNS required

When bridge_base is empty, sends a UDP poll to 255.255.255.255:8089.
The bridge already listens on that port and responds normally.
Source IP from the response reveals the bridge address.

Tried before mDNS — works on networks where multicast is blocked
but broadcast isn't (most consumer gear). Falls back to mDNS if
broadcast gets no response.

* fix: flash-size 8MB→16MB in merge-bin — matches sdkconfig

Firmware compiled with CONFIG_ESPTOOLPY_FLASHSIZE=16MB but the
merge-bin header said 8MB. Bootloader sees the mismatch and the
device becomes unresponsive after web flash.

* fix: show network/setup messages in manifest UI mode

After erase+flash, WiFi provisioning messages were invisible because
manifest_ui_set_network_status was a no-op stub. No bridge = no
manifest = black screen with no text.

Implements a fullscreen overlay banner that shows setup messages
("Setup: Connect to 'roon-knob-setup'", "WiFi: Connecting...", etc.)
centered on a black background. Created on first use, hidden when
ui_set_network_status(NULL) fires after WiFi connects.

* fix: setup/provisioning messages visible in manifest UI

- AP mode banner now shows full instructions: WiFi SSID to connect
  to AND the fallback URL (192.168.4.1) for when captive portal
  auto-popup doesn't trigger
- Captive portal save/reboot countdown uses persistent network
  banner instead of transient 3-second status messages that
  disappeared before the user could read them

* fix: zone list items respond to touch

List buttons had user_data set but no click handler. Tap now sets
selected index and fires UI_INPUT_PLAY_PAUSE to confirm — same
path as encoder press.

* fix: OTA overlay says 'Tap to install' — knob has touchscreen

* fix: force full-screen invalidation on init — clear stale GRAM

SH8601 OLED retains its framebuffer across ESP reset. After
erase+flash the old screen content persists because LVGL's partial
rendering mode may not redraw pixels it considers unchanged.

lv_obj_invalidate(screen) at end of manifest_ui_init() forces LVGL
to flush the entire display on the first lv_task_handler() call.

* fix: remove lv_refr_now (stack overflow), add init+banner logging

* fix: show connection progress and distinguish first-time vs fallback AP

- CONNECTING shows SSID and attempt count (e.g. 'Attempt 2/5')
- AP_STARTED after failed retries shows 'Could not connect to <ssid>'
- First-time AP (no saved SSID) shows original setup instructions

* fix: WiFi setup/retry flow — three bugs

1. Missing break in AP_STARTED case — fell through to AP_STOPPED,
   overwriting setup banner with 'WiFi: Connecting...'

2. bridge_client set_network_ready(false) competed with WiFi handler
   for the banner during boot. Now only sets banner for RECONNECTING
   (was operational, lost network), not during initial BOOT.

3. esp_wifi_stop() in start_ap_mode fired STA_DISCONNECTED which
   triggered spurious retry. Set s_ap_mode before stopping STA,
   guard schedule_retry_with_reason.

Also: remove debug logging (flush count, ui_loop_iter, network_status).

* ci: update ESP-IDF to release-v5.5 to match local build

CI was building with v5.4.3, local with v5.5.2. The v5.4 binary
doesn't flush LVGL after esp_restart (post-captive-portal reboot),
causing stale GRAM content to persist. v5.5 works.

* fix: apply display defaults (rotation 180) on fresh config

platform_storage_defaults() did memset(0) but never called
rk_cfg_set_display_defaults(), so rotation_charging stayed 0
instead of the default 180. Screen wasn't flipped when charging.

* fix: captive portal preserves display defaults (rotation, timeouts)

On fresh device, captive portal saved config with rotation=0/0
because it loaded an empty config and never applied defaults.
Now calls rk_cfg_set_display_defaults() before saving when no
valid config exists. Also uses RK_CFG_CURRENT_VER instead of
hardcoded 1.

* fix: don't regress from OPERATIONAL to CONNECTED on WiFi reconnect

set_network_ready(true) unconditionally set state to CONNECTED and
showed 'Loading zones...' banner even when device was already
OPERATIONAL. On transient WiFi drops during art mode, this caused
the banner to appear on wake. Now preserves OPERATIONAL state.

* fix: don't force-show network banner on art mode wake

ui_set_controls_visible(true) unconditionally unhid the network
banner, resurrecting a stale 'Loading zones...' message that had
already been cleared. The banner manages its own visibility via
set_network_status — art mode wake should not touch it.

* feat: WiFi signal strength indicator in chrome

4 vertical bars (top-left area), updated every 2s from ESP32 RSSI.
Bars light up based on signal: ≥-50 dBm = 4, ≥-60 = 3, ≥-70 = 2,
≥-80 = 1. Hidden when not connected. Hidden in art mode, reappears
on wake via periodic update. PC build guarded.

* fix: WiFi indicator — horizontal fan bars, remove RSSI log

Replaced vertical bars (mobile signal look) with horizontal bars
in fan shape (widest at top, narrowest at bottom). 4 bars: 16/12/8/4px
wide, 2px tall, centered. Adjusted thresholds for ESP32 antenna.

* fix: WiFi bars fill from bottom (narrowest) up

* fix: nudge WiFi indicator down 3px to clear volume arc

* fix: WiFi indicator position — 2px up, 2px right

* feat: WiFi indicator — colored dot at top center

Replaced horizontal signal bars with a single 8px colored dot.
Color encodes signal strength: blue (excellent ≥-55), green (good
≥-65), yellow (fair ≥-75), red (weak). Black border for contrast
against album art. Hidden in art mode, reappears on wake.

* feat: progress bar with local seek interpolation

Bridge sends seek_position as a snapshot (LMS doesn't push
continuous seek updates like Roon). Firmware now interpolates
locally: stores last seek + timestamp, ticks forward every 1s
while playing. Progress arc animates smoothly instead of staying
frozen at the initial position.

Also: WiFi TX power bumped 11→17 dBm for better range on boards
with weak PCB antennas. Negligible battery impact (~1-2mA avg).
Add ui_show_settings() call to zone_label_long_press_cb in
manifest_ui.c, matching legacy ui.c behavior. The callback already
sets s_zone_long_pressed to prevent click-after-long-press from
also opening the zone picker.

Closes #127

Co-authored-by: Miranda <miranda@example.com>
* feat: render list item sublabels with selection highlight

- Add flex column layout to list item buttons for label + sublabel
 vertical stacking
- Render sublabel in font_small() with COLOR_TEXT_DIM when present
- Items without sublabel render unchanged (single flex child)
- Change selected item highlight from grey (0x333333) to blue-tinted
 (0x2a4a6a), matching legacy zone picker
- Add LV_LABEL_LONG_DOT truncation for long sublabels

Closes #128

* fix: add width constraints for label DOT truncation in column flex

Per CodeRabbit review: LV_LABEL_LONG_DOT requires explicit width to
trigger ellipsis. Also fix primary label width which loses auto-fill
when button flex switches from ROW to COLUMN.

---------

Co-authored-by: Miranda <miranda@example.com>
Port battery display from ui.c into manifest_ui.c:
- Battery icon in header flex column above zone label
- 4 discrete levels (critical/low/medium/high) with hysteresis
- Red color for critical/low, grey for normal
- Charging state shows charging icon
- 30-second poll timer + immediate refresh on ui_update_battery()
- Art mode hides/shows icon with hysteresis reset

Closes #130

Co-authored-by: Miranda <miranda@example.com>
- Port fractional step detection from legacy: use (step_abs - (int)step_abs) > 0.01f
 instead of vol_step < 1.0f which incorrectly treats integer steps as fractional
- Extend fractional formatting to percentage mode (symmetric with dB mode)
- Fix play button border width: 2px -> 3px to match legacy visual weight
- Document that manifest color choices (muted green, red offline, OPA_90) are
 intentional improvements over legacy, not bugs

Closes #131

Co-authored-by: Miranda <miranda@example.com>
* fix: long-press zone label toggles settings panel

* fix: shrink album art to 320px — pull inside circle edge

* fix: remove progress debug logging
…139)

In manifest mode, UI_UPDATE is a noop so error state details (attempt
counts, header text) were lost. The UI_SET_NETWORK_STATUS calls existed
but used terse messages like 'mDNS: 3/10' or 'Bridge: Retry 2/10'.

Enrich all four error path network status messages to include the full
context previously carried by UI_UPDATE line1/line2:
- Just lost connection: 'Testing Bridge\nAttempt X of Y...'
- mDNS still searching: 'Searching for Bridge\nAttempt X of Y...'
- Bridge configured, max retries with IP: 'Bridge unreachable\nUpdate at http://...'
- Bridge configured, still retrying: 'Testing Bridge\nAttempt X of Y...'

The network banner is already persistent and fullscreen, so these
messages now satisfy the acceptance criteria for persistent error state
display during the connection sequence.

Co-authored-by: Miranda <miranda@example.com>
* fix: long-press zone label toggles settings panel

* fix: shrink album art to 320px — pull inside circle edge

* fix: remove progress debug logging

* feat: album art edge color as media screen background

Bridge extracts average color from outer 15% border strip of album art
during image conversion and caches it by image_key. Manifest includes
background_color on MediaScreen. Firmware parses hex string and applies
it to the media container background.

Also shrinks ART_SIZE from 336 to 280 to pull art inside circle edge,
making the colored background visible around the artwork.
…142)

* fix: long-press zone label toggles settings panel

* fix: shrink album art to 320px — pull inside circle edge

* fix: remove progress debug logging
…144)

- Progress gutter arc (8px black) behind progress arc for contrast
- Progress indicator color derived from album art edge color
- brighten_floor() ensures minimum channel value of 80 for dark art
- Gutter visibility synced with progress arc (hidden when no track length)
* feat: multi-WiFi support — store up to 4 networks, try in order

Config v3 adds rk_wifi_entry_t wifi[4] array to rk_cfg_t.
On boot, wifi_manager tries each stored network in order.
After STA_FAIL_THRESHOLD failures on one network, advances to
the next. After all exhausted, enters AP mode for provisioning.

Captive portal adds new credentials to the wifi[] list.
Config web page (http://<knob-ip>/) shows saved networks with
add/remove controls.

Migration: v2 configs auto-migrate ssid/pass into wifi[0].
Active ssid/pass fields kept for runtime use by bridge_client.

* fix: clear auto-discovered bridge on WiFi connect for location switching

When network becomes ready, clear bridge_base if it was from mDNS.
Forces fresh discovery on every WiFi connect. Solves two cases:
- Different locations with different SSIDs (different wifi[] entries)
- Different locations with the SAME SSID (mDNS finds local bridge)

* fix: config_server.c compile error — missing cfg declaration and if block

* feat: UI polish — zone selector fix, art-themed accents, progress arc stability

- Fix zone selector showing status instead of names: reset flex_grow(0)
  and LV_LABEL_LONG_DOT on primary label after switching lv_list_add_btn
  to column flow (lv_list_add_btn sets flex_grow(1) + SCROLL_CIRCULAR
  which breaks in column layout)

- Theme entire UI from album art edge color: volume arc, progress arc,
  and transport button borders all use brightness-floored accent color
  derived from album art. Falls back to default blues when no art color.

- Remove volume arc gradient (blue→purple→red) — flat accent color,
  volume number provides the loudness indication

- Add black gutter behind volume arc (12px black behind 8px colored)
  for visibility against art backgrounds

- Move WiFi indicator dot into progress arc ring at 12 o'clock

- Fix progress arc stalling: remove backward snap logic (diff < -3)
  that dragged local interpolation back to stale server seek values.
  Now only resets on: first update, server ahead, or state change.

- Reduce RK_MAX_WIFI from 4 to 2 (rk_cfg_t 766→570 bytes)

* fix: address CodeRabbit review — zone re-discovery, input validation, buffer sizes

- Reset zone_resolved + s_bridge_verified when clearing mDNS bridge on
  WiFi reconnect, ensuring zones are re-discovered at new location

- Increase wifi_html buffer 512→1024 for HTML rendering headroom
- Increase POST body buffer 256→384 for URL-encoded SSID+password
- Replace atoi with strtol+validation in wifi_remove_handler
- Return 500 on platform_storage_save failure in add/remove handlers
- Clear wifi[] array in platform_storage_reset_wifi_only

* fix: hide volume gutter arc in art mode

* fix: accent color — uniform brightness scaling preserving hue

Per-channel floor clamping desaturated dark colors (e.g. #052062 became
grayish #505062). Now scales all channels uniformly so the brightest
reaches the floor (180), preserving hue and saturation. #052062 becomes
a vivid blue rgb(9,59,180) instead of gray.

* fix: accent color matches background — no brightness manipulation

Use the same parsed hex color for both background and accent.
The brightness floor was distorting the hue. Remove dead
parse_hex_color_bright function.

* fix: remove default theme shadow from transport buttons

LVGL default theme applies a grey shadow to all buttons, creating
a second border-like ring around the transport controls.

* fix: arc track colors adapt to background brightness

Compute perceived luma of album art edge color. Dark backgrounds
(luma < 80) get lighter arc tracks (0x5a5a5a/0x4a4a4a) for contrast.
Light backgrounds keep the default dark tracks. Resets to defaults
when no art color.

* feat: adaptive button shadows — light glow on dark, dark shadow on light

Dark backgrounds (luma < 80) get a subtle grey glow around buttons.
Light backgrounds get a dark drop shadow. No art color = no shadow
(transparent). Play button slightly larger shadow (8px vs 6px).

* fix: play button clipping — allow overflow on controls container

* feat: adaptive text colors — light text on dark bg, dark text on light bg

Title and volume use near-white on dark backgrounds (default).
Light backgrounds flip to near-black title/volume and dark gray artist.

* fix: explicit 84px height for controls row — prevent play button clipping

LV_SIZE_CONTENT may undercount cross-axis height in flex row when
children have different sizes, causing the 80px play button to be
clipped by the 60px-height siblings.

* feat: adaptive zone label color — same light/dark treatment as title

* fix: remove dead last_seek<0 check, fix else brace indentation

CodeRabbit review round 3 — both minor cleanups.
* fix: detect stale sdkconfig after defaults change

ESP-IDF only applies sdkconfig.defaults when generating a fresh sdkconfig.
If a user already has a sdkconfig from a previous build, pulling new code
with updated defaults silently ignores the changes. This causes confusing
build failures: DRAM overflow, missing LVGL symbols, wrong optimization
level.

Add a CMake guard that hashes sdkconfig.defaults and stamps it in the
build directory. On subsequent configures, if the hash has changed and
an existing sdkconfig is present, the build fails with a clear message
telling the user to delete sdkconfig and rebuild.

The guard is bypassed when no sdkconfig exists (fresh build), so the
normal first-build flow is unaffected.

* fix: add CMAKE_CONFIGURE_DEPENDS so idf.py build triggers the check

Without this, CMake skips reconfiguration when only sdkconfig.defaults
changes (it's not in the default dependency set). The staleness guard
would only fire on explicit reconfigure or fullclean, not on a normal
idf.py build after git pull.

* fix: remove unused HTTPS/basic-auth config from install.sh

install.sh injected CONFIG_ESP_HTTP_CLIENT_ENABLE_HTTPS=y and
CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH=y into sdkconfig.local.
Neither HTTPS nor basic auth is used anywhere in the firmware —
the bridge communicates over plain HTTP on the local network.

These options pull in the full mbedTLS stack, adding significant
DRAM and flash usage. Combined with the manifest UI, PSRAM artwork
buffers, LVGL, and WiFi, this pushes the firmware over the DRAM
budget, causing boot crashloops on the device.

Also make build_flash_idf.sh skip set-target when already configured,
matching the pattern install.sh already uses. The unconditional
set-target was triggering a full rebuild on every invocation.

* fix: add missing stack size and SPIRAM BSS to sdkconfig.defaults

Two settings that were only in local sdkconfigs, never committed to
defaults. Clean builds from sdkconfig.defaults alone would crash:

- CONFIG_ESP_MAIN_TASK_STACK_SIZE=4096: IDF default 3584 is too small
  for the manifest UI init path, causing stack overflow on boot.

- CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY=y: allows large BSS
  segments to spill to PSRAM. Without this, DRAM overflows when the
  manifest UI allocates screen/list buffers in static storage.

* refactor: split setup and install into separate scripts

setup_mac.sh — one-time after clone or IDF version change:
  brew deps, fullclean, set-target esp32s3

install.sh — build and flash with baked-in WiFi credentials:
  writes sdkconfig.local, reconfigure, build, flash
  Bails early if setup hasn't been run.

Previously install.sh tried to do both, and build_flash_idf.sh ran
set-target on every invocation (triggering full rebuilds). Now setup
is explicit and only done once.

* fix: increase MANIFEST_MAX_ID from 32 to 64 for Roon zone IDs

Roon zone IDs are UUIDs (36 chars, e.g. 1601a5e3-9b43-4b64-8f44-3964e129d3ee).
MANIFEST_MAX_ID was 32, truncating them during manifest parsing. The
truncated ID didn't match anything in bridge_client's zone list, so
zone selection silently failed. Without a valid zone_id, fetch_manifest
bails, no manifest loads, and subsequent taps of the zone label find
no list screen — does nothing.

Reverts the MANIFEST_MAX_SCREENS bump (4→8) from the previous commit —
the bridge always sends exactly 2 screens (media + zones list),
regardless of zone count. That was a red herring.

* fix: address review nits from CodeRabbit

- Quote CMake variable expansions in staleness guard for path robustness
- Remove redundant idf.py reconfigure from install.sh (build suffices)
- Remove duplicate CONFIG_LWIP_NETIF_HOSTNAME=y entry
- Remove extra blank line in CMakeLists.txt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Muness Castle <931+muness@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: increase UDP zone_id from 32 to 64 bytes for Roon IDs

Roon zone IDs are 41 characters (e.g., roon:1601...c231) but the UDP
wire protocol used zone_id[32], truncating them to 31 chars. This broke
UDP fast-path polling and volume commands for Roon zones.

Closes #150

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review findings for UDP zone_id change

- Update test_udp.py to use 64-byte zone_id (was hardcoded to 32)
- Add _Static_assert for all three UDP struct sizes
- Improve comments: mention max backend ID size, not just Roon

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add _Static_assert for UDP_CMD_SIZE (without value field)

CodeRabbit caught that UDP_CMD_SIZE (command without float value)
was not covered by compile-time assertions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: WiFi failover race condition + captive portal network management

1. Race condition: connect_now() called esp_wifi_set_config() while the
   driver was still connecting to the previous SSID, causing "sta is
   connecting, cannot set config" errors. Fix: disconnect before applying
   new config.

2. Captive portal (AP mode) now shows saved WiFi networks with Remove
   buttons, so users can delete stale/wrong SSIDs when the device can't
   connect. Previously this was only available in the config server
   (STA mode), which is unreachable when WiFi is broken.

Refs #152

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address CodeRabbit review — XSS, POST for removal, assert comment

- Escape SSIDs in HTML output (captive portal + config server) to
  prevent XSS from rogue AP names containing <, >, &, ", '
- Change captive portal wifi-remove from GET to POST to prevent
  accidental deletion by prefetchers/link scanners
- Clarify UDP_CMD_SIZE assert assumes 'value' is the last field

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Zone picker fixes:
- Rotary encoder scroll moves visual highlight and auto-scrolls
- Current zone pre-selected and scrolled into view when picker opens
- List padding prevents item clipping at circular display bezel edges
- Selecting the current zone closes picker silently (no "Loading zone...")
- Zone label stays hidden when display wakes while picker is open

WiFi fix:
- Cancel pending retry timer on successful connection (GOT_IP) — the timer
  could fire after WiFi connected, disconnecting a working link and causing
  a reconnect loop

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add hiphi frame — e-ink now-playing display for PhotoPainter board

New ESP-IDF app (frame_app/) targeting the Waveshare ESP32-S3-PhotoPainter
with its 7.3" 800x480 4-color e-ink display. Reuses common/ bridge client
and platform HAL.

Includes: SPI e-ink driver, Floyd-Steinberg dithering to 4-color palette,
bitmap font renderer, 3-button input, AXP2101 PMIC battery monitoring,
WiFi provisioning via captive portal, and debounced e-ink refresh (180s
cooldown per Waveshare recommendation).

Tested on hardware: boots, connects to bridge, downloads and dithers
album art, renders to e-ink panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: BLE media remote, zone/BLE web UI, art-centered e-ink layout

Add BLE HID host (NimBLE HOGP) for media remote control — scan, pair,
bonding with NVS persistence. Consumer Control HID keys (play/pause,
next, prev, volume) mapped to bridge commands.

Extend captive portal with STA-mode web server for zone picker and BLE
remote configuration. Zone API exposed from bridge_client for web UI.

Overhaul e-ink layout: ~450x450 centered album art with slim track/artist
bar at bottom, BLE/bridge status icons in bottom-right corner.

Builds clean for ESP32-S3 with WiFi + BLE coexistence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review findings — thread safety, XSS, favicon

- bridge_client: copy zone_id/cfg under lock instead of returning raw ptr
- bridge_client: set_zone copies locals before unlocking for save/UI call
- ble_remote: add mutex protecting all BLE state, volatile for cross-task bools
- ble_remote: fix unpair double-free with s_unpair_pending flag
- ble_remote: bounded reconnect attempts (max 20), device_name copies under lock
- captive_portal: XSS prevention via is_safe_url() + html_escape()
- captive_portal: WiFi form uses POST, password no longer logged
- captive_portal: strtol for BLE pair index, larger zone page buffer
- captive_portal: add favicon (same icon as unified-hifi-control)
- eink_ui: bridge/BLE icons use red when disconnected for visibility
- sdkconfig: document CONFIG_BT_NIMBLE_HID_SERVICE requirement

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address CodeRabbit findings — 4 critical, 8 major

Critical:
- dns_server: bounds check before writing 16-byte answer section
- eink_font: fix font_24 scaling (12/8=1 truncation) with nearest-neighbor sampling
- eink_ui: cache dithered artwork in PSRAM, blit on non-art-dirty renders
- platform_storage: use sizeof(*out) not stored_len for nvs_get_blob capacity

Major:
- ble_remote: clear s_scanning on semaphore timeout to prevent stuck state
- dns_server: bounds check before rx_buf[qpos] in domain logging
- dns_server: use semaphore instead of blind vTaskDelay for task stop
- eink_display: null check s_fb in set_pixel/get_pixel
- platform_http: dynamic buffering for chunked transfer encoding
- platform_input: split GPIO config — pull-down for active-high PWR pin
- platform_input: promote timer handle to static, implement proper shutdown
- platform_mdns: check addr.type == ESP_IPADDR_TYPE_V4 before using ip4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: reduce e-ink cooldown, strip buttons, add web restart

- Reduce render cooldown from 180s to 30s, debounce from 5s to 3s
- Strip physical buttons to essentials: BOOT long=WiFi AP, GP4 long=restart
- Add POST /api/restart endpoint and restart button to zones + BLE pages
- Add cooldown diagnostic logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: enable PMIC ALDO power rails for e-ink panel, add 180° rotation

Root cause: e-ink panel had no power — AXP2101 ALDO1-4 outputs were
never enabled, so the panel ignored all SPI commands and BUSY pin
stayed HIGH. Display appeared to "refresh" in 40ms but nothing
actually happened on screen.

- Configure PMIC ALDO1-4 to 3.3V and enable before display init
- Move PMIC init before e-ink init in boot sequence
- Add 180° framebuffer rotation so display is right-side up with
  the frame leg at the bottom
- Panel refresh now takes ~19s (real ACeP refresh) with working
  BUSY pin signaling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: enable all 6 ACeP colors (add blue+green), stop volume-triggered refreshes

The dither palette only had 4 colors (black, white, yellow, red), causing
blue to render as gray. Expanded to full 6-color ACeP palette with correct
panel hardware indices (Blue=5, Green=6). Also removed volume changes from
triggering full e-ink refreshes since volume isn't displayed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: deep sleep, checkerboard dither, 180s cooldown, e-ink hardware docs

- Panel enters deep sleep (0x07/0xA5) after each refresh, wakes via
  hardware reset before next refresh — reduces idle power to ~µA
- Add checkerboard dithering as alternative to Floyd-Steinberg; cleaner
  pattern at 127 PPI where individual pixels are visible
- Increase refresh cooldown from 30s to 180s per Waveshare recommendation
  for panel longevity
- Add docs/frame/hw-reference/eink_panel.md with full Spectra 6 panel
  reference (commands, color indices, limitations, dither notes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* revert: switch back to Floyd-Steinberg dithering

Checkerboard dithering produced visibly worse quality. Revert to
Floyd-Steinberg error diffusion. Checkerboard function remains
available in eink_dither.c for future experimentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: BLE pair button layout, revert to Floyd-Steinberg dithering

- Add missing .device form CSS rule (display:inline;margin:0) matching
  .zone form — fixes Pair button being invisible/non-functional in flex
  layout
- Revert from checkerboard to Floyd-Steinberg dithering (better quality)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: BLE pairing — initiate security before GATT reads

esp_hidh's nimble_hidh.c has no security handling. When connecting to
HID devices that require encryption (like the FiiO RM3), GATT reads of
the HID Report Map fail with INSUFFICIENT_ENCRYPTION (status 271) and
the connection drops.

Fix: register a system-wide NimBLE GAP event listener that calls
ble_gap_security_initiate() immediately on BLE_GAP_EVENT_CONNECT. This
triggers pairing/bonding before esp_hidh attempts protected reads.

Also enable USB Serial/JTAG console so logs appear over USB.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: BLE remote commands — skip usage check, revert to UART console

The esp_hidh report map parsing fails due to GATT reads happening
before encryption completes (status=14), leaving all reports with
usage=0. Since BLE remotes only send consumer control, pass all
HID input directly to the consumer control handler.

Also revert console from USB_SERIAL_JTAG to UART — USB_SERIAL_JTAG
causes flash reliability issues (connection drops mid-write).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address 24 CodeRabbit review findings across 11 files

BLE remote:
- Read bonded device state under mutex in reconnect_task (#22)
- Check xTaskCreate return in scan_start to avoid stuck s_scanning (#23)
- Increase unpair delay and keep s_unpair_pending until free (#20)

Captive portal:
- Fix get_form_field substring matching (check preceding '&') (#21)
- Check save result before sending success response (#24)
- HTML-escape bridge_url in href attributes to prevent XSS (#25)
- Clamp snprintf pos to prevent OOB writes on truncation (#26)

E-ink:
- Only clear art_dirty on successful artwork render (#29)
- Fix stale refresh time comments, bounds-check doc (#27, #43)
- Guard eink_scale_bilinear against zero dimensions (#44)
- Suppress unused volume_step parameter warning (#45)

HTTP:
- Cap response buffer growth at 512KB (#30)
- Fix signed integer overflow in gzip shift operations (#31)
- Cap gzip uncompressed size at 2MB (#32)
- Add missing error log on image read failure (#33)

Platform:
- Check esp_timer_create/start return values (#34)
- Make s_mdns_ready volatile for cross-task visibility (#35)
- Fix .local suffix check to match end-of-string only (#36)
- Log oversized NVS blob case (#37)

Main:
- Guard against double-init of BLE and STA server on reconnect (#39)
- Check xTaskCreate return for UI loop task (#40)

PMIC:
- Initialize ldo_ctrl and check I2C read result (#41)
- Fix ADC comment: 1mV/LSB, not 1.1mV (#42)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: don't blank e-ink when artwork download fails

If artwork download fails and there's no cached art, skip the
render entirely. It's e-ink — whatever's on screen stays, which
is always better than a blank white panel.

Closes #159

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: e-ink blank screen, add bridge eink_acep6 pre-processing, full-width art

Three changes:

1. Fix e-ink going blank (#159): eink_display_init_panel() was clearing the
   framebuffer on every panel wake from deep sleep, wiping artwork that had
   just been drawn. Also stop text-only state changes (status, zone, message,
   track name, battery) from triggering 20s e-ink refreshes — only artwork
   changes trigger renders now. Cancel pending renders when artwork is cleared
   (nothing playing) to keep last image on screen.

2. Bridge pre-processing: request format=eink_acep6 (4-bit packed, panel
   hardware color indices) from the bridge. Falls back to format=rgb565 with
   on-device Floyd-Steinberg dithering if the bridge returns an unexpected
   size. Eliminates all image processing on the ESP32 when bridge supports it.

3. Full-width artwork: expand from 450x450 centered square to 800x450
   (full panel width, 30px text bar at bottom). Bridge handles crop-to-fill
   for non-matching aspect ratios.

Closes #159

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address 6 CodeRabbit review findings

- bridge_client: null/size guard in bridge_client_get_zones
- ble_remote: check ble_gap_conn_find return before using desc
- platform_input: delete timer handle if start_periodic fails
- eink_ui: free img_data on rgb565 download failure (leak fix)
- eink_dither: guard src_w/src_h against zero in scale_bilinear
- docs: fix SPI typo CPHL → CPHA

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address remaining 9 CodeRabbit review findings

- captive_portal: increase wifi_html buffer 512→1024, loop strstr for
  substring-safe field matching
- platform_input: clarify poll_button calls update state for restart check
- pmic_axp2101: log failures on all pmic_write_reg calls
- main_frame: add s_mdns_initialized double-init guard
- platform_storage: fix stale "identical" comment, verify bridge_base
  and zone_id on config save (not just ssid)
- eink_panel.md: fix CPHL→CPHA typo, add language IDs to code blocks,
  update dither pipeline docs for eink_acep6

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: eliminate double-write in artwork render pipeline

render_artwork() was writing pixels to both the framebuffer AND the
art cache, then render_full_screen() would clear the FB and re-blit
from cache — effectively writing every artwork pixel twice.

Now cache_packed_artwork() and decode_rgb565_artwork() only populate
the cache; render_full_screen() handles the single write to FB.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: show album name in e-ink text bar

Parse line3 (album) from bridge /now_playing response and display it
in the bottom text bar as "Track  -  Artist  -  Album".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Auto-detect firmware directory (esp_dial or idf_app) so CI works for
both master (idf_app) and v4/feature branches (esp_dial rename).

Also adds v4 to pull_request branch targets and workflow_dispatch trigger.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
get_form_field() was truncating URL-encoded data to fit the 65-byte
decoded password buffer, then decoding in place. Special characters
common in password manager passwords (! @ # $ etc.) URL-encode to 3
bytes each, so a 63-char password could encode to ~189 bytes and get
silently truncated to 64 before decoding — producing a garbled shorter
password and "Wrong Password" on connection.

Fix: use a 256-byte intermediate buffer for the URL-encoded value,
decode there, then copy the decoded result into the caller's buffer.

Fixes #179
@coderabbitai

coderabbitai Bot commented Mar 20, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@muness has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 1 minutes and 56 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3d62fa51-4de2-4e78-853c-beb92ca4007a

📥 Commits

Reviewing files that changed from the base of the PR and between 2f0c0f8 and ebef10a.

📒 Files selected for processing (10)
  • common/bridge_client.c
  • common/manifest_parse.c
  • common/manifest_ui.c
  • common/rk_cfg.h
  • common/ui.c
  • frame_app/main/ble_remote.c
  • frame_app/main/captive_portal.c
  • idf_app/main/captive_portal.c
  • idf_app/main/config_server.c
  • idf_app/main/ota_update.c
📝 Walkthrough

Walkthrough

Reworked form-field parsing and several string-copy sites: get_form_field() now decodes URL-encoded substrings via a fixed intermediate 256-byte buffer before copying into caller buffers; multiple places switched from strncpy(..., size-1) to snprintf(..., "%s", ...) for safer truncation and NUL-termination.

Changes

Cohort / File(s) Summary
Frame captive portal
frame_app/main/captive_portal.c
get_form_field() now copies the matched URL-encoded substring into a fixed 256-byte stack buffer, URL-decodes there, then copies the decoded result into the caller out buffer with explicit clamping and NUL-termination (prevents truncation of URL-encoded inputs).
IDF captive portal & config server
idf_app/main/captive_portal.c, idf_app/main/config_server.c
Same get_form_field() change as above. Also replaced strncpy(..., size-1) with snprintf(..., "%s", ...) when writing Wi‑Fi/bridge strings to ensure consistent truncation and termination semantics.
Config helpers
common/rk_cfg.h
Added #include <stdio.h> and replaced strncpy(..., size-1) + explicit termination with snprintf(..., sizeof(...), "%s", ...) when adding/updating cfg->wifi[].ssid and cfg->wifi[].pass.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I nibble bytes in careful stride,
Encoded crumbs no longer hide,
Into a buffer neat and true,
I decode secrets back to you.
Wi‑Fi whispers safe at last, hop hop—no more past!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main fix: reordering URL-decoding before truncation to prevent password corruption when decoding multi-byte URL-encoded special characters.
Linked Issues check ✅ Passed Changes implement all key requirements from #179: use 256-byte intermediate buffer for URL-encoded data [captive_portal.c, config_server.c, rk_cfg.h], decode before copying to output, preserve final buffer sizes (pass[65], ssid[33]), and apply fix across all affected handlers.
Out of Scope Changes check ✅ Passed All changes directly address #179's requirements: intermediate buffer addition, decoding order fix, and strncpy→snprintf conversions to eliminate compiler warnings from the buffer size changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/wifi-password-url-decode

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
idf_app/main/captive_portal.c (1)

124-127: Consider aligning field search logic with frame_app/main/captive_portal.c.

The frame_app version has substring match protection that checks the preceding character is & or start-of-string, preventing false matches like xssid= when searching for ssid=. This file (and config_server.c) use simple strstr(). While not introduced by this PR, you may want to align them for consistency.

♻️ Optional: Add substring match protection
-  const char *start = strstr(data, search);
-  if (!start) {
-    return false;
-  }
+  // Search for the field, skipping substring matches like "xssid=" for "ssid="
+  const char *start = data;
+  while ((start = strstr(start, search)) != NULL) {
+    if (start == data || *(start - 1) == '&') break;
+    start++;  // Skip this substring match, keep looking
+  }
+  if (!start) {
+    return false;
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@idf_app/main/captive_portal.c` around lines 124 - 127, The current substring
search uses const char *start = strstr(data, search); which can produce false
positives (e.g. matching "xssid="); change the logic to find the next occurrence
and verify the character before the match is either '&' or the start of the
string: loop using strstr(start + 1, search) on failure of that check until a
valid match or NULL, or implement a small helper that checks the preceding
character; update references in this file (start, strstr, data, search) to use
that guarded-match approach consistent with frame_app/main/captive_portal.c.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@idf_app/main/captive_portal.c`:
- Around line 124-127: The current substring search uses const char *start =
strstr(data, search); which can produce false positives (e.g. matching
"xssid="); change the logic to find the next occurrence and verify the character
before the match is either '&' or the start of the string: loop using
strstr(start + 1, search) on failure of that check until a valid match or NULL,
or implement a small helper that checks the preceding character; update
references in this file (start, strstr, data, search) to use that guarded-match
approach consistent with frame_app/main/captive_portal.c.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e2085b49-40ed-4d31-85e6-48a1bf82a819

📥 Commits

Reviewing files that changed from the base of the PR and between b639f5b and 74367ef.

📒 Files selected for processing (3)
  • frame_app/main/captive_portal.c
  • idf_app/main/captive_portal.c
  • idf_app/main/config_server.c

Muness Castle added 4 commits March 20, 2026 14:18
Replace strncpy with snprintf for string copies where GCC's static
analysis propagates the 256-byte intermediate buffer size through the
call chain, triggering truncation warnings on downstream strncpy calls
that were previously borderline.
Add a small rk_strlcpy helper using memcpy+strlen which avoids both
-Wstringop-truncation (from strncpy) and -Wrestrict (from snprintf)
under GCC's aggressive static analysis with -Werror.
Fix -Werror=stringop-truncation in ota_update.c, captive_portal.c
(both idf_app and frame_app) where GCC flags strncpy when source
and destination sizes match.
GCC 14's -Werror=stringop-truncation flags strncpy when source and
destination sizes match. Replace all remaining strncpy calls across
bridge_client.c, manifest_ui.c, manifest_parse.c, ui.c, ble_remote.c,
and ota_update.c with rk_strlcpy which uses memcpy internally and
avoids the warning.
@github-actions

Copy link
Copy Markdown

Firmware Preview

Flash this PR's firmware directly in your browser:
https://roon-knob.muness.com/pr/180/flash.html

Updated: 2026-03-20T18:44:38.999Z

@muness

muness commented Mar 20, 2026

Copy link
Copy Markdown
Owner Author

Requested a test from the user who reported #179

@muness

muness commented Mar 21, 2026

Copy link
Copy Markdown
Owner Author

Superseded by direct fix on master. The strncpy refactor is no longer needed.

@muness muness closed this Mar 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WiFi passwords with special characters silently corrupted during setup

2 participants