Disabled save buttons (e.g. the OTA upload button and the MQTT IATA
save button) were still showing a hover background change because the
generic `button:hover` rule applied unconditionally to all buttons,
including disabled ones.
The attempt in fbab1483 to fix this was incorrect: it added a
`.savebtn:not(:disabled):hover` rule, but that rule sets the same
`var(--accent-hover)` value that `button:hover` already provides.
It never prevented `button:hover` from firing on a disabled element — it
simply didn't match when the button was disabled, leaving `button:hover`
to win unchallenged. The rule had zero net effect and was dead CSS.
The correct fix is to guard the root rule itself:
button:not(:disabled):hover { background: var(--accent-hover); }
Adding `:not(:disabled)` raises the selector's specificity from (0,1,1)
to (0,2,1), which would have overridden `.iconbtn:hover` and
`.themebtn:hover` (both (0,2,0), no element selector). Those two rules
are compensated by adding the `button` element qualifier, restoring
their specificity to (0,2,1) and keeping source-order tie-breaking
intact.
The now-redundant `.savebtn:not(:disabled):hover` rule is removed.
After a successful OTA upload the web panel polled the device by
fetching "/" and waiting for a 200 OK response. This approach
is fundamentally broken when the new firmware contains a freshly
generated self-signed certificate (e.g. from a clean CI build):
- The browser rejects the TLS handshake with a certificate error
(MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT in Firefox; other browsers
report different error names but behave identically). The fetch()
call throws a generic TypeError — the same type thrown for any other
network failure such as a refused or timed-out connection. Browsers
intentionally provide no way to distinguish a cert rejection from a
plain connectivity failure, so the catch block cannot tell whether
the device is up-but-cert-changed or simply not yet online.
- Switching to an explicit http:// URL to sidestep TLS is not an
option: the web panel is served over HTTPS, and browsers block
active mixed content (fetch/XHR to HTTP) unconditionally from an
HTTPS origin.
Because no network-based probe can reliably detect device readiness
under these constraints without changing how the certificate is
generated, replace the polling loop with a simple 10-second countdown
timer that unconditionally redirects to the login page. The device
is typically back online within ~7 seconds, so the 10-second wait
provides a reasonable safety margin.
The packets series is a delta series: each point represents the change in
packets_recv/packets_sent between two consecutive samples. The oldest
point (index 0) has no predecessor, so no meaningful delta can be computed
for it.
Previously, buildSeriesJson() started the loop at i = 0 and guarded the
delta computation with have_previous, emitting [uptime, 0, 0] for the
first sample. This spurious zero point caused several visible problems:
- Hovering over the leftmost column of the stacked packets graph showed
a zero-height bar with a zero tooltip.
- The min/max range display always showed min 0 because the zero point
was included in the range calculation.
- oldest_age_secs (computed via buildPointValue in the first loop) was
anchored to the zero point rather than the oldest valid delta sample.
Fix by restructuring the packets branch in buildSeriesJson: read sample 0
into previous before the loop, then start the loop at i = step. This
eliminates the zero point at the source and removes the per-iteration
have_previous check from the inner loop.
Also change buildPointValue for the packets series to return false
when previous == nullptr, consistent with error_rate. This ensures the
first loop (which computes oldest_age_secs) no longer treats the first
sample as a valid data point. The current-value calculation is unaffected
because it already passes an explicit previous pointer for delta series.
As a side effect, the original loop incremented emitted for the wasted
first iteration, counting it against the emitted < points limit and
causing one fewer actual point to be serialized than requested. The
new loop only increments emitted for real iterations, correcting this
off-by-one.
The packets series is serialized as 3-element points [timestamp, rx, tx]
by buildSeriesJson() since b64d57f, unlike other series which use
[timestamp, value].
The min/max range display in renderTrendResult() was using p[1] for all
series, which for packets only captured the rx delta, ignoring tx.
Fix by selecting the mapper function based on the series key: for
"packets", sum p[1] + p[2] (rx + tx); for all other series, use p[1]
as before. This matches the existing pattern already used in
drawSparkline() (line 1890).
Remove the hardcoded BLE_PIN_CODE build flag from the Heltec_E290_companion_ble
environment. The presence of BLE_PIN_CODE prevents the unit from operating as a
USB companion. Commenting it out allows dynamic PIN generation and enables USB
companion functionality.
Rename arch/esp32/tls_cipher_restrict.c → arch/esp32/tls_server_config.c.
The file now handles two aspects of TLS server configuration via the
existing __wrap_mbedtls_ssl_config_defaults() intercept, so the old name
no longer described its full scope.
Add TLS session ticket support (RFC 5077). Browsers can now resume
HTTPS sessions without a full RSA handshake, reducing connection latency
on repeated connections.
A static mbedtls_ssl_ticket_context is initialized once on first server
start using the ESP32 hardware RNG (esp_fill_random), then registered via
mbedtls_ssl_conf_session_tickets_cb(). The context must be static because
mbedTLS stores the pointer for the lifetime of the server, analogous to
kServerOnlyCipherSuites. If mbedtls_ssl_ticket_setup() fails, the flag
remains unset and session tickets are silently skipped rather than
registering callbacks against an uninitialized context.
CONFIG_MBEDTLS_SSL_SESSION_TICKETS is enabled by default in the pre-built
arduino-esp32 framework, so no build system changes are required beyond
the filename update in platformio.ini.
Add a new command `region bulk` for defining region hierarchies in a single line. This command allows users to create multiple regions in a single message. Updated the documentation to include usage examples and detailed parameter descriptions.
After a successful firmware upload the UI previously showed a static
"Done. Device is rebooting..." message with no indication of progress.
Replace the static message with a live polling loop. A dot is appended
to the message every second as a visual progress indicator. After a
4-second initial delay (to allow the device to start rebooting), the
loop fetches "/" to check whether the HTTPS server is back up. On a
successful response the interval is cleared and the user is redirected
to the login page after 1.5 s (the session token is invalidated by the
reboot). On a network error the fetch is retried every 4 seconds.
A guard value of 1000 prevents a second fetch from being started while
the first is still in flight. If the device does not respond within
60 seconds the loop stops and an error message is shown.
OTA firmware updates previously required starting a separate plain-HTTP
AsyncWebServer on port 80 via ElegantOTA. This meant stopping the
redirect server, spinning up a new server instance, and transmitting
the firmware binary unencrypted without authentication enforced.
Add a POST /api/ota endpoint directly to WebPanelServer. The handler
authenticates via the existing X-Auth-Token session token, resets the
idle timeout via noteActivity(), and streams the request body in
4096-byte chunks into Update.write() (matching the ESP32 flash sector
size). Update.begin() is called with the exact Content-Length so the
library can verify partition space upfront. Update.abort() is called
on any read or write error to leave the device on the current firmware.
On success the handler sends a 200 response and reboots after 1s.
The endpoint runs over the existing HTTPS server on port 443.
Replace the "Start OTA" button in the actions bar with an inline
"Firmware Update" card on the app page. The card contains a .bin file
picker and an upload button. Upload progress is tracked via
XMLHttpRequest's upload.onprogress event and shown with a progress bar.
A 401 response redirects to the login page; other failures re-enable
the file picker so the user can retry without reloading the page.
The old ElegantOTA infrastructure (ESP32Board::startOTAUpdate,
WebService::prepareForOTAStart, the /update redirect handler) is not
yet removed and remains accessible via the `start ota` CLI command.
When hovering over a sparkline chart, the hover point is now drawn
simultaneously on all other trend charts at the same relative time
position. The tooltip, however, is shown only for the chart under
the cursor to avoid visual noise.
Implementation: store _updateHover and _points directly on each
canvas element after binding. On mousemove/touchstart, compute a
normalized position fraction (0..1) and broadcast it to all canvases
inside #statsTrends via querySelectorAll, translating the fraction
to each chart's own point index. On mouseleave/touchend, reset the
hover state on all canvases. The showTooltip parameter added to
updateHover controls whether the tooltip is updated, defaulting to
true to preserve existing behavior.
Swap Memory and Packets cards in the stats dashboard grid so that
Radio and Packets appear together in the middle row, and Wi-Fi and
Memory appear together in the lower row. This groups network-related
stats (Radio/Packets) and puts the less-frequently-needed Memory card
last, improving readability on both desktop and mobile layouts.
Replace the hardcoded 24-city <select id="mqttIata"> with an
<input list="mqttIataList"> + <datalist id="mqttIataList"> pair.
The datalist is populated at runtime from the same conf.json endpoint
used for radio presets, so the city list stays in sync with the API
without requiring firmware updates.
The <input> allows the user to type any IATA code directly, including
codes not present in the preset list. onfocus="this.select()" selects
all text on focus so the user can immediately type to replace the
current value without manually clearing it first.
Disable the Save button in HTML and enable it only when the field is
non-empty. The <input> event handler updates the button state as the
user types so Save is disabled immediately when the field is cleared.
refreshMqttIataWarning() also updates the button state after a Refresh
or Save, ensuring the button is re-enabled when the device value is
loaded.
The warning banner and inline warning are not updated while
the user is typing — the <input> event handler does not call
refreshMqttIataWarning(). The warning only reflects the device's
saved state, updated on Refresh and after a successful Save.
Switch the event listener from "change" to "input" so the Save
button responds to every keystroke rather than only on blur.
Change the radio preset endpoint from https://api.meshcore.nz/api/v1/config
to https://api.vbart.ru/meshcore-firmware/v1/conf.json, which returns
a flat JSON array of city objects:
[{"code":"BSK","name":"Biysk","timezone":"...","settings":{...}}, ...]
Adapt preset parsing to the new schema: pass entry.settings through
normalizeRadioConfig() at the map step so invalid or missing settings
fields are silently filtered out via filter(Boolean). radioSignature()
and formatRadioConfig() are simplified to operate directly on already-
normalized numbers, using JS default number-to-string to avoid trailing
zeros (62.5 instead of 62.500, 869 instead of 869.000).
Rework the preset UX:
- Remove the auto-matching logic that highlighted the current radio in
the dropdown; many cities share identical radio settings so it is
impossible to reliably determine which preset is active.
The dropdown now starts at "Load from preset".
- When a preset is selected, preview it in the "Current Radio" field
(relabelled "Selected radio preset") and show "Click Save to apply".
If the selected preset matches the current radio settings, the Save
button is not activated and the UI is left unchanged.
- After a successful apply, set radioSettingsChanged=true and show a
persistent "reboot required" warning; relabel the field "New radio".
- Disable the Save button (with opacity + cursor feedback) until a
preset is chosen; savebtn:hover suppressed while disabled.
- Reset the preset dropdown when the radio config is refreshed from
the device.
- Update placeholder to "Loading..." to match the new display format.
- Remove now-unused "Preset" label row from the Radio Settings card.
Replace the two separate volatile counters (s_idle_ticks, s_busy_ticks)
with a two-element array s_ticks[2]:
[0] = idle ticks (xTaskGetCurrentTaskHandle() != s_idle_handle → false → 0)
[1] = busy ticks (xTaskGetCurrentTaskHandle() != s_idle_handle → true → 1)
The tick hook becomes a single branchless statement:
s_ticks[xTaskGetCurrentTaskHandle() != s_idle_handle]++;
This eliminates the conditional branch from the tick ISR at the cost
of a few additional instructions in IRAM. On Xtensa LX7 the branch
is somewhat unpredictable — the system alternates between idle and
busy — so removing the misprediction penalty (~5–10 cycles/call)
outweighs the slightly larger code size.
_onSample() is updated to read s_ticks[0] (idle) and s_ticks[1]
(busy) accordingly. All other logic is unchanged.
Previously _onSample() computed and stored a volatile float _sma_avg
on every sample (64 times/minute on core 0). The getter simply returned
that pre-computed value.
This commit inverts the responsibility:
- _sma_sum is now volatile uint16_t — the raw integer sum is the
cross-core shared state, written atomically by core 0 (single S16I
instruction on Xtensa LX7) and read by core 1.
- The float conversion (uint16_t → float multiply) is moved into
getCore0Util(), which is called rarely (once/minute for history,
on-demand for web requests). The multiply now happens on the core
that actually needs the result.
- _sma_avg is removed entirely, saving 4 bytes and one volatile float
store per sample from the hot path.
- The SMA update is rewritten as a single combined expression:
const uint16_t last = _sma_buf[_sma_idx];
_sma_buf[_sma_idx] = s8;
_sma_sum = (_sma_sum - last) + s8;
This produces a single write to _sma_sum instead of two (decrement
then increment), which is cleaner when _sma_sum is volatile.
The cross-core contract is unchanged: core 0 writes _sma_sum once per
sample; core 1 reads it in getCore0Util(). A 16-bit aligned store on
Xtensa LX7 is a single instruction, so no spinlock is needed.
The previous implementation maintained three exponential moving averages
(1/5/15-minute) of CPU busy-fraction, sampled every 5 seconds. This had
two problems:
1. Wrong semantics: the metric was labelled "load_avg" (a Unix concept
measuring run-queue depth), but the tracker actually measures CPU
busy-time as a fraction of FreeRTOS ticks — i.e. utilization, not
load. The name was misleading.
2. Poor responsiveness: the 1-minute EMA (DECAY = exp(-5/60) ≈ 0.920)
has a ~60-second time constant. On a live web panel, this made the
metric appear frozen, especially during short bursts of activity
(e.g. an HTTP request from the panel itself).
This commit replaces the EMA with a 64-sample simple moving average
(SMA) over a compact uint8_t circular buffer. The sample interval is
60 s / 64 = 937,500 µs, so the window covers exactly 60 seconds:
Algorithm:
- Every 937,500 µs (= 60 s / 64), the FreeRTOS tick delta (busy vs
idle ticks on core 0) is computed and stored as a uint8_t
(0–255 = 0–100% utilization).
- The circular buffer holds 64 samples, spanning exactly 60 seconds
(64 × 937,500 µs = 60,000,000 µs). The timer interval is derived
directly from the window size: 60 000 000 / SMA_WINDOW µs.
- The index wraps with a bitwise AND (& 63) instead of modulo, since
64 is a power of 2.
- The running sum is a uint16_t (max 64 × 255 = 16 320, fits easily).
- The result _sma_avg is a volatile float written atomically by the
esp_timer task (core 0) and read from the main loop (core 1).
No spinlock is needed: on Xtensa LX7, a 32-bit aligned float store
is a single instruction.
The 60-second window smooths out short bursts (e.g. WiFi/HTTP spikes)
while reacting to sustained load changes within ~10–15 seconds.
Naming:
- The public API is now getCore0Util() returning a float in [0.0, 1.0].
The name explicitly identifies which core is measured (core 0, which
runs WiFi, MQTT, HTTP, and LwIP — see task_pinning.c).
- JSON keys: "load_avg" (array) → "core0_util" (scalar float, percent)
- HistorySample field: load_avg1_pct → core0_util_pct
- StatsHistory series key: "cpu_load" → "core0_util"
- Web panel label: "Load Avg" → "Core0 Util"
The history snapshot (once per minute) reads the same _sma_avg value,
which at that point represents the rolling average of the last 60
seconds — exactly one history interval.
Apply consistent number-unit spacing and helper usage:
- Add a space before "%" in all percentage labels (Battery,
Signal Quality, TX Share, RX/TX Flood Share, RX Error Rate,
Archive Free), matching the existing style of other units
such as " dBm" and " dB".
- Replace raw String(x) + " s" with formatDuration() for RX Air,
TX Air, and Total Air in the Radio card, matching the style
already used for uptime and event age.
Reorder the four air-time metrics in the Radio card:
Before: TX Air | RX Air
TX Share | Total Air
After: RX Air | TX Air
Total Air | TX Share
Move RX Air to the primary (top-left) position — a repeater
receives before it retransmits, so RX is the primary signal.
Move TX Share after Total Air so the derived percentage follows
the raw counters it summarises.
Add a compact "min X / max Y" label to the right of the current value
on each trend card in the stats panel. The range is computed client-side
from the points array already present in the API response, so no backend
changes are required.
CSS: add .trend-info (flex:1 wrapper) and .trend-range (small muted
label) classes; make .trend-value a flex row with space-between so the
range label is pushed to the right edge.
JS: refactor formatTrendValue to use a module-level kTrendFmt lookup
table instead of a chain of if-statements, and add a withUnit parameter
(default true) so the same function can produce unit-free numbers for
the range display. Populate the new head-range-<key> span in
renderTrendResult after the existing axis-label block.
Wire the 1-minute CPU load average into the stats history pipeline:
- Rename HistorySample::reserved to load_avg1_pct (uint8_t, 0-100);
struct size unchanged.
- Populate load_avg1_pct in updateStatsHistory on ESP32 from
_cpu_tracker.getLoadAvg1() * 100.
- Register "cpu_load" series in buildPointValue / seriesTitle /
seriesUnit so buildSeriesJson serves it via
/api/stats?series=cpu_load.
- Persist load_avg1_pct as a 29th CSV column in flushSummaryLog.
parseSummaryLine accepts both 28-column (old, load_avg1_pct=0)
and 29-column (new) formats for backward compatibility.
- Add "cpu_load" to the web panel trend card order with orange
sparkline color (#e07b39), "N %" hover formatting, and a Y-axis
floored at 0 with a 10% minimum ceiling.