Графік комітів
100 Коміти
Автор SHA1 Повідомлення Дата
Valentin V. BartenevтаGitHub 17689e4d1d Mention LNA on/off in README.md
Build and deploy Docs site to GitHub Pages / github-pages (push) Waiting to run
Build and deploy Docs site to GitHub Pages / github-pages (push) Waiting to run
Run Unit Tests / test (push) Waiting to run
2026-06-07 20:31:02 +03:00
Valentin V. Bartenev 1817248d97 Add LoRa FEM RX-path LNA control via CLI; restore enabled default
Ported from meshcore-dev/MeshCore#2140 (Quency-D <hj_zzns@163.com>).

The upstream v1.16.0 merge changed the Heltec FEM LNA to disabled by
default (upstream commit 696aae6e).  This patch restores the enabled
default for repeater via the new "radio.fem.rxgain" preference
(defaulted to 1), while also making the setting user-configurable
and persisted across reboots.
2026-06-07 15:36:19 +03:00
Valentin V. Bartenev d58311ce3c Revert on-disk layout to the one before 1.16.0 merge
When "flood_max_unscoped" and "flood_max_advert" were inserted at offsets
291-292 in a previous commit, they displaced "fan_mode" and "fan_timeout_secs"
from their established positions.  On upgrade, devices with existing saved
prefs would load the old "fan_mode" byte (0, 1, or 2) into "flood_max_unscoped"
and the low byte of "fan_timeout_secs" into "flood_max_advert", both yielding
values of 0 or 1.  A "flood_max_unscoped" or "flood_max_advert" of 0 or 1
effectively silences the repeater, causing it to drop nearly all unscoped
flood and advert packets.  Additionally, "rx_boosted_gain" was displaced to
offset 291 in the new layout, causing it to read 0 (gain disabled) on devices
whose prefs were saved before the layout change.

Restore a correct, upgrade-safe layout:

  290: reserved_290  (placeholder; preserves the byte that held
                      rx_boosted_gain in the layout before this series)
  291: rx_boosted_gain
  292: fan_mode
  293-294: fan_timeout_secs  (uint16_t)
  295: flood_max_unscoped
  296: flood_max_advert

"flood_max_unscoped" and "flood_max_advert" are moved to the end so that
existing saved prefs with "fan_mode"/"fan_timeout_secs" at 291-294 are
read correctly on upgrade, and the repeater resumes normal flood forwarding
behaviour.

Also fix a copy-paste bug in loadPrefsInt where the availability
checks for "flood_max_unscoped" and "flood_max_advert" both used
sizeof(rx_boosted_gain) instead of their own field sizes.
2026-06-07 14:51:02 +03:00
Valentin V. Bartenev 6aef34567e Merge official v1.16.0 firmware and resolve conflicts 2026-06-06 21:55:43 +03:00
Valentin V. Bartenev 7635634f87 Restore original docs and workflows to avoid conflicts while merging 2026-06-06 20:50:05 +03:00
Valentin V. Bartenev ae60d86207 Adjust image paths in README after the docs move 2026-06-06 20:20:49 +03:00
Valentin V. Bartenev b9bd0fc270 Moved docs and workflows to avoid conflicts while merging official repo 2026-06-06 20:18:03 +03:00
Valentin V. Bartenev 86154ce8d0 Fix intermittent RadioLib static SPI buffer overflow
When RADIOLIB_STATIC_ONLY=1 is set, RadioLib's SPItransferStream()
allocates two fixed-size stack buffers (buffOut and buffIn) of
RADIOLIB_STATIC_ARRAY_SIZE bytes each, instead of heap-allocating
exactly the right size.

The default value of RADIOLIB_STATIC_ARRAY_SIZE is 256.  When receiving
a maximum-size LoRa packet (255 bytes, equal to MAX_TRANS_UNIT),
SX126x::readBuffer() passes a 3-byte SPI command header
(CMD_READ_BUFFER + offset + NOP) plus 255 bytes of payload to
SPItransferStream(), for a total buffLen of 258 bytes.  This overflows
the 256-byte stack buffers by 2 bytes, corrupting adjacent locals and
occasionally the stack canary, triggering __stack_chk_fail.

The same overflow occurs on the transmit path: SX126x::writeBuffer()
passes a 2-byte command header plus 255 bytes of payload (buffLen=257),
also overflowing the 256-byte buffer.

The overflow is small (2 bytes on the read path, 1 byte on the write
path), so it only intermittently reaches the stack canary depending on
compiler-generated stack frame layout.

Set RADIOLIB_STATIC_ARRAY_SIZE=260 to eliminate the overflow on both
paths, with 2 bytes of margin on the read path (258 < 260) and 3 bytes
on the write path (257 < 260). The value is placed in [arduino_base] so
it applies to all target platforms.
2026-06-06 03:49:15 +03:00
Valentin V. Bartenev 7804546434 Web: add eventLabel helper and show low_memory free heap in events panel
Extract eventLabel(event) to centralize per-event label formatting in
the web panel events history.  The helper replaces the inline ternary
that was previously inlined in renderEventsSection().

Add a low_memory case to eventLabel: when a low_memory event carries a
non-null value (free heap in KB at the time of the event), the label
is rendered as "low memory (N KB)".  The value is already emitted
by appendJsonEvents and stored as free_heap/1024 by recordStatsEvent().

The boot case is unchanged: boot events with a non-null value continue
to display the reset reason string via bootReasonLabel().

renderEventsSection() is simplified back to a plain template literal
now that label construction is delegated to eventLabel().
2026-05-31 02:56:37 +03:00
Valentin V. Bartenev 9c8f884f11 ESP32: add reset reason to boot event, CLI, and web panel
Implement getResetReason() and getResetReasonString() for ESP32Board,
covering all esp_reset_reason_t values: power-on, external reset,
software reset, panic/exception, interrupt watchdog, task watchdog,
generic watchdog, deep sleep wake, brownout, and SDIO reset.

Extend the pwrmgt.bootreason CLI command to support ESP32 alongside
NRF52. On ESP32 only the reset reason is reported (no shutdown reason).

Pass the reset reason as the value field when recording the
HISTORY_EVENT_BOOT stats event so the reason is stored in the in-memory
ring buffer and persisted to the archive events log.

Add bootReasonLabel() to the web panel and update renderEventsSection()
to annotate boot events with the human-readable reset reason string,
e.g. "boot (panic/exception)", when a non-null value is present.
2026-05-31 02:41:57 +03:00
Valentin V. Bartenev e11d14e6cc Fix: expand HistorySample::recv_errors from uint16_t to uint32_t
The error_rate graph showed all zeros after ~65535 receive errors had
accumulated.  Root cause was a two-part bug:

1. In MyMesh::updateStatsHistory(), recv_errors was clamped to 0xFFFF
   via min<uint32_t>(..., 0xFFFF).  Once the counter reached 65535,
   every subsequent sample stored the same constant value, making the
   per-interval delta always zero and therefore error_rate always zero.

2. HistorySample::recv_errors was declared as uint16_t while the
   underlying counter (n_recv_errors) is uint32_t.  parseSummaryLine()
   also truncated the restored value back to uint16_t.

Fix:
- Remove the clamp in MyMesh::updateStatsHistory(); assign
  getPacketsRecvErrors() (uint32_t) directly.
- Change HistorySample::recv_errors from uint16_t to uint32_t,
  moving it next to the other uint32_t fields.
- Update both parseSummaryLine() code paths to cast recv_errors to
  uint32_t instead of uint16_t.

The archive format is unchanged: recv_errors is written as %u and
read back into an unsigned local, which is correct for a 32-bit
value on ESP32.
2026-05-20 19:53:12 +03:00
Valentin V. Bartenev 6a3a29f429 NetworkService: use NTP server from DHCP Option 42 when available
Call sntp_servermode_dhcp(1) before WiFi.begin() so that lwIP's DHCP
client passes any NTP server address received in DHCP Option 42 to the
SNTP client (slot 0).

Replace configTzTime() with explicit setenv/tzset + sntp_setservername
+ sntp_init() to avoid overwriting the DHCP-provided server.  Static
fallback servers (0.ru.pool.ntp.org, ntp.ix.ru, ntp21.vniiftri.ru) are
placed in slots 1-2 when DHCP provides a server, or slots 0-2 otherwise.

This relies on CONFIG_LWIP_DHCP_GET_NTP_SRV=y, which is already enabled
in the pre-built Arduino ESP32 core (framework-arduinoespressif32).

Add debug logging of the initialised NTP server list, guarded by
WIFI_DEBUG_LOGGING.
2026-05-18 01:12:19 +03:00
Valentin V. Bartenev 8a0d04fa86 Web: add meshcoretel.ru profile link button next to public key
Adds a hidden `ℹ` button (meshcoretelProfileBtn) in the Public Key
field of the Info panel.  After initApp() finishes loading the
"Loading info..." section, the public key value is read from the
DOM and validated against /^[0-9A-F]{64}$/.  If it passes, an onclick
handler is attached that opens https://meshcoretel.ru/<PUBLIC_KEY>
in a new tab, and the button is made visible.  The button remains
hidden if the key fails to load or does not match the expected 64-char
uppercase hex pattern, so the UI degrades gracefully on any firmware
that does not return a valid public key.

The container div is changed from fieldline (2-column grid: 1fr auto)
to inline-actions (3-column grid: minmax(0,1fr) auto auto) to accommodate
the second action button without wrapping.
2026-05-17 15:40:10 +03:00
Valentin V. Bartenev 7378666416 Web: link neighbour IDs to meshcoretel.ru profile pages
renderNeighbourId in the /stats neighbours table now checks for the
"full_id" field emitted by appendJsonNeighbours().  If the value is a
valid 64-character hex string (the node's full 32-byte public key),
the short ID is wrapped in an anchor tag pointing to
https://meshcoretel.ru/<FULL_ID_UPPERCASE> (target="_blank",
rel="noopener noreferrer").  Falls back to plain short ID when
"full_id" is absent or does not match the expected pattern.
2026-05-17 06:11:52 +03:00
Valentin V. Bartenev 88517878d6 Docs: update according to changes in the new versions
web-panel.md:
- Replace Start OTA nav item with dedicated Firmware Update section
- Update Info panel: Version/Client Version → Hardware/Firmware
- Add auto-refresh toggle to /stats element list
- Expand Trends list: add error_rate, core0_util, mcu_temp,
  note RX/TX stacked rendering of packets series
- Update sequential trend loading order accordingly
- Rewrite OTA subsection to describe native HTTPS upload flow

api.md:
- Add error_rate and core0_util to supported series list

boards.md:
- Add DIY_ESP32S3_N16R8_E22_Back2back to repeater_mqtt table

local-builds.md:
- Add DIY_ESP32S3_N16R8_E22_Back2back_repeater_mqtt
- Add Ebyte_EoRa-S3_Repeater_mqtt
- Add ThinkNode_M2_Repeater_mqtt
- Add ThinkNode_M5_Repeater_mqtt
2026-05-16 22:15:15 +03:00
Valentin V. Bartenev 0005731c45 Web: fix bar chart hover index for edge columns
The hover index in syncHoverByX was computed as:

  Math.round(x / width * (n - 1))

which linearly maps the full canvas width [0, width] to indices
[0, n-1].  For bar charts (packets, gps_satellites) the bars are
drawn in a plot area with plotLeft=4 and plotRight=width-4 margins,
so bar centers are at plotLeft + slotWidth*i + slotWidth/2.  This
caused the first and last bars to require the cursor to be at the
very edge of the canvas to highlight correctly, while center bars
appeared fine.

For bar chart keys, use a slot-based formula instead:

  Math.floor((x - plotLeft) / slotWidth)

which maps the cursor to whichever slot it falls in, matching the
actual bar layout.  Line chart keys retain the original formula.
2026-05-16 16:58:16 +03:00
Valentin V. Bartenev d5d4311e0e Web: sync graph hover by timestamp instead of relative position
When hovering over a sparkline, the same X fraction was applied to
all other canvases to compute the highlighted point index.  Because
different series can have different numbers of valid samples (some
values are filtered out), the same fraction mapped to different
indices and therefore different timestamps on each graph.

Fix by reading the uptime_secs timestamp (point[0]) of the hovered
point on the source canvas and finding the closest-timestamp point
in each other series, instead of using the fractional position.

Also de-duplicate the near-identical onmousemove/ontouchstart and
onmouseleave/ontouchend handlers into syncHoverByX() and
syncHoverClear() helpers, reducing the block from 34 lines to 16.
2026-05-16 16:28:04 +03:00
Valentin V. Bartenev 7f17c3aeab Web: fix bogus packets/error_rate delta at SD archive restore boundary
restoreSummaryLog() seeds the ring buffer with one sample from the
previous session (summary.latest).  The next live sample has reset
counters and uptime starting from zero, so the delta across that
boundary is meaningless.

For the packets series this produced a zero point (same symptom as the
first-sample zero fixed in 1018404b, but at the restore boundary instead).
For error_rate it produced an inflated value because the counter difference
wraps around.

Fix by skipping any sample whose uptime_secs does not exceed the
previous sample's uptime_secs.  In buildPointValue this returns false;
in the buildSeriesJson packets loop it skips emission and does not
advance previous.

Also: move have_previous = false into the else branch (the packets
branch does not use it), drop the now-redundant >= guards in favour of
direct uint32_t subtraction, and replace a C-style cast with
static_cast<int> for consistency.
2026-05-16 15:29:11 +03:00
Valentin V. Bartenev 43d0e549fb Web: fix hover effect on disabled buttons
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.
2026-05-16 02:20:34 +03:00
Valentin V. Bartenev 59f6df50c5 Web: replace unreliable post-OTA device poll with a countdown timer
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.
2026-05-16 01:51:06 +03:00
Valentin V. Bartenev 1018404b82 Web: fix packets series emitting a spurious zero point at the start
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.
2026-05-15 23:00:15 +03:00
Valentin V. Bartenev 21e396366c Web: fix trend min/max range to include both rx and tx for packets series
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).
2026-05-15 21:49:33 +03:00
Valentin V. Bartenev 89a59d5a2e Web: enable TLS session tickets for faster session resumption
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.
2026-05-13 04:48:15 +03:00
Valentin V. BartenevтаGitHub 89df9eb9a0 Update README.md with new features 2026-05-11 13:53:31 +03:00
Valentin V. Bartenev 34cadd3a8e Web: poll for device reboot after OTA upload
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.
2026-05-11 10:52:48 +03:00
Valentin V. Bartenev ac33787de5 Web: replace ElegantOTA UI with native HTTPS firmware upload
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.
2026-05-11 09:18:12 +03:00
Valentin V. Bartenev 3a81dcf69b Web: synchronize sparkline hover highlight across all trend charts
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.
2026-05-11 07:16:01 +03:00
Valentin V. Bartenev 5642259bee Web: reorder stats dashboard cards (Radio+Packets, Wi-Fi+Memory)
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.
2026-05-11 04:08:37 +03:00
Valentin V. Bartenev 4dc5bdf8cf Web: rework MQTT IATA field
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.
2026-05-11 04:00:41 +03:00
Valentin V. Bartenev fbab148382 Web: rework preset UX
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.
2026-05-11 01:01:02 +03:00
Valentin V. Bartenev 72fc497980 Slightly optimize CPU util tracking by branchless tick hook
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.
2026-05-10 10:02:21 +03:00
Valentin V. Bartenev 482b31bc19 Slightly optimize CPU util tracking by defer float conversion
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.
2026-05-10 07:09:03 +03:00
Valentin V. Bartenev a9273f02bb Redesign approach for CPU usage tracking
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.
2026-05-10 06:26:08 +03:00
Valentin V. Bartenev 5bf017be1b Web: normalize unit formatting across all HUD cards
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.
2026-05-10 00:09:08 +03:00
Valentin V. Bartenev 86bf8dcb5c Web: reorder Radio HUD air-time metrics (RX first, TX Share last)
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.
2026-05-09 23:56:48 +03:00
Valentin V. Bartenev d5fa2466d7 Web: show min/max range on trend cards
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.
2026-05-09 23:50:59 +03:00
Valentin V. Bartenev eaf3c4c263 Stats: add CPU load trend series to history and web panel
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.
2026-05-09 22:31:02 +03:00
Valentin V. Bartenev bbb07b65ca Web: stale stats indicator in Stats header
Add a blinking "refreshed >Xm ago" label aligned to the right of the
Stats page h2 header.  The label is hidden until stats are more than
1 minute old, then appears with a CSS color-pulse animation (red to
--text-muted) to draw attention. It hides immediately when a refresh
is initiated and reappears only after the next successful fetch.

- Add @keyframes blink-stale (color pulse red <-> --text-muted) and
  .stats-stale CSS class (13px, normal weight, 1.5s animation)
- Add statsLastUpdated span inside the Stats h2 (flex, right-aligned,
  initially hidden)
- statsLastFetchedAt: null until first successful fetch, reset to null
  at the start of each loadStatsPage call, set to Date.now() on success
- updateStatsLastUpdated() hides span when null or secs < 60, shows
  minutes/hours elapsed otherwise
- setInterval(updateStatsLastUpdated, 10000) runs only on /stats page
2026-05-09 21:23:40 +03:00
Valentin V. Bartenev 55f21997aa Web: show RX metrics before TX in Packets HUD card
For a mesh repeater, a packet must be received before it can be
retransmitted, so RX metrics are the primary signal. Reorder the
Packets card to reflect this:

- Swap meter order: RX Flood Share before TX Flood Share
- Swap metric order: Recv before Sent, RX Direct before TX Direct
- Reorder floodTx/directTx declarations to match the new order
2026-05-09 20:12:03 +03:00
Valentin V. Bartenev b64d57f6a4 Web: split "packets" series into separate RX/TX components
The "packets" trend series previously emitted a single combined
packet-count delta per interval. This change breaks it into two
separate values so the sparkline can show directional traffic.

Backend (StatsHistory::buildSeriesJson):
- Add a dedicated branch for series == "packets" in the second
  (JSON-emission) loop.
- Each point is now emitted as [uptime_secs, rx_delta, tx_delta]
  instead of [uptime_secs, total_delta].
- rx_delta / tx_delta are computed as non-wrapping uint32_t
  differences of packets_recv / packets_sent between consecutive
  samples, consistent with the existing buildPointValue logic.
- Buffer overflow guard updated from +24 to +40 bytes to cover
  the wider three-integer format.

Frontend (WebPanelServer / inline JS):
- drawSparkline: for "packets", compute Y scale and bar height
  from rx+tx total; render a stacked bar with TX on top (cyan,
  #06b6d4 / hover #22d3ee) and RX below (violet, #8b5cf6 /
  hover #a78bfa), proportioned by the rx:tx ratio.
- bindSparkHover tooltip: show "RX: N  TX: M" for "packets"
  instead of the generic formatTrendValue output.
- Remove the now-unused sparkStrokeColor early-return for
  "packets" (stroke color is not used in the bar path).
2026-05-09 19:58:36 +03:00
Valentin V. Bartenev c3ab904361 Web: add RX error rate sparkline and HUD meter
Backend (StatsHistory.cpp):
- buildPointValue: add "error_rate" series — computes per-interval
  receive error rate as (d_errors * 1000) / (d_errors + d_recv),
  yielding a per-mille value (0–1000); returns false when no previous
  sample is available or the interval had zero traffic
- seriesTitle: register "error_rate" → "Error Rate"
- seriesUnit: register "error_rate" → "per_mille"
- buildSeriesJson: extend the packets delta-series special case to
  also cover "error_rate" so the current-value field is populated
  correctly instead of being emitted as null

Frontend (WebPanelServer.cpp):
- renderMeter: suppress the hud-sub div when note is empty to avoid
  blank layout space
- renderPacketsCard: compute recv_errors / (recv + recv_errors) as
  errorRatePct; display it as a dedicated "RX Error Rate" meter with
  an errors/attempts subtitle; lay out TX and RX flood-share meters
  side-by-side in a two-column grid to make room
- sparkline pipeline: register "error_rate" throughout
    - sparkFormatValue:  (value / 10).toFixed(1) + " %" (per-mille → %)
    - sparkStrokeColor:  fixed red (#ef4444)
    - sparkValueRange:   fixed 0–1000
    - sparkBands:        green 0–20 %, yellow 20–45 %, red 45–100 %
- summary panel order: battery → memory → packets → error_rate →
  signal → noise_floor (packet throughput and error rate grouped)
2026-05-09 19:45:36 +03:00
Valentin V. Bartenev 3854abfd50 Web: add MCU temperature sparkline with thermal threshold coloring
Add visual indicators for the mcu_temp trend series on the /stats page.
Values are stored as deci-°C (tenths of a degree), so all threshold
constants are in that unit (750 = 75 °C, 950 = 95 °C).

Threshold rationale (ESP32-S3 / Heltec V4, internal die temperature,
T_j max = 105 °C per datasheet):
  - < 75 °C  (< 750):   normal, green — comfortable operating range
  - 75–95 °C (750–950): elevated, amber — die is running hot,
    30 °C below the junction temperature limit
  - > 95 °C  (> 950):   critical, red — 10 °C below the 105 °C
    junction limit; sensor accuracy is ±2 °C in this range
  - Chart upper bound 125 °C (1250) covers the full sensor measurement
    range with headroom

Changes:
- sparkStrokeColor: color the mcu_temp stroke dynamically based on
  the most recent data point (green / amber / red)
- sparkGuideValues: draw horizontal guide lines at 75 °C and 95 °C
- sparkBands: shade three background bands (normal / high / critical)
- getTrendSeriesOrder: insert "mcu_temp" at position 2 (after battery
  and memory, before RF metrics) when sensors.mcu_temp_c is present
  in the summary payload
2026-05-09 19:29:43 +03:00
Valentin V. Bartenev bb97a0400e Web: open OTA update page automatically after starting OTA mode
Add a /update endpoint to the HTTPS web panel server that issues a
302 redirect to the OTA update server (http://<host>/update, port 80).
When the user confirms the OTA prompt in the web panel, the browser
now opens the update page in a new tab automatically instead of
requiring manual navigation.  The redirect also helps users who
manually navigate to https://<host>/update and forget to switch to
http — they are transparently forwarded to the correct URL.

The JS handler opens a relative URL (window.location.origin + "/update")
rather than a hardcoded device IP, so it works correctly when the device
is behind a reverse proxy.  Similarly, handleOtaRedirect builds the
redirect target from the request's Host header rather than WiFi.localIP(),
preserving the proxy hostname end-to-end.

The new tab is opened only after runCommand("start ota") returns a
successful response, meaning the OTA server is already up and listening
on port 80 by the time the browser navigates to /update — no race
condition or polling needed.

The /update endpoint returns 404 until notifyOtaStarted() is called,
ensuring the redirect is only served once OTA mode is actually active.

- Add _ota_started flag to WebPanelServer, initialized to false;
  set to true via notifyOtaStarted()
- Register GET /update route; returns 302 to http://<host>/update
  only after OTA has been started (404 otherwise)
- Call notifyOtaStarted() from WebService::prepareForOTAStart() after
  stopping the HTTP redirect server, freeing port 80 for the OTA server
- Update OTA button JS handler to open window.location.origin+"/update"
  in a new tab on successful "start ota" command response
2026-05-09 18:28:48 +03:00
Valentin V. Bartenev 05ff5fccdb Hide mqttBrokerWarning with display:none to remove surplus space
The .panel-warning class carries min-height:1.4em, so clearing its
textContent left an empty element at the bottom of the MQTT Settings
section.

Move the warning string into the HTML as static content and switch
refreshMqttIataWarning() to toggle style.display instead of textContent.
When no warning is needed the element is removed from layout entirely,
eliminating the extra vertical gap.
2026-05-09 18:02:37 +03:00
Valentin V. Bartenev c1b9a5018d Web: clear CLI input after Enter and remove misleading placeholder
The Enter key handler in the CLI panel now awaits runCommand() directly
instead of simulating a button click, and clears the input field once
the command has been dispatched. This makes keyboard-driven use more
comfortable: the field is ready for the next command without manual
clearing.

The placeholder text "get mqtt.status" is removed from the command
input. Its color was nearly identical to regular input text, making the
field appear non-empty even when cleared, which was confusing. The
Quick "get" Commands panel already provides one-click buttons for all
common read-only queries, so the hint was redundant in any case.

The Run button onclick path is intentionally unchanged: the command
stays in the field after execution, allowing fast repeat by clicking
Run again without retyping.
2026-05-09 17:56:08 +03:00
Valentin V. Bartenev cd6efec324 Web: add auto-refresh toggle to stats page
Add a split-button next to the Refresh button on the stats page.
The right half (↻) toggles a 60-second auto-refresh countdown.
While active, the button shows the remaining seconds and fires
loadStatsPage() each time the counter reaches zero, then resets.
A manual Refresh while the timer is running resets the countdown
display to keep it in sync.

The syncNavButton helper is simplified: visibility is now toggled
on the whole split-group container instead of the individual button,
and the manual gridTemplateColumns override is removed because the
flex split-group handles its own layout.

CSS: introduce .btn-split, .btn-split button:first-child, and
.btn-split button:last-child rules for the joined-button appearance.
2026-05-09 17:48:06 +03:00
Valentin V. Bartenev d49af5d785 Web: bring theme toggle to the login page for consistent UX
The app and stats pages already use an explicit data-theme attribute
approach — reading the saved "repeater-theme" key from localStorage
and falling back to the OS preference — with a ☾/☀ toggle button in
the UI.  The login page was the only page still relying solely on the
CSS @media (prefers-color-scheme) query with no manual override.

This commit mirrors the same pattern on the login page so the theme
switcher works consistently across all pages:

- Add --surface2 CSS variable to both light and dark theme blocks
- Replace @media (prefers-color-scheme: dark) with :root[data-theme="dark"]
- Add a theme toggle button (☾/☀) in the login card header
- Add getPreferredTheme(), applyTheme(), and toggleTheme() helpers,
  identical in behaviour to those already present on the app page
2026-05-09 17:40:51 +03:00
Valentin V. Bartenev 34192fa884 Web: rework Info panel to show Hardware instead of Client Version
Replace the two Info panel fields so they surface more useful
device identity information on login:

- First field: renamed from "Version" (id: versionValue, cmd: ver)
  to "Hardware" (id: hardware, cmd: board), showing the board
  identifier rather than the firmware version string.

- Second field: renamed from "Client Version" (id: clientVersionValue,
  cmd: get mqtt.client_version) to "Firmware" (id: firmware, cmd: ver),
  showing the firmware version string.

"Client Version" was a static MQTT protocol identifier string used
internally in MQTT status payloads published to brokers.  It tracks
the firmware build but carries no additinal information for a
user.  It remains accessible via the Quick "get" Commands panel
(get mqtt.client_version).
2026-05-09 17:35:50 +03:00
Valentin V. Bartenev a6f01d544e ESP32: add CPUUsageTracker – core-0 load averages (1/5/15 min)
Track CPU utilisation on core 0 using a FreeRTOS tick hook that
increments per-tick idle/busy counters, sampled every 5s by an
esp_timer callback.  Exponential moving averages with Linux-style
time constants (1 / 5 / 15 min) are maintained in software:

  DECAY1  = exp(-5/60)  ≈ 0.9200  (1-minute window)
  DECAY5  = exp(-5/300) ≈ 0.9835  (5-minute window)
  DECAY15 = exp(-5/900) ≈ 0.9945  (15-minute window)

Core 1 is excluded intentionally: it runs the Arduino loopTask at
100% load for LoRa packet processing, so its figure is always 1.0
and carries no diagnostic value.  All other tasks are pinned to
core 0 by task_pinning.c, so core-0 load reflects the true system
utilisation.

New files:
  arch/esp32/CPUUsageTracker.h   – class declaration
  arch/esp32/CPUUsageTracker.cpp – tick hook + esp_timer sampling

Integration:
  MyMesh::begin() calls _cpu_tracker.begin() on ESP32 builds.
  formatStatsReply() emits "load_avg":[<1m>,<5m>,<15m>] in the
  compact stats JSON payload.
  formatWebStatsSummaryJson() adds the same field to the web-panel
  stats endpoint under core.load_avg.
  The web-panel HUD gains a "Load Avg" metric tile showing all
  three values side-by-side; the core-metrics grid is widened from
  4 to 5 columns.  The tile is rendered conditionally so older
  firmware (or non-ESP32 builds) that omit the field degrade
  gracefully.
  CPUUsageTracker.cpp is added to the esp32_base build_src_filter
  in platformio.ini.
2026-05-09 17:26:27 +03:00
Valentin V. Bartenev ce510738d5 Web: delay WiFi shutdown and reorder stop logs
This fixes accidental crash when disabling the web panel without an
active MQTT uplink.

When the web panel was disabled, isWebEnabled() immediately returned
false.  If no other network consumer was active, network.loop(false)
tore down WiFi *before* web.loop() stopped the httpd server.

Calling httpd_stop() while WiFi was being freed sometimes caused a
LoadProhibited panic (ieee80211_output_do -> wifi_free).  This only
occurred when the server was stopped after WiFi teardown began.

Fix by including _panel.isRunning() in isWebEnabled(): WiFi stays up
for one extra iteration, so the server is stopped while the stack is
still valid.  Only on the next loop cycle does the network shut down.

Additionally, move the "server stopped" and "redirect server stopped"
log messages to after the actual httpd_stop() / httpd_ssl_stop()
calls.  This prevents a misleading "stopped" message if a crash
happens during teardown.
2026-05-08 20:22:18 +03:00
Valentin V. Bartenev 47f41847ac Improve OTA behaviour when WiFi is already connected
When startOTAUpdate() was called while the device was already connected
as a STA (web panel or MQTT active), it would bring up an open
"MeshCore-OTA" AP alongside the existing connection and report its IP
(softAPIP, typically 192.168.4.1) in the reply.  The OTA AsyncWebServer
also listens on the STA interface, so the update page was reachable via
the existing network IP, but that address was not reported.

Two problems with the old behaviour:
- Raising an open AP while a STA connection is active is a security
  concern.
- The reported AP address is not useful to a user who is already on the
  same network as the device; they would naturally try the STA IP.

New behaviour:
- If the device is already connected as a STA (WiFi.status() ==
  WL_CONNECTED), skip softAP() entirely and report WiFi.localIP() in
  the reply.  The OTA server is already reachable on that address.
- If there is no STA connection, keep the original behaviour: bring up
  the "MeshCore-OTA" AP and report softAPIP().

In both cases the reply now includes the network name so the user knows
which interface to connect to:
  STA: "Started: http://<ip>/update (WiFi: <ssid>)"
  AP:  "Started: http://<ip>/update (AP: MeshCore-OTA)"

If a user specifically wants to perform OTA via the dedicated AP, they
can disable the current WiFi interface first, after which startOTAUpdate
will fall into the AP path as before.
2026-05-08 03:49:35 +03:00
Valentin V. Bartenev 918c839e09 Web: fix crash/deadlock when disabling web panel via HTTP command
Calling `set web off` through the web panel caused the device to either
hang permanently or crash with a Guru Meditation Error (LoadProhibited).

Root cause: the HTTP command handler runs inside the httpd task and
synchronously called `setWebEnabled(false)`, which called
`_panel.stop()` -> `httpd_ssl_stop()` on the very connection that was
serving the request. This is unsafe in two ways:

1. `httpd_ssl_stop()` blocks waiting for the httpd task to finish, but
   the httpd task is the one executing the handler — a self-deadlock.

2. Even if the stop proceeds, closing the active TLS connection from
   within its own handler triggers lwIP teardown (esp_netif_down_api ->
   dhcp_stop -> TCP RST) while the WiFi driver is in an inconsistent
   state, causing a null-pointer dereference in ieee80211_output_do.

The deadlock was further compounded by a second concurrent call:
while the httpd task was stuck in httpd_ssl_stop(), the main loop kept
running, called ensureWebServer(), saw _server != nullptr (never cleared
because httpd_ssl_stop() never returned), and issued a second
httpd_ssl_stop() on the same handle — blocking the main loop as well
and stopping all radio packet processing until reboot.

The explicit start/stop calls in setWebEnabled() were redundant:
WebService::loop() already calls ensureWebServer() on every iteration,
which starts the server when enabled and WiFi is up, and stops it
otherwise.  Remove the block entirely and let loop() handle both
transitions safely from the main loop context, outside the httpd task.
2026-05-08 02:28:19 +03:00
Valentin V. Bartenev de978414ce Fix: remove redundant stopRedirectServer() call from ensureWebServer()
When the "start ota" command was issued from the web panel, a race
condition could occur between the main loop thread and the HTTP server
thread:

- HTTP thread: prepareForOTAStart() -> stopRedirectServer() ->
  httpd_stop(_redirect_server) [blocking]
- Main thread: loop() -> ensureWebServer() -> stopRedirectServer() ->
  httpd_stop(_redirect_server) [double free!]

Because stopRedirectServer() sets _redirect_server = nullptr only after
httpd_stop() returns, both threads could pass the nullptr check
simultaneously, resulting in a double free and heap corruption:

  CORRUPT HEAP: Bad head at 0x3fcb1f24. Expected 0xabba1234 got 0x3fca5f34
  assert failed: multi_heap_free multi_heap_poisoning.c:259 (head != NULL)

The call in ensureWebServer() was added as a safety net in commit
4b83142b, but is redundant: prepareForOTAStart() already calls
stopRedirectServer() synchronously before startOTAUpdate() occupies
port 80. Removing it eliminates the race condition.

The bug only manifested when OTA was triggered from the web panel
(HTTP thread), not from radio (main thread), because in the latter
case both calls happen on the same thread and cannot race.
2026-05-08 01:38:09 +03:00
Valentin V. Bartenev efce5856d8 Web: refactor sendWhole loop to eliminate per-iteration branch
The previous while loop computed a ternary `chunk_len` on every
iteration to handle the final partial chunk as a special case, and
called vTaskDelay(1) after every chunk including the last one,
adding an unnecessary yield immediately before the terminating null
chunk.

Restructure the loop using the identity:

  last_size = ((len - 1) % kWebPageChunkSize) + 1

This gives `last_size` in [1, kWebPageChunkSize] for any non-zero `len`,
so the final partial chunk is always non-empty and can be sent after
the loop without a special case.  The for loop then iterates only over
full kWebPageChunkSize chunks, making every iteration identical and
branch-free, and vTaskDelay(1) is called only after full chunks.

Also add a `len == 0` guard to short-circuit immediately when there is
nothing to send, and remove the best-effort terminator send on the
error path since a failed send makes a follow-up send equally likely
to fail.
2026-05-06 23:11:26 +03:00
Valentin V. Bartenev 50c9daa8c2 Fix broken compilation by copy-paste typo in b4b301d 2026-05-06 23:10:51 +03:00
Valentin V. Bartenev 16066547fa Web: use sizeof instead of strlen for PROGMEM HTML page sizes
The HTML pages (login, app, stats) are string literals compiled into
PROGMEM. Their sizes are known at compile time, but the previous
sendProgmemChunked() called strlen() on every request to determine
the length, requiring a full scan of potentially tens of kilobytes of
PROGMEM data before the first byte was sent.

Replace sendProgmemChunked() with a sendWhole() function that accepts
an explicit length parameter, and introduce a sendProgmem() macro that
passes sizeof(mem) - 1 at the call site. For array literals this
resolves to a compile-time constant, eliminating the runtime strlen
scan entirely.

The rename from sendProgmemChunked() to sendWhole() also better reflects
what the function does — it sends the entire buffer in chunks — rather
than where the data comes from.
2026-05-06 22:33:00 +03:00
Valentin V. Bartenev 650d03abf3 Web: allow browser caching of static HTML pages
The login, app, and stats HTML pages are static assets compiled into
PROGMEM.  All dynamic content is fetched separately via JavaScript API
calls after the page loads — the HTML itself never changes between
requests within the same firmware version.

The "Cache-Control: no-store" header forced the browser to re-download
the full HTML on every visit, including a complete chunked transfer
from the ESP32.  Given that the ESP32 is slow and serves responses in
small chunks, this was an unnecessary repeated cost that added latency
before the page became interactive.

Removing no-store allows the browser to cache the HTML pages locally.
Subsequent requests are served from the browser cache instantly, with no
transfer from the ESP32 at all, leaving the server free to handle the
API requests that actually carry dynamic data.
2026-05-06 22:30:22 +03:00
Valentin V. Bartenev b4b301dbbf Web: prevent redirect server socket exhaustion from stale connections
The HTTP redirect server has max_open_sockets = 2.  A client that
connects and then disappears silently (network drop, browser crash,
mobile radio loss) leaves its socket occupying a slot indefinitely —
the server has no way to detect the loss until it tries to write to
the socket again.

Enable lru_purge_enable on the redirect server so that when both
slots are occupied by such stale connections, the least-recently-used
one is evicted automatically to make room for a new incoming
connection, rather than refusing it outright.

Add a "Connection: close" header to the 302 response as a complementary
measure.  For well-behaved clients this triggers an immediate TCP
teardown after the redirect is received, shrinking the window during
which a connection can turn into a zombie.  Together the two changes
provide defence in depth: "Connection: close" prevents stale connections
from forming in the first place; lru_purge_enable cleans them up when
they do.
2026-05-06 22:25:51 +03:00
Valentin V. Bartenev 94d2ddf507 Web: tune httpd config to reduce browser freezes on slow ESP32
The ESP32 processes HTTP requests sequentially on a single task.
Under load — generating chunked HTML pages, running commands, or
serving sequential stats API calls — it can be slow to send data
or loop back to accept the next request.  The previous 2-second
recv/send timeouts were too tight for this: the httpd layer would
drop connections mid-transfer, causing the browser to hang or
show incomplete pages.

Raise recv_wait_timeout and send_wait_timeout from 2 s to 10 s
on both the HTTPS and HTTP-redirect servers, giving the ESP32
enough headroom to finish generating and sending responses without
the transport layer tearing down the connection prematurely.

Set backlog_conn to 0 on both servers. With the previous backlog
of 2, incoming connections completed the TCP handshake and queued
in the kernel while the ESP32 was busy.  The browser saw the
connection as open but received no HTTP response, causing it to
freeze indefinitely.  With backlog 0 (lwIP minimum), connections
that cannot be immediately accepted are refused outright, giving
the browser a fast, recoverable error instead of a silent hang.

Enable lru_purge_enable on the HTTPS server.  Browsers hold
keep-alive connections open for reuse.  With max_open_sockets = 2,
both slots can be occupied by idle keep-alive connections from the
same session, blocking a new connection attempt entirely.  LRU
purge automatically closes the least-recently-used idle keep-alive
connection to make room, ensuring the single client can always
reconnect without a server restart.
2026-05-06 22:14:28 +03:00
Valentin V. Bartenev 9b2358416c Web: yield in sendProgmemChunked loop to prevent task WDT reset
When sending large PROGMEM content in chunks over HTTPS, the lwIP TCP/IP
task (tiT) could monopolize CPU 0 for an extended period without ever
yielding, starving the IDLE0 task and triggering the task watchdog timer.

Add vTaskDelay(1) at the end of each iteration in sendProgmemChunked()
to yield to the scheduler between chunks, allowing the IDLE task to reset
the watchdog and preventing spurious reboots during web panel page loads.
2026-05-06 22:02:57 +03:00
Valentin V. Bartenev 4d404f5301 Web: align page chunk size to MBEDTLS_SSL_OUT_CONTENT_LEN to fix WDT reboot
The hardcoded kWebPageChunkSize of 768 bytes caused the task watchdog
to trigger when serving the web panel over HTTPS.  Each call to
httpd_resp_send_chunk() results in a separate TLS record encryption
via mbedTLS, which on ESP32-S3 uses DMA-backed AES-GCM (esp_aes_process_dma).

The gdma_disconnect() call inside that path enters a critical section,
blocking the IDLE0 task.  With 768-byte chunks, a large page response
requires many such DMA operations in tight succession, starving the IDLE
task long enough to trip the watchdog.

Replacing the hardcoded value with MBEDTLS_SSL_OUT_CONTENT_LEN aligns the
chunk size to the TLS output record buffer, minimising the number of TLS
records (and thus DMA encryption operations) needed to send a full page,
and keeping the httpd task within the watchdog timeout.

Fixes: task_wdt abort in sendProgmemChunked() -> httpd_ssl_send() ->
       esp_aes_process_dma() -> gdma_disconnect() on ESP32-S3.
2026-05-06 21:58:57 +03:00
Valentin V. Bartenev 2f69718cfe ESP32: fix HTTPS task watchdog by restricting TLS to RSA cipher suites
Browsers negotiate ECDHE cipher suites by default.  On ESP32-S3 the
hardware RSA accelerator handles RSA key exchange efficiently, but
there is no ECP hardware accelerator.  ECDHE requires the server to
compute an ephemeral key pair: ecp_precompute_comb() builds a comb
table through many sequential ECP point doublings, each dispatched
to the hardware bignum unit (esp_bignum.c), but the ECP layer has
no RTOS yield points between iterations.  The entire computation runs
to completion on CPU 0 without ever resetting the task watchdog.

A single handshake does not exceed the watchdog timeout on its own,
but two consecutive handshakes (e.g. a browser retry after a failed
attempt) accumulate enough uninterrupted runtime to starve IDLE0:

  E (54924) esp-tls-mbedtls: mbedtls_ssl_handshake returned -0x0050
  E (57208) esp-tls-mbedtls: mbedtls_ssl_handshake returned -0x7280
  E (57638) task_wdt: Task watchdog got triggered.
  E (57638) task_wdt:  - IDLE0 (CPU 0)
  E (57638) task_wdt: Tasks currently running:
  E (57638) task_wdt: CPU 0: httpd

The crash occurs in ecp_precompute_comb() → ecp_double_jac() →
mbedtls_mpi_mul_mpi() during the ServerKeyExchange step.

Fix by wrapping mbedtls_ssl_config_defaults() via the linker --wrap
mechanism.  The wrapper intercepts server-side SSL config init
(MBEDTLS_SSL_IS_SERVER) and replaces the cipher suite list with
RSA key exchange only, routing handshakes through the hardware RSA
accelerator and eliminating the ECDH path entirely.  MQTT connections
(MBEDTLS_SSL_IS_CLIENT) are unaffected.

Also switch the self-signed cert generator from EC (prime256v1) to
RSA 2048 so the generated certificate matches the restricted cipher
suites.

Changes:
- arch/esp32/tls_cipher_restrict.c: new file implementing the
  mbedtls_ssl_config_defaults wrap; restricts server cipher suites
  to RSA_WITH_AES_{128,256}_{GCM,CBC}_SHA{256,384}
- platformio.ini: add -Wl,--wrap=mbedtls_ssl_config_defaults and
  include tls_cipher_restrict.c in the esp32_base build
- arch/esp32/extra_scripts/generate_web_panel_cert.py: switch key
  generation from `openssl ecparam -name prime256v1` to `openssl
  genrsa 2048`
2026-05-06 21:50:13 +03:00
Valentin V. BartenevтаGitHub 11233933e9 Mention no additional latency to LoRa as a feature in README.md 2026-05-05 22:06:07 +03:00
Valentin V. Bartenev eb2c6d0bb8 ESP32: pin all tskNO_AFFINITY tasks to core 0 via linker wrap
On dual-core ESP32-S3 (ARDUINO_RUNNING_CORE=1), the Arduino loop and
LoRa processing run exclusively on core 1.  ESP-IDF v4 creates mqtt_task
with tskNO_AFFINITY, meaning FreeRTOS may schedule them on core 1 under
load, preempting the LoRa loop.

ESP-IDF v4 provides no public API to change a task's core affinity after
creation (vTaskCoreAffinitySet is IDF v5+ only), and the esp_mqtt_client
config struct has no task_core_id field.  The precompiled Arduino-ESP32
framework cannot be patched via sdkconfig.

Instead, use the GCC/LD --wrap linker mechanism to intercept every call
to xTaskCreatePinnedToCore.  Any task created with tskNO_AFFINITY is
redirected to core 0.  Tasks that are already explicitly pinned (Wi-Fi
driver, LwIP, httpd, esp_timer, ipc0/ipc1) are passed through unchanged.

We intentionally do not filter by task name.  Pinning all unpinned tasks
makes the approach robust against internal ESP-IDF task name changes and
catches any future tasks that may be added with tskNO_AFFINITY.

Verified task layout after the change:
  loopTask  pri=1  core=1   (Arduino loop / LoRa — unchanged)
  mqtt_task pri=5  core=0   (was tskNO_AFFINITY, now pinned)
  httpd     pri=2  core=0   (core_id set explicitly in WebPanelServer)
  tiT       pri=18 core=0   (LwIP, already pinned by ESP-IDF)
  wifi      pri=23 core=0   (Wi-Fi driver, already pinned)
  esp_timer pri=22 core=0   (already pinned)
  ipc0/ipc1 pri=24 core=0/1 (IPC, already pinned per-core)

The -Wl,--wrap flag and arch/esp32/task_pinning.c are added only to
[esp32_base] (IDF v4).  The ESP32-C6 pioarduino target (IDF v5) is
unaffected and can use vTaskCoreAffinitySet() if needed in the future.
2026-05-05 21:58:58 +03:00
Valentin V. Bartenev e99cee2a1b Web: pin HTTPS and redirect server tasks to core 0 at low priority
Set task_priority = tskIDLE_PRIORITY + 2 and core_id = 0 for both the
HTTPS server and the HTTP-to-HTTPS redirect server. This keeps web
serving off core 1, which handles radio and application logic, reducing
interference with time-sensitive operations.
2026-05-05 21:00:13 +03:00
Valentin V. Bartenev 58258aa673 Adjust tag name for triggering repeater builds 2026-05-04 04:35:47 +03:00
Valentin V. Bartenev ccd5a588a2 Adjust firmware GitHub building actions 2026-05-04 02:08:02 +03:00
Valentin V. Bartenev dd90afba1d NetworkService: keep hasTimeSync() true for 24h after WiFi drops
_have_time_sync is reset to false whenever WiFi disconnects, even
though the ESP32 RTC continues to hold accurate time after a
successful SNTP sync. This caused hasTimeSync() to return false
during transient WiFi outages, unnecessarily tearing down MQTT
broker connections and suppressing packet publishing.

Introduce _last_time_sync to record the wall-clock time of the
most recent confirmed sync. Move hasTimeSync() out of the header
into NetworkService.cpp and extend its logic: in addition to the
existing _have_time_sync flag, return true if the system clock is
still sane (>= kMinSaneEpoch) and no more than kMaxOutOfSync (24h)
has elapsed since the last confirmed sync.

This makes the MQTT uplink resilient to brief WiFi dropouts without
requiring any changes to callers of hasTimeSync().

Also bump kMinSaneEpoch from 2025-01-01 to 2026-01-01.
2026-05-03 22:10:32 +03:00
Valentin V. Bartenev 80851b9c25 Use Russian NTP servers 2026-05-03 20:09:57 +03:00
Valentin V. Bartenev c2074fc996 Docs: reorder items in userguide list 2026-05-02 21:46:35 +03:00
Valentin V. Bartenev 12a9d3b8dc Docs: translate title 2026-05-02 21:43:36 +03:00
Valentin V. Bartenev 85ad6a2852 Docs: fix building due to missing quotes around string with colon in yaml 2026-05-02 21:41:52 +03:00
Valentin V. Bartenev 2d47eef756 Docs: switch theme language and localize the rest 2026-05-02 21:39:52 +03:00
Valentin V. Bartenev 9bdca82e48 Docs: shorter menu title for firmware building 2026-05-02 21:32:54 +03:00
Valentin V. Bartenev fdd6ef7b0b Docs: fixes and menu translation 2026-05-02 21:31:14 +03:00
Valentin V. BartenevтаGitHub 0b6501f874 Heading for features lists in README.md 2026-05-02 20:48:45 +03:00
Valentin V. BartenevтаGitHub 194e004186 Add info about fan control feature to README.md 2026-05-02 20:13:36 +03:00
Valentin V. BartenevтаGitHub 3b017b42f0 Even shorter heading README.md 2026-05-02 20:12:19 +03:00
Valentin V. BartenevтаGitHub fa2f1fc1b5 Shorter heading in README.md 2026-05-02 20:11:05 +03:00
Valentin V. BartenevтаGitHub dedffa6dc2 Update README.md 2026-05-02 20:08:45 +03:00
Valentin V. Bartenev 5cc794ce1b Slightly refresh docs index 2026-05-02 19:53:12 +03:00
Valentin V. Bartenev 28d43e964d Adopt and translate documentation 2026-05-02 19:47:10 +03:00
Valentin V. BartenevтаGitHub fda92915d4 Reorganize web-panel feature list in README.md 2026-05-02 18:54:58 +03:00
Valentin V. Bartenev a6a6b4f43f Slightly crop web-panel app screenshot
To make it equally sized with the stats one, which gives better look
when they are side-by-side on the README page.
2026-05-02 17:19:56 +03:00
Valentin V. BartenevтаGitHub 80c83423fa Adjust web-panel features and screenshots in README.md 2026-05-02 17:02:17 +03:00
Valentin V. Bartenev 9e964d3b23 Update web-panel screenshots to MeshCoreTel variant
Format is changed to WebP for better compression and reduced loading time.
2026-05-02 16:50:58 +03:00
Valentin V. BartenevтаGitHub d129846acd Add screenshots of web-panel to README.md 2026-05-02 04:22:36 +03:00
Valentin V. Bartenev c4e7a43639 Adjust GitHub-pages site logo 2026-05-02 01:34:02 +03:00
Valentin V. Bartenev 18a0db8d42 Adjust GitHub-pages site configuration for MeshCoreTel-firmware 2026-05-02 01:30:25 +03:00
Valentin V. Bartenev 4c95fb11d4 Remove migaration docs page
It's about migration from older EastMesh firmware:

 - https://github.com/xJARiD/MeshCore

and  not relevant to MeshCoreTel at all.
2026-05-02 01:13:54 +03:00
Valentin V. Bartenev ea3f676a3a Replaced EastMesh to MeshCoreTel across all the docs 2026-05-01 20:45:40 +03:00
Valentin V. BartenevтаGitHub 3ae2aab8ed Fix EastMesh firmware URI in README.md 2026-05-01 20:30:13 +03:00
Valentin V. BartenevтаGitHub ef65791d72 Additional correction in README.md 2026-05-01 20:23:13 +03:00
Valentin V. BartenevтаGitHub b0ead9e695 Fix README.md 2026-05-01 20:21:07 +03:00
Valentin V. BartenevтаGitHub 729f5c45e4 Adjust heading in README.md 2026-05-01 20:00:14 +03:00
Valentin V. BartenevтаGitHub d0b9c82464 Update README.md 2026-05-01 19:59:14 +03:00
Valentin V. BartenevтаGitHub fe8e0556df Translated README.md 2026-05-01 19:51:18 +03:00
Valentin V. Bartenev 89aec17b68 Remove Station G2 repeater build with logging
There's no known reason why additional firmware build of this board
with serial logging enabled was needed.

So, removing it for now to optimize building process and cleanup
firmware list.
2026-05-01 15:34:23 +03:00
Valentin V. Bartenev d74d99c7f1 Add DIY ESP32S3 N16R8 E22 Back2back board support
This closes #1.
2026-05-01 15:29:56 +03:00
Valentin V. Bartenev afd5fc2f98 Unify firmware version format with release builds. 2026-04-29 01:07:11 +03:00