From 1af0f3b32028feede374398345660470a3394a93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ma=C5=82ecki?= Date: Sat, 6 Jun 2026 01:38:20 +0200 Subject: [PATCH 01/12] feat: add WiFi internet OTA upgrade + LNA fix + build script - WiFiConnect.h: multi-network WiFi manager (WIFI_NETWORKS="ssid:pwd;...") - InternetOTA.h: HTTPS manifest fetch + HTTPUpdate flash; 4-part semver comparison (vX.Y.Z.W); no ArduinoJson dependency - ESP32Board: startInternetOTA / checkInternetOTA methods (WIFI_INTERNET_OTA guard) - CommonCLI: 'check cloud ota' / 'start cloud ota' CLI commands - platformio.ini: [esp32_internet_ota] and [poli_build] sections - heltec_v4/heltec_v3/xiao_s3_wio: BOARD_ID defines + cloud_ota envs - LoRaFEMControl.h: disable LNA by default (fixes deaf radio on Heltec V4.3; matches upstream commit 696aae6e) - build_firmware.sh: universal build+deploy script; vX.Y.Z.W versioning; auto-increment; per-board manifest.json; macOS Bash 3.2 compatible - poli_version.txt: tracks current PoLi build number (v1.15.0.0) Co-Authored-By: Claude Sonnet 4.6 --- build_firmware.sh | 328 ++++++++++++++++++++++++++++ platformio.ini | 15 ++ poli_version.txt | 1 + src/MeshCore.h | 2 + src/helpers/CommonCLI.cpp | 10 + src/helpers/ESP32Board.cpp | 90 ++++++++ src/helpers/ESP32Board.h | 2 + src/helpers/esp32/InternetOTA.h | 175 +++++++++++++++ src/helpers/esp32/WiFiConnect.h | 80 +++++++ variants/heltec_v3/platformio.ini | 25 +++ variants/heltec_v4/LoRaFEMControl.h | 2 +- variants/heltec_v4/platformio.ini | 27 +++ variants/xiao_s3_wio/platformio.ini | 20 ++ 13 files changed, 776 insertions(+), 1 deletion(-) create mode 100755 build_firmware.sh create mode 100644 poli_version.txt create mode 100644 src/helpers/esp32/InternetOTA.h create mode 100644 src/helpers/esp32/WiFiConnect.h diff --git a/build_firmware.sh b/build_firmware.sh new file mode 100755 index 0000000000..4b55dd83ae --- /dev/null +++ b/build_firmware.sh @@ -0,0 +1,328 @@ +#!/usr/bin/env bash +# build_firmware.sh – PoLi MeshCore firmware builder & deployer +# +# Usage: +# ./build_firmware.sh [OPTIONS] [VERSION] +# +# VERSION format: vX.Y.Z.W (e.g. v1.15.0.3) +# Base X.Y.Z mirrors upstream MeshCore versioning. +# Build number W is PoLi-specific; upstream builds are treated as W=0. +# +# Options: +# --auto Auto-increment W from poli_version.txt +# --boards LIST Comma-separated subset: heltec_v4,heltec_v3,xiao_s3_wio +# --no-deploy Build only – do not copy to website data/firmware/ +# --dry-run Print what would happen, build & copy nothing +# --keep-ini Do not remove platformio.local.ini after build +# -h, --help Show this help +# +# Examples: +# ./build_firmware.sh v1.15.0.1 +# ./build_firmware.sh --auto +# ./build_firmware.sh --auto --boards heltec_v4 +# ./build_firmware.sh v1.15.0.2 --no-deploy +# ./build_firmware.sh --dry-run --auto +# ./build_firmware.sh --auto --boards heltec_v4,xiao_s3_wio + +set -euo pipefail + +# ─── Colors ─────────────────────────────────────────────────────────────────── +if [[ -t 1 ]]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' + CYAN='\033[0;36m'; BOLD='\033[1m'; DIM='\033[2m'; RESET='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; DIM=''; RESET='' +fi +ok() { echo -e "${GREEN}✓${RESET} $*"; } +err() { echo -e "${RED}✗${RESET} $*" >&2; } +info() { echo -e "${CYAN}→${RESET} $*"; } +warn() { echo -e "${YELLOW}⚠${RESET} $*"; } +hdr() { echo -e "\n${BOLD}── $* ──${RESET}"; } +dim() { echo -e "${DIM}$*${RESET}"; } + +# ─── Paths ──────────────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WEBSITE_DIR="${SCRIPT_DIR}/../meshcore.epila.pl/data/firmware" +BASE_URL="https://meshcore.epila.pl/firmware" +VERSION_FILE="${SCRIPT_DIR}/poli_version.txt" +LOCAL_INI="${SCRIPT_DIR}/platformio.local.ini" + +# ─── Board definitions ──────────────────────────────────────────────────────── +# Format: "BOARD_ID|PIO_ENV_NAME" +# BOARD_ID – used as subdirectory in data/firmware/ and BOARD_ID define +# PIO_ENV – exact env name (case-sensitive! matches .pio/build//) +declare -a BOARD_DEFS=( + "heltec_v4|heltec_v4_repeater_cloud_ota" + "heltec_v3|Heltec_v3_repeater_cloud_ota" + "xiao_s3_wio|Xiao_S3_WIO_repeater_cloud_ota" +) + +# ─── Argument parsing ───────────────────────────────────────────────────────── +VERSION="" +AUTO_INC=false +DRY_RUN=false +NO_DEPLOY=false +KEEP_INI=false +declare -a FILTER_BOARDS=() + +usage() { + sed -n '/^# Usage:/,/^[^#]/p' "$0" | grep '^#' | sed 's/^# \{0,1\}//' + exit 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + v[0-9]*) VERSION="$1"; shift ;; + --auto) AUTO_INC=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --no-deploy) NO_DEPLOY=true; shift ;; + --keep-ini) KEEP_INI=true; shift ;; + --boards) + IFS=',' read -ra FILTER_BOARDS <<< "${2:-}" + shift 2 ;; + -h|--help) usage ;; + *) err "Unknown option: $1"; echo "Run with --help for usage."; exit 1 ;; + esac +done + +# ─── Version resolution ─────────────────────────────────────────────────────── +resolve_version() { + if [[ -n "$VERSION" && "$AUTO_INC" == "true" ]]; then + err "Cannot use both VERSION and --auto at the same time."; exit 1 + fi + + if [[ "$AUTO_INC" == "true" ]]; then + if [[ ! -f "$VERSION_FILE" ]]; then + echo "v1.15.0.0" > "$VERSION_FILE" + fi + local stored; stored=$(tr -d '[:space:]' < "$VERSION_FILE") + # strip leading v + local plain="${stored#v}" + local base; base=$(echo "$plain" | cut -d. -f1-3) + local build; build=$(echo "$plain" | cut -d. -f4) + # ensure build is numeric + [[ "$build" =~ ^[0-9]+$ ]] || { err "poli_version.txt: invalid format '$stored'"; exit 1; } + build=$(( build + 1 )) + VERSION="v${base}.${build}" + info "Auto-increment: ${stored} → ${VERSION}" + fi + + if [[ -z "$VERSION" ]]; then + err "No version specified. Pass VERSION (e.g. v1.15.0.1) or use --auto." + exit 1 + fi + + # Validate: vX.Y.Z.W + if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + err "Invalid version: '${VERSION}'. Required format: vX.Y.Z.W (e.g. v1.15.0.1)" + exit 1 + fi +} + +# ─── Board filter ───────────────────────────────────────────────────────────── +# Populates global ACTIVE_BOARDS array (nameref not available in Bash < 4.3) +ACTIVE_BOARDS=() +filter_boards() { + ACTIVE_BOARDS=() + for def in "${BOARD_DEFS[@]}"; do + local bid="${def%%|*}" + if [[ ${#FILTER_BOARDS[@]} -eq 0 ]]; then + ACTIVE_BOARDS+=("$def") + else + for sel in "${FILTER_BOARDS[@]}"; do + if [[ "$sel" == "$bid" ]]; then + ACTIVE_BOARDS+=("$def"); break + fi + done + fi + done +} + +# ─── platformio.local.ini writer ────────────────────────────────────────────── +write_local_ini() { + local version="$1" + local build_date; build_date=$(date "+%d %b %Y") + # PlatformIO string macro: -D NAME='"value"' + printf \ +'; Generated by build_firmware.sh at %s\n; Remove or update before regular (non-PoLi) builds.\n[poli_build]\nbuild_flags =\n -D FIRMWARE_VERSION='"'"'"%s"'"'"'\n -D FIRMWARE_BUILD_DATE='"'"'"%s"'"'"'\n' \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$version" "$build_date" \ + > "$LOCAL_INI" + ok "platformio.local.ini → ${version} / ${build_date}" +} + +# ─── Build one board ───────────────────────────────────────────────────────── +build_board() { + local board_id="$1" pio_env="$2" + local bin_path="${SCRIPT_DIR}/.pio/build/${pio_env}/firmware.bin" + + info "Compiling ${board_id} [env:${pio_env}]…" + + if [[ "$DRY_RUN" == "true" ]]; then + warn " DRY-RUN: pio run -e ${pio_env}" + return 0 + fi + + # Run PlatformIO; stream output; propagate exit code without triggering set -e + if ! pio run -e "$pio_env"; then + err "Build failed for ${board_id}" + return 1 + fi + + if [[ ! -f "$bin_path" ]]; then + err "firmware.bin not found after build: ${bin_path}" + return 1 + fi + + local size; size=$(du -h "$bin_path" | cut -f1) + ok " ${bin_path} (${size})" + return 0 +} + +# ─── Deploy one board ──────────────────────────────────────────────────────── +deploy_board() { + local board_id="$1" pio_env="$2" version="$3" + local bin_src="${SCRIPT_DIR}/.pio/build/${pio_env}/firmware.bin" + local dest_dir="${WEBSITE_DIR}/${board_id}" + local bin_dst="${dest_dir}/firmware.bin" + local manifest="${dest_dir}/manifest.json" + local fw_url="${BASE_URL}/${board_id}/firmware.bin" + + if [[ "$NO_DEPLOY" == "true" ]]; then + warn " NO-DEPLOY: skipping ${board_id}" + return 0 + fi + + if [[ ! -d "$dest_dir" ]]; then + err " Deploy dir missing: ${dest_dir}" + err " Create it manually or run: mkdir -p ${dest_dir}" + return 1 + fi + + if [[ "$DRY_RUN" == "true" ]]; then + warn " DRY-RUN: cp ${bin_src}" + warn " → ${bin_dst}" + warn " DRY-RUN: manifest.json ← {\"version\":\"${version}\",\"url\":\"${fw_url}\"}" + return 0 + fi + + cp "$bin_src" "$bin_dst" + ok " firmware.bin → ${bin_dst}" + + printf '{"version":"%s","url":"%s"}\n' "$version" "$fw_url" > "$manifest" + ok " manifest.json ← version=${version}" +} + +# ─── Cleanup ────────────────────────────────────────────────────────────────── +cleanup_ini() { + if [[ "$KEEP_INI" == "true" || "$DRY_RUN" == "true" ]]; then + dim " platformio.local.ini kept" + return + fi + if [[ -f "$LOCAL_INI" ]]; then + rm "$LOCAL_INI" + dim " platformio.local.ini removed" + fi +} + +# ─── Save version ───────────────────────────────────────────────────────────── +save_version() { + [[ "$DRY_RUN" == "true" ]] && { warn "DRY-RUN: poli_version.txt not updated"; return; } + echo "$1" > "$VERSION_FILE" + ok "poli_version.txt → $1" +} + +# ─── Main ───────────────────────────────────────────────────────────────────── +main() { + echo -e "${BOLD}╔══════════════════════════════════════════╗${RESET}" + echo -e "${BOLD}║ PoLi MeshCore Firmware Builder v2.0 ║${RESET}" + echo -e "${BOLD}╚══════════════════════════════════════════╝${RESET}" + [[ "$DRY_RUN" == "true" ]] && warn "DRY-RUN mode – nothing will be built or deployed" + + # ── Resolve version ── + resolve_version + + # ── Resolve boards ── + filter_boards + if [[ ${#ACTIVE_BOARDS[@]} -eq 0 ]]; then + err "No boards matched. Available: heltec_v4, heltec_v3, xiao_s3_wio" + exit 1 + fi + + # ── Print plan ── + hdr "Build plan" + info "Version : ${VERSION}" + info "Date : $(date '+%d %b %Y')" + if [[ "$NO_DEPLOY" == "true" ]]; then + info "Deploy to : DISABLED (--no-deploy)" + else + info "Deploy to : ${WEBSITE_DIR}" + fi + echo "" + for def in "${ACTIVE_BOARDS[@]}"; do + local bid="${def%%|*}" env="${def##*|}" + printf " ${CYAN}%-14s${RESET} → env:%-40s\n" "$bid" "$env" + printf " ${DIM}%-14s bin: .pio/build/%s/firmware.bin${RESET}\n" "" "$env" + if [[ "$NO_DEPLOY" != "true" ]]; then + printf " ${DIM}%-14s dst: %s/%s/${RESET}\n" "" "$WEBSITE_DIR" "$bid" + fi + done + + # ── Write platformio.local.ini ── + hdr "Version config" + if [[ "$DRY_RUN" != "true" ]]; then + write_local_ini "$VERSION" + dim " Contents:" + sed 's/^/ /' "$LOCAL_INI" + else + warn "DRY-RUN: would write platformio.local.ini" + fi + + # ── Build & deploy ── + declare -a success=() failed=() + for def in "${ACTIVE_BOARDS[@]}"; do + local board_id="${def%%|*}" pio_env="${def##*|}" + hdr "Building: ${board_id}" + if build_board "$board_id" "$pio_env"; then + hdr "Deploying: ${board_id}" + if deploy_board "$board_id" "$pio_env" "$VERSION"; then + success+=("$board_id") + else + failed+=("$board_id") + fi + else + failed+=("$board_id") + fi + done + + # ── Cleanup & persist version ── + hdr "Finishing" + cleanup_ini + if [[ ${#success[@]} -gt 0 ]]; then + save_version "$VERSION" + fi + + # ── Summary ── + hdr "Summary" + info "Version : ${VERSION}" + [[ ${#success[@]} -gt 0 ]] && ok "Built OK : ${success[*]}" + [[ ${#failed[@]} -gt 0 ]] && err "Failed : ${failed[*]}" + + if [[ "$NO_DEPLOY" != "true" && "$DRY_RUN" != "true" && ${#success[@]} -gt 0 ]]; then + echo "" + echo -e "${BOLD}Next – push to server:${RESET}" + echo " rsync -av --progress \\" + echo " ${WEBSITE_DIR}/ \\" + echo " user@server:/opt/meshcore-epila/data/firmware/" + echo "" + echo -e "${BOLD}Verify manifests:${RESET}" + for def in "${ACTIVE_BOARDS[@]}"; do + local bid="${def%%|*}" + echo " curl -s ${BASE_URL}/${bid}/manifest.json" + done + fi + + [[ ${#failed[@]} -gt 0 ]] && exit 1 + return 0 +} + +main "$@" diff --git a/platformio.ini b/platformio.ini index 864e5e1ffe..e281db14c7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -69,6 +69,21 @@ lib_deps = ESP32Async/ESPAsyncWebServer @ 3.10.3 file://arch/esp32/AsyncElegantOTA +; Internet OTA: firmware pulled from meshcore.epila.pl over WiFi. +; Add this to an [env:...] section together with esp32_ota. +; Configure WiFi networks with WIFI_NETWORKS (semicolon-separated "ssid:pwd" pairs) +; or with individual WIFI_SSID/WIFI_PWD, WIFI_SSID_2/WIFI_PWD_2, WIFI_SSID_3/WIFI_PWD_3. +; BOARD_ID is set per-variant automatically. +[esp32_internet_ota] +build_flags = + -D WIFI_INTERNET_OTA=1 + +; PoLi custom build version injection. +; Overridden by build_firmware.sh via platformio.local.ini. +; Cloud OTA envs reference ${poli_build.build_flags} to pick this up. +[poli_build] +build_flags = + ; esp32c6 uses arduino framework 3.x ; WARNING: experimental. May not work as stable as other platforms. [esp32c6_base] diff --git a/poli_version.txt b/poli_version.txt new file mode 100644 index 0000000000..fbe3a8d0f1 --- /dev/null +++ b/poli_version.txt @@ -0,0 +1 @@ +v1.15.0.0 diff --git a/src/MeshCore.h b/src/MeshCore.h index 3cf60a3497..47b3a7d00d 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -58,6 +58,8 @@ class MainBoard { virtual uint8_t getStartupReason() const = 0; virtual bool getBootloaderVersion(char* version, size_t max_len) { return false; } virtual bool startOTAUpdate(const char* id, char reply[]) { return false; } // not supported + virtual bool startInternetOTA(const char* firmware_version, char reply[]) { return false; } // not supported + virtual bool checkInternetOTA(const char* firmware_version, char reply[]) { return false; } // not supported // Power management interface (boards with power management override these) virtual bool isExternalPowered() { return false; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 9f1249f7b4..11072b4e20 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -238,6 +238,16 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re if (!_board->startOTAUpdate(_prefs->node_name, reply)) { strcpy(reply, "Error"); } + } else if (strcmp(command, "check cloud ota") == 0) { + if (!_board->checkInternetOTA(_callbacks->getFirmwareVer(), reply)) { + // reply already set by checkInternetOTA + } + } else if (strcmp(command, "start cloud ota") == 0) { + strcpy(reply, "Starting cloud OTA..."); + if (!_board->startInternetOTA(_callbacks->getFirmwareVer(), reply)) { + // reply already set by startInternetOTA on error + } + // on success, board reboots — this line is never reached } else if (memcmp(command, "clock", 5) == 0) { uint32_t now = getRTCClock()->getCurrentTime(); DateTime dt = DateTime(now); diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index e0ca1d0eeb..ccc7ca410c 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -44,4 +44,94 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { } #endif +// ---- Internet OTA (WiFi STA + HTTP pull from meshcore.epila.pl) ---- +#if defined(WIFI_INTERNET_OTA) + +#include +#include + +static WiFiConnect _ota_wifi; +static bool _ota_wifi_configured = false; + +static void _ensure_wifi_configured() { + if (_ota_wifi_configured) return; + _ota_wifi_configured = true; + +#if defined(WIFI_NETWORKS) + _ota_wifi.addNetworksFromString(WIFI_NETWORKS); +#else + // Fall back to single SSID/password pair if defined + #if defined(WIFI_SSID) && defined(WIFI_PWD) + _ota_wifi.addNetwork(WIFI_SSID, WIFI_PWD); + #endif + #if defined(WIFI_SSID_2) && defined(WIFI_PWD_2) + _ota_wifi.addNetwork(WIFI_SSID_2, WIFI_PWD_2); + #endif + #if defined(WIFI_SSID_3) && defined(WIFI_PWD_3) + _ota_wifi.addNetwork(WIFI_SSID_3, WIFI_PWD_3); + #endif #endif +} + +bool ESP32Board::checkInternetOTA(const char* firmware_version, char reply[]) { + _ensure_wifi_configured(); + inhibit_sleep = true; + + if (!_ota_wifi.begin()) { + strcpy(reply, "ERR: WiFi connect failed"); + inhibit_sleep = false; + return false; + } + + InternetOTA ota(_ota_wifi); + OTAManifest manifest; + bool has_update = ota.checkForUpdate(firmware_version, manifest, reply, 160); + + // Keep WiFi up if update available so caller can apply it immediately + if (!has_update) { + _ota_wifi.disconnect(); + inhibit_sleep = false; + } + return has_update; +} + +bool ESP32Board::startInternetOTA(const char* firmware_version, char reply[]) { + _ensure_wifi_configured(); + inhibit_sleep = true; + + if (!_ota_wifi.isConnected()) { + if (!_ota_wifi.begin()) { + strcpy(reply, "ERR: WiFi connect failed"); + inhibit_sleep = false; + return false; + } + } + + InternetOTA ota(_ota_wifi); + bool ok = ota.checkAndUpdate(firmware_version, reply, 160); + + _ota_wifi.disconnect(); + inhibit_sleep = false; + + if (ok) { + delay(500); + esp_restart(); + } + return ok; +} + +#else + +bool ESP32Board::checkInternetOTA(const char* firmware_version, char reply[]) { + strcpy(reply, "ERR: WIFI_INTERNET_OTA not enabled for this build"); + return false; +} + +bool ESP32Board::startInternetOTA(const char* firmware_version, char reply[]) { + strcpy(reply, "ERR: WIFI_INTERNET_OTA not enabled for this build"); + return false; +} + +#endif // WIFI_INTERNET_OTA + +#endif // ESP_PLATFORM diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index c2d78ae08f..a687572c3a 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -126,6 +126,8 @@ class ESP32Board : public mesh::MainBoard { } bool startOTAUpdate(const char* id, char reply[]) override; + bool startInternetOTA(const char* firmware_version, char reply[]) override; + bool checkInternetOTA(const char* firmware_version, char reply[]) override; void setInhibitSleep(bool inhibit) { inhibit_sleep = inhibit; diff --git a/src/helpers/esp32/InternetOTA.h b/src/helpers/esp32/InternetOTA.h new file mode 100644 index 0000000000..998ae98b5a --- /dev/null +++ b/src/helpers/esp32/InternetOTA.h @@ -0,0 +1,175 @@ +#pragma once + +#ifdef ESP_PLATFORM + +#include +#include +#include +#include +#include +#include "WiFiConnect.h" + +// Base URL for firmware manifests: //manifest.json +// Server uses HTTPS — certificate validation is skipped (embedded device, known server). +#ifndef OTA_BASE_URL + #define OTA_BASE_URL "https://meshcore.epila.pl/firmware" +#endif + +// Board ID must be defined per variant (e.g. "heltec_v4", "heltec_v3", "xiao_s3_wio") +#ifndef BOARD_ID + #define BOARD_ID "unknown" +#endif + +struct OTAManifest { + char version[32]; // e.g. "v1.15.1" + char url[256]; // full URL to .bin +}; + +// Compare semantic versions: "vMAJOR.MINOR.PATCH" or "vMAJOR.MINOR.PATCH.BUILD". +// The optional BUILD field (4th part) lets PoLi builds (e.g. v1.15.0.3) compare +// correctly against upstream 3-part versions (v1.15.0 treated as v1.15.0.0). +// Returns: <0 if a < b, 0 if equal, >0 if a > b +static int ota_semver_cmp(const char* a, const char* b) { + if (*a == 'v' || *a == 'V') a++; + if (*b == 'v' || *b == 'V') b++; + + int maj_a = 0, min_a = 0, pat_a = 0, bld_a = 0; + int maj_b = 0, min_b = 0, pat_b = 0, bld_b = 0; + sscanf(a, "%d.%d.%d.%d", &maj_a, &min_a, &pat_a, &bld_a); + sscanf(b, "%d.%d.%d.%d", &maj_b, &min_b, &pat_b, &bld_b); + + if (maj_a != maj_b) return maj_a - maj_b; + if (min_a != min_b) return min_a - min_b; + if (pat_a != pat_b) return pat_a - pat_b; + return bld_a - bld_b; +} + +class InternetOTA { + WiFiConnect* _wifi; + + // Minimal JSON field extractor — avoids pulling in ArduinoJson. + // Finds the value of the first occurrence of `"key":"value"` or `"key": "value"`. + bool extractJsonString(const char* json, const char* key, char* out, size_t out_len) { + char needle[64]; + snprintf(needle, sizeof(needle), "\"%s\"", key); + const char* p = strstr(json, needle); + if (!p) return false; + p += strlen(needle); + while (*p == ' ' || *p == ':' || *p == '\t') p++; + if (*p != '"') return false; + p++; + size_t i = 0; + while (*p && *p != '"' && i < out_len - 1) { + out[i++] = *p++; + } + out[i] = '\0'; + return i > 0; + } + +public: + explicit InternetOTA(WiFiConnect& wifi) : _wifi(&wifi) {} + + // Fetch manifest from server and fill `manifest`. Returns true on success. + bool fetchManifest(OTAManifest& manifest, char reply[], size_t reply_len) { + if (!_wifi->isConnected()) { + snprintf(reply, reply_len, "ERR: WiFi not connected"); + return false; + } + + char url[320]; + snprintf(url, sizeof(url), "%s/%s/manifest.json", OTA_BASE_URL, BOARD_ID); + + WiFiClientSecure tls; + tls.setInsecure(); // skip cert validation — known server, firmware hash verified by ESP-IDF + + HTTPClient http; + http.begin(tls, url); + http.setTimeout(10000); + int code = http.GET(); + + if (code != 200) { + snprintf(reply, reply_len, "ERR: HTTP %d from %s", code, url); + http.end(); + return false; + } + + String body = http.getString(); + http.end(); + + bool ok = extractJsonString(body.c_str(), "version", manifest.version, sizeof(manifest.version)) + && extractJsonString(body.c_str(), "url", manifest.url, sizeof(manifest.url)); + + if (!ok) { + snprintf(reply, reply_len, "ERR: invalid manifest JSON"); + return false; + } + return true; + } + + // Check if server has a newer version than `current_version`. + // Fills `reply` with a status message. Returns true if update is available. + bool checkForUpdate(const char* current_version, OTAManifest& manifest, char reply[], size_t reply_len) { + if (!fetchManifest(manifest, reply, reply_len)) return false; + + if (ota_semver_cmp(manifest.version, current_version) > 0) { + snprintf(reply, reply_len, "Update available: %s -> %s", current_version, manifest.version); + return true; + } + + snprintf(reply, reply_len, "Up to date: %s (server: %s)", current_version, manifest.version); + return false; + } + + // Download and flash firmware from `url`. Calls `on_progress` (if non-null) + // periodically with bytes written and total. Returns true on success. + bool applyUpdate(const OTAManifest& manifest, char reply[], size_t reply_len, + void (*on_progress)(int, int) = nullptr) { + if (!_wifi->isConnected()) { + snprintf(reply, reply_len, "ERR: WiFi not connected"); + return false; + } + + WiFiClientSecure tls; + tls.setInsecure(); // skip cert validation — firmware hash verified by ESP-IDF partition check + + if (on_progress) { + httpUpdate.onProgress(on_progress); + } + + httpUpdate.rebootOnUpdate(false); // we reboot ourselves after logging + + t_httpUpdate_return ret = httpUpdate.update(tls, manifest.url); + + switch (ret) { + case HTTP_UPDATE_FAILED: + snprintf(reply, reply_len, "ERR: update failed (%d) %s", + httpUpdate.getLastError(), + httpUpdate.getLastErrorString().c_str()); + return false; + + case HTTP_UPDATE_NO_UPDATES: + snprintf(reply, reply_len, "Server: no update needed"); + return false; + + case HTTP_UPDATE_OK: + snprintf(reply, reply_len, "OK: flashed %s — rebooting", manifest.version); + return true; + + default: + snprintf(reply, reply_len, "ERR: unknown update result"); + return false; + } + } + + // Convenience: check + apply in one call. Returns true if reboot is needed. + bool checkAndUpdate(const char* current_version, char reply[], size_t reply_len, + void (*on_progress)(int, int) = nullptr) { + OTAManifest manifest; + if (!checkForUpdate(current_version, manifest, reply, reply_len)) { + return false; // either up-to-date or error (reply already set) + } + return applyUpdate(manifest, reply, reply_len, on_progress); + } +}; + +#endif // ESP_PLATFORM diff --git a/src/helpers/esp32/WiFiConnect.h b/src/helpers/esp32/WiFiConnect.h new file mode 100644 index 0000000000..efc3fd11ba --- /dev/null +++ b/src/helpers/esp32/WiFiConnect.h @@ -0,0 +1,80 @@ +#pragma once + +#ifdef ESP_PLATFORM + +#include +#include +#include + +#define WIFI_CONNECT_TIMEOUT_MS 15000 +#define WIFI_MAX_NETWORKS 5 + +class WiFiConnect { + WiFiMulti _multi; + bool _started = false; + bool _connected = false; + +public: + // Add a network credential. Call before begin(). + void addNetwork(const char* ssid, const char* password) { + _multi.addAP(ssid, password); + } + + // Parse a semicolon-delimited list "ssid1:pwd1;ssid2:pwd2;..." + void addNetworksFromString(const char* networks_str) { + char buf[256]; + strncpy(buf, networks_str, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + + char* entry = strtok(buf, ";"); + while (entry != nullptr) { + char* colon = strchr(entry, ':'); + if (colon != nullptr) { + *colon = '\0'; + addNetwork(entry, colon + 1); + } else { + addNetwork(entry, ""); // open network + } + entry = strtok(nullptr, ";"); + } + } + + // Connect to the best available network. Returns true on success. + bool begin(uint32_t timeout_ms = WIFI_CONNECT_TIMEOUT_MS) { + if (_started) return _connected; + + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + _started = true; + + uint32_t deadline = millis() + timeout_ms; + while (millis() < deadline) { + if (_multi.run() == WL_CONNECTED) { + _connected = true; + return true; + } + delay(500); + } + _connected = false; + return false; + } + + // Re-check connection state (call in loop if you need reconnect). + bool isConnected() { + _connected = (WiFi.status() == WL_CONNECTED); + return _connected; + } + + // Disconnect and release resources. + void disconnect() { + WiFi.disconnect(true); + _started = false; + _connected = false; + } + + IPAddress localIP() { return WiFi.localIP(); } + String ssid() { return WiFi.SSID(); } + int8_t rssi() { return WiFi.RSSI(); } +}; + +#endif // ESP_PLATFORM diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index 7cc4963fff..fd01f1505c 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -17,6 +17,7 @@ build_flags = -D USE_SX1262 -D RADIO_CLASS=CustomSX1262 -D WRAPPER_CLASS=CustomSX1262Wrapper + -D BOARD_ID='"heltec_v3"' -D LORA_TX_POWER=22 -D P_LORA_TX_LED=35 -D PIN_BOARD_SDA=17 @@ -57,6 +58,30 @@ lib_deps = ${esp32_ota.lib_deps} bakercp/CRC32 @ ^2.0.0 +; Repeater with internet OTA support. +[env:Heltec_v3_repeater_cloud_ota] +extends = Heltec_lora32_v3 +build_flags = + ${Heltec_lora32_v3.build_flags} + ${esp32_internet_ota.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Heltec Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D MESH_PACKET_LOGGING=1 + -D MESH_DEBUG=1 + -D WIFI_NETWORKS='"MyNetwork:mypassword;BackupNetwork:backuppass"' + ${poli_build.build_flags} +build_src_filter = ${Heltec_lora32_v3.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_lora32_v3.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + [env:Heltec_v3_repeater_bridge_rs232] extends = Heltec_lora32_v3 build_flags = diff --git a/variants/heltec_v4/LoRaFEMControl.h b/variants/heltec_v4/LoRaFEMControl.h index 7545296503..13225bd56b 100644 --- a/variants/heltec_v4/LoRaFEMControl.h +++ b/variants/heltec_v4/LoRaFEMControl.h @@ -23,7 +23,7 @@ class LoRaFEMControl LoRaFEMType getFEMType(void) const { return fem_type; } private: LoRaFEMType fem_type=OTHER_FEM_TYPES; - bool lna_enabled=true; + bool lna_enabled=false; bool lna_can_control=false; }; diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 062bee2f31..d380866773 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -26,6 +26,7 @@ build_flags = -D PIN_USER_BTN=0 -D PIN_VEXT_EN=36 -D PIN_VEXT_EN_ACTIVE=HIGH + -D BOARD_ID='"heltec_v4"' -D LORA_TX_POWER=10 ;If it is configured as 10 here, the final output will be 22 dbm. -D MAX_LORA_TX_POWER=22 ; Max SX1262 output -D SX126X_REGISTER_PATCH=1 ; Patch register 0x8B5 for improved RX @@ -102,6 +103,32 @@ lib_deps = ${esp32_ota.lib_deps} bakercp/CRC32 @ ^2.0.0 +; Repeater with internet OTA support. +; Set WIFI_NETWORKS to a semicolon-separated list of "ssid:password" entries, +; or use WIFI_SSID/WIFI_PWD (+ optionally WIFI_SSID_2/WIFI_PWD_2, WIFI_SSID_3/WIFI_PWD_3). +[env:heltec_v4_repeater_cloud_ota] +extends = heltec_v4_oled +build_flags = + ${heltec_v4_oled.build_flags} + ${esp32_internet_ota.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Heltec Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D MESH_PACKET_LOGGING=1 + -D MESH_DEBUG=1 + -D WIFI_NETWORKS='"MyNetwork:mypassword;BackupNetwork:backuppass"' + ${poli_build.build_flags} +build_src_filter = ${heltec_v4_oled.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${heltec_v4_oled.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + [env:heltec_v4_repeater_bridge_espnow] extends = heltec_v4_oled build_flags = diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index 13d406792b..5b2ba8b06a 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -29,6 +29,7 @@ build_flags = ${esp32_base.build_flags} -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 -D SX126X_RX_BOOSTED_GAIN=1 + -D BOARD_ID='"xiao_s3_wio"' build_src_filter = ${esp32_base.build_src_filter} +<../variants/xiao_s3_wio> + @@ -53,6 +54,25 @@ lib_deps = ${Xiao_S3_WIO.lib_deps} ${esp32_ota.lib_deps} +; Repeater with internet OTA support. +[env:Xiao_S3_WIO_repeater_cloud_ota] +extends = Xiao_S3_WIO +build_src_filter = ${Xiao_S3_WIO.build_src_filter} + +<../examples/simple_repeater/*.cpp> +build_flags = + ${Xiao_S3_WIO.build_flags} + ${esp32_internet_ota.build_flags} + -D ADVERT_NAME='"XiaoS3 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WIFI_NETWORKS='"MyNetwork:mypassword;BackupNetwork:backuppass"' + ${poli_build.build_flags} +lib_deps = + ${Xiao_S3_WIO.lib_deps} + ${esp32_ota.lib_deps} + ; [env:Xiao_S3_WIO_repeater_bridge_rs232] ; extends = Xiao_S3_WIO ; build_src_filter = ${Xiao_S3_WIO.build_src_filter} From ebfeda81a52722cafe5c6432e6e2f011e7d1857c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ma=C5=82ecki?= Date: Sat, 6 Jun 2026 01:44:41 +0200 Subject: [PATCH 02/12] refactor: reduce upstream merge conflict surface for cloud OTA CommonCLI.cpp: move cloud OTA else-if blocks from the middle of the chain to the very end (just before the Unknown command fallback), wrapped in #if defined(WIFI_INTERNET_OTA) so they are invisible in non-PoLi builds and clearly scoped in diffs. Also drop the empty if(!board->...) {} pattern. MeshCore.h: add [PoLi] comment marker on the virtual methods so they are easily identifiable during upstream merges. Co-Authored-By: Claude Sonnet 4.6 --- src/MeshCore.h | 5 +++-- src/helpers/CommonCLI.cpp | 18 ++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/MeshCore.h b/src/MeshCore.h index 47b3a7d00d..daef71bc83 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -58,8 +58,9 @@ class MainBoard { virtual uint8_t getStartupReason() const = 0; virtual bool getBootloaderVersion(char* version, size_t max_len) { return false; } virtual bool startOTAUpdate(const char* id, char reply[]) { return false; } // not supported - virtual bool startInternetOTA(const char* firmware_version, char reply[]) { return false; } // not supported - virtual bool checkInternetOTA(const char* firmware_version, char reply[]) { return false; } // not supported + // [PoLi] internet OTA — pull firmware from meshcore.epila.pl + virtual bool startInternetOTA(const char* firmware_version, char reply[]) { return false; } + virtual bool checkInternetOTA(const char* firmware_version, char reply[]) { return false; } // Power management interface (boards with power management override these) virtual bool isExternalPowered() { return false; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 11072b4e20..b8d0cf3152 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -238,16 +238,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re if (!_board->startOTAUpdate(_prefs->node_name, reply)) { strcpy(reply, "Error"); } - } else if (strcmp(command, "check cloud ota") == 0) { - if (!_board->checkInternetOTA(_callbacks->getFirmwareVer(), reply)) { - // reply already set by checkInternetOTA - } - } else if (strcmp(command, "start cloud ota") == 0) { - strcpy(reply, "Starting cloud OTA..."); - if (!_board->startInternetOTA(_callbacks->getFirmwareVer(), reply)) { - // reply already set by startInternetOTA on error - } - // on success, board reboots — this line is never reached } else if (memcmp(command, "clock", 5) == 0) { uint32_t now = getRTCClock()->getCurrentTime(); DateTime dt = DateTime(now); @@ -467,6 +457,14 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _callbacks->formatRadioStatsReply(reply); } else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { _callbacks->formatStatsReply(reply); +#if defined(WIFI_INTERNET_OTA) // [PoLi] internet OTA commands + } else if (strcmp(command, "check cloud ota") == 0) { + _board->checkInternetOTA(_callbacks->getFirmwareVer(), reply); + } else if (strcmp(command, "start cloud ota") == 0) { + strcpy(reply, "Starting cloud OTA..."); + _board->startInternetOTA(_callbacks->getFirmwareVer(), reply); + // on success, board reboots — reply is set by startInternetOTA on error +#endif } else { strcpy(reply, "Unknown command"); } From bb1eb1ced13b84520e912802e4ee25e5d1f24656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ma=C5=82ecki?= Date: Sat, 6 Jun 2026 01:54:11 +0200 Subject: [PATCH 03/12] refactor: replace WIFI_NETWORKS string with numbered WIFI_SSID_N/WIFI_PWD_N pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semicolon-delimited WIFI_NETWORKS string was fragile (broke on passwords containing ':' or ';') and hard to read in platformio.local.ini. - WiFiConnect.h: remove addNetworksFromString() — parser no longer needed - ESP32Board.cpp: _ensure_wifi_configured() now uses WIFI_SSID_1/WIFI_PWD_1 through WIFI_SSID_5/WIFI_PWD_5 (up to 5 networks, all optional) - All cloud_ota variant envs: remove WIFI_NETWORKS placeholder; add comment documenting the WIFI_SSID_N/WIFI_PWD_N format for platformio.local.ini - Also removes MESH_PACKET_LOGGING/MESH_DEBUG from cloud_ota envs (debug flags don't belong in production builds) WiFi credentials belong exclusively in platformio.local.ini (gitignored). Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ESP32Board.cpp | 29 +++++++++++++++-------------- src/helpers/esp32/WiFiConnect.h | 19 ------------------- variants/heltec_v3/platformio.ini | 7 ++++--- variants/heltec_v4/platformio.ini | 9 ++++----- variants/xiao_s3_wio/platformio.ini | 5 ++++- 5 files changed, 27 insertions(+), 42 deletions(-) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index ccc7ca410c..aeee1ea82b 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -56,20 +56,21 @@ static bool _ota_wifi_configured = false; static void _ensure_wifi_configured() { if (_ota_wifi_configured) return; _ota_wifi_configured = true; - -#if defined(WIFI_NETWORKS) - _ota_wifi.addNetworksFromString(WIFI_NETWORKS); -#else - // Fall back to single SSID/password pair if defined - #if defined(WIFI_SSID) && defined(WIFI_PWD) - _ota_wifi.addNetwork(WIFI_SSID, WIFI_PWD); - #endif - #if defined(WIFI_SSID_2) && defined(WIFI_PWD_2) - _ota_wifi.addNetwork(WIFI_SSID_2, WIFI_PWD_2); - #endif - #if defined(WIFI_SSID_3) && defined(WIFI_PWD_3) - _ota_wifi.addNetwork(WIFI_SSID_3, WIFI_PWD_3); - #endif + // Configure via WIFI_SSID_1/WIFI_PWD_1 … WIFI_SSID_5/WIFI_PWD_5 in platformio.local.ini +#if defined(WIFI_SSID_1) && defined(WIFI_PWD_1) + _ota_wifi.addNetwork(WIFI_SSID_1, WIFI_PWD_1); +#endif +#if defined(WIFI_SSID_2) && defined(WIFI_PWD_2) + _ota_wifi.addNetwork(WIFI_SSID_2, WIFI_PWD_2); +#endif +#if defined(WIFI_SSID_3) && defined(WIFI_PWD_3) + _ota_wifi.addNetwork(WIFI_SSID_3, WIFI_PWD_3); +#endif +#if defined(WIFI_SSID_4) && defined(WIFI_PWD_4) + _ota_wifi.addNetwork(WIFI_SSID_4, WIFI_PWD_4); +#endif +#if defined(WIFI_SSID_5) && defined(WIFI_PWD_5) + _ota_wifi.addNetwork(WIFI_SSID_5, WIFI_PWD_5); #endif } diff --git a/src/helpers/esp32/WiFiConnect.h b/src/helpers/esp32/WiFiConnect.h index efc3fd11ba..796a62f023 100644 --- a/src/helpers/esp32/WiFiConnect.h +++ b/src/helpers/esp32/WiFiConnect.h @@ -20,25 +20,6 @@ class WiFiConnect { _multi.addAP(ssid, password); } - // Parse a semicolon-delimited list "ssid1:pwd1;ssid2:pwd2;..." - void addNetworksFromString(const char* networks_str) { - char buf[256]; - strncpy(buf, networks_str, sizeof(buf) - 1); - buf[sizeof(buf) - 1] = '\0'; - - char* entry = strtok(buf, ";"); - while (entry != nullptr) { - char* colon = strchr(entry, ':'); - if (colon != nullptr) { - *colon = '\0'; - addNetwork(entry, colon + 1); - } else { - addNetwork(entry, ""); // open network - } - entry = strtok(nullptr, ";"); - } - } - // Connect to the best available network. Returns true on success. bool begin(uint32_t timeout_ms = WIFI_CONNECT_TIMEOUT_MS) { if (_started) return _connected; diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index fd01f1505c..e46abc2f98 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -59,6 +59,10 @@ lib_deps = bakercp/CRC32 @ ^2.0.0 ; Repeater with internet OTA support. +; WiFi credentials go in platformio.local.ini (not committed to git): +; -D WIFI_SSID_1='"MyNetwork"' -D WIFI_PWD_1='"password"' +; -D WIFI_SSID_2='"BackupNet"' -D WIFI_PWD_2='"password2"' +; (up to WIFI_SSID_5/WIFI_PWD_5) [env:Heltec_v3_repeater_cloud_ota] extends = Heltec_lora32_v3 build_flags = @@ -70,9 +74,6 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 - -D WIFI_NETWORKS='"MyNetwork:mypassword;BackupNetwork:backuppass"' ${poli_build.build_flags} build_src_filter = ${Heltec_lora32_v3.build_src_filter} + diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index d380866773..1d086d73da 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -104,8 +104,10 @@ lib_deps = bakercp/CRC32 @ ^2.0.0 ; Repeater with internet OTA support. -; Set WIFI_NETWORKS to a semicolon-separated list of "ssid:password" entries, -; or use WIFI_SSID/WIFI_PWD (+ optionally WIFI_SSID_2/WIFI_PWD_2, WIFI_SSID_3/WIFI_PWD_3). +; WiFi credentials go in platformio.local.ini (not committed to git): +; -D WIFI_SSID_1='"MyNetwork"' -D WIFI_PWD_1='"password"' +; -D WIFI_SSID_2='"BackupNet"' -D WIFI_PWD_2='"password2"' +; (up to WIFI_SSID_5/WIFI_PWD_5) [env:heltec_v4_repeater_cloud_ota] extends = heltec_v4_oled build_flags = @@ -117,9 +119,6 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 - -D WIFI_NETWORKS='"MyNetwork:mypassword;BackupNetwork:backuppass"' ${poli_build.build_flags} build_src_filter = ${heltec_v4_oled.build_src_filter} + diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index 5b2ba8b06a..901be86ddd 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -55,6 +55,10 @@ lib_deps = ${esp32_ota.lib_deps} ; Repeater with internet OTA support. +; WiFi credentials go in platformio.local.ini (not committed to git): +; -D WIFI_SSID_1='"MyNetwork"' -D WIFI_PWD_1='"password"' +; -D WIFI_SSID_2='"BackupNet"' -D WIFI_PWD_2='"password2"' +; (up to WIFI_SSID_5/WIFI_PWD_5) [env:Xiao_S3_WIO_repeater_cloud_ota] extends = Xiao_S3_WIO build_src_filter = ${Xiao_S3_WIO.build_src_filter} @@ -67,7 +71,6 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 - -D WIFI_NETWORKS='"MyNetwork:mypassword;BackupNetwork:backuppass"' ${poli_build.build_flags} lib_deps = ${Xiao_S3_WIO.lib_deps} From c2b2655b3306861502836292bea532984a35e058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ma=C5=82ecki?= Date: Sat, 6 Jun 2026 02:01:21 +0200 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20build=5Ffirmware.sh=20=E2=80=94=20?= =?UTF-8?q?find=20pio=20binary=20+=20preserve=20WiFi=20credentials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-detect pio at ~/.platformio/penv/bin/pio (macOS PlatformIO install does not add pio to PATH for non-interactive shells) - write_local_ini(): preserve existing WIFI_SSID_N/WIFI_PWD_N lines from platformio.local.ini before overwriting with new version/date - cleanup_ini(): never delete platformio.local.ini if it contains WiFi credentials (WIFI_SSID_N/WIFI_PWD_N) Co-Authored-By: Claude Sonnet 4.6 --- build_firmware.sh | 52 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/build_firmware.sh b/build_firmware.sh index 4b55dd83ae..5c0e2da5f8 100755 --- a/build_firmware.sh +++ b/build_firmware.sh @@ -47,6 +47,23 @@ BASE_URL="https://meshcore.epila.pl/firmware" VERSION_FILE="${SCRIPT_DIR}/poli_version.txt" LOCAL_INI="${SCRIPT_DIR}/platformio.local.ini" +# ─── PlatformIO binary ──────────────────────────────────────────────────────── +PIO_BIN="" +for _candidate in \ + "$(which pio 2>/dev/null)" \ + "${HOME}/.platformio/penv/bin/pio" \ + "${HOME}/.local/bin/pio"; do + if [[ -x "$_candidate" ]]; then + PIO_BIN="$_candidate" + break + fi +done +if [[ -z "$PIO_BIN" ]]; then + err "PlatformIO (pio) not found. Install from https://platformio.org or run:" + err " pip install platformio" + exit 1 +fi + # ─── Board definitions ──────────────────────────────────────────────────────── # Format: "BOARD_ID|PIO_ENV_NAME" # BOARD_ID – used as subdirectory in data/firmware/ and BOARD_ID define @@ -142,12 +159,30 @@ filter_boards() { write_local_ini() { local version="$1" local build_date; build_date=$(date "+%d %b %Y") - # PlatformIO string macro: -D NAME='"value"' - printf \ -'; Generated by build_firmware.sh at %s\n; Remove or update before regular (non-PoLi) builds.\n[poli_build]\nbuild_flags =\n -D FIRMWARE_VERSION='"'"'"%s"'"'"'\n -D FIRMWARE_BUILD_DATE='"'"'"%s"'"'"'\n' \ - "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$version" "$build_date" \ - > "$LOCAL_INI" + + # Preserve WIFI_SSID_N / WIFI_PWD_N lines from existing file + local wifi_lines="" + if [[ -f "$LOCAL_INI" ]]; then + wifi_lines=$(grep -E "^\s+-D WIFI_(SSID|PWD)_[0-9]" "$LOCAL_INI" || true) + fi + + { + printf '; Generated by build_firmware.sh at %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + printf '; WiFi credentials (WIFI_SSID_N/WIFI_PWD_N) are preserved across runs.\n' + printf '[poli_build]\nbuild_flags =\n' + printf " -D FIRMWARE_VERSION='\"${version}\"'\n" + printf " -D FIRMWARE_BUILD_DATE='\"${build_date}\"'\n" + if [[ -n "$wifi_lines" ]]; then + printf '%s\n' "$wifi_lines" + else + printf '; No WiFi credentials found — add them manually:\n' + printf '; -D WIFI_SSID_1='"'"'"YourSSID"'"'"'\n' + printf '; -D WIFI_PWD_1='"'"'"YourPassword"'"'"'\n' + fi + } > "$LOCAL_INI" + ok "platformio.local.ini → ${version} / ${build_date}" + [[ -n "$wifi_lines" ]] && ok " WiFi credentials preserved" } # ─── Build one board ───────────────────────────────────────────────────────── @@ -163,7 +198,7 @@ build_board() { fi # Run PlatformIO; stream output; propagate exit code without triggering set -e - if ! pio run -e "$pio_env"; then + if ! "$PIO_BIN" run -e "$pio_env"; then err "Build failed for ${board_id}" return 1 fi @@ -218,6 +253,11 @@ cleanup_ini() { dim " platformio.local.ini kept" return fi + # Never remove if WiFi credentials are present + if [[ -f "$LOCAL_INI" ]] && grep -qE "WIFI_(SSID|PWD)_[0-9]" "$LOCAL_INI" 2>/dev/null; then + dim " platformio.local.ini kept (contains WiFi credentials)" + return + fi if [[ -f "$LOCAL_INI" ]]; then rm "$LOCAL_INI" dim " platformio.local.ini removed" From e1f18b83377ba7851c39000e41038312f511c9f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ma=C5=82ecki?= Date: Sat, 6 Jun 2026 02:08:26 +0200 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20build=5Ffirmware.sh=20=E2=80=94=20?= =?UTF-8?q?two=20set=20-e=20traps=20+=20dry-run=20directory=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - write_local_ini: replace `[[ -n ]] && cmd` with `if/fi` — the short-circuit false exit code was killing the script under set -e when no WiFi credentials were present (caused silent exit after Version config, before build loop) - deploy_board: move DRY_RUN check before the directory existence check so dry-run no longer requires the deploy directories to exist Co-Authored-By: Claude Sonnet 4.6 --- build_firmware.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/build_firmware.sh b/build_firmware.sh index 5c0e2da5f8..51c57cd0d7 100755 --- a/build_firmware.sh +++ b/build_firmware.sh @@ -182,7 +182,11 @@ write_local_ini() { } > "$LOCAL_INI" ok "platformio.local.ini → ${version} / ${build_date}" - [[ -n "$wifi_lines" ]] && ok " WiFi credentials preserved" + if [[ -n "$wifi_lines" ]]; then + ok " WiFi credentials preserved" + else + warn " No WiFi credentials — add WIFI_SSID_N/WIFI_PWD_N to platformio.local.ini" + fi } # ─── Build one board ───────────────────────────────────────────────────────── @@ -227,12 +231,6 @@ deploy_board() { return 0 fi - if [[ ! -d "$dest_dir" ]]; then - err " Deploy dir missing: ${dest_dir}" - err " Create it manually or run: mkdir -p ${dest_dir}" - return 1 - fi - if [[ "$DRY_RUN" == "true" ]]; then warn " DRY-RUN: cp ${bin_src}" warn " → ${bin_dst}" @@ -240,6 +238,12 @@ deploy_board() { return 0 fi + if [[ ! -d "$dest_dir" ]]; then + err " Deploy dir missing: ${dest_dir}" + err " Create it manually or run: mkdir -p ${dest_dir}" + return 1 + fi + cp "$bin_src" "$bin_dst" ok " firmware.bin → ${bin_dst}" From 09a25055475023db44260519ce42d6026e0c921a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ma=C5=82ecki?= Date: Sat, 6 Jun 2026 02:12:33 +0200 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20build=5Ffirmware.sh=20=E2=80=94=20?= =?UTF-8?q?auto-create=20missing=20deploy=20directories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of failing when data/firmware// doesn't exist, create it with mkdir -p. Avoids manual intervention after git clean or fresh clone of meshcore.epila.pl. Also bump poli_version.txt to v1.15.0.6 (manually deployed). Co-Authored-By: Claude Sonnet 4.6 --- build_firmware.sh | 5 ++--- poli_version.txt | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/build_firmware.sh b/build_firmware.sh index 51c57cd0d7..fae2800742 100755 --- a/build_firmware.sh +++ b/build_firmware.sh @@ -239,9 +239,8 @@ deploy_board() { fi if [[ ! -d "$dest_dir" ]]; then - err " Deploy dir missing: ${dest_dir}" - err " Create it manually or run: mkdir -p ${dest_dir}" - return 1 + mkdir -p "$dest_dir" + ok " Created: ${dest_dir}" fi cp "$bin_src" "$bin_dst" diff --git a/poli_version.txt b/poli_version.txt index fbe3a8d0f1..deeb7ec0be 100644 --- a/poli_version.txt +++ b/poli_version.txt @@ -1 +1 @@ -v1.15.0.0 +v1.15.0.6 From 6deaeed19a1ed582135c767c22baf374229e5098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ma=C5=82ecki?= Date: Sat, 6 Jun 2026 02:15:47 +0200 Subject: [PATCH 07/12] new release --- poli_version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poli_version.txt b/poli_version.txt index deeb7ec0be..dc65b52afd 100644 --- a/poli_version.txt +++ b/poli_version.txt @@ -1 +1 @@ -v1.15.0.6 +v1.15.0.7 From c4c6395e3e78642cc80d23db54e4bb65ddcaf5c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ma=C5=82ecki?= Date: Sat, 6 Jun 2026 02:28:35 +0200 Subject: [PATCH 08/12] feat: OTA token authentication for firmware downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware binary contains compiled-in WiFi credentials — the download endpoint needs protection so random internet users can't grab the .bin and extract passwords with `strings`. Solution: shared secret token appended as ?token= query parameter to all firmware requests. Server validates the token in nginx; token is compiled into firmware via OTA_TOKEN define (platformio.local.ini, gitignored). - InternetOTA.h: OTA_TOKEN define (default empty); append ?token= to manifest URL and firmware download URL - build_firmware.sh: preserve OTA_TOKEN in platformio.local.ini across runs (same mechanism as WIFI_SSID_N/WIFI_PWD_N) Token is set in platformio.local.ini and nginx.conf (private repo only). Co-Authored-By: Claude Sonnet 4.6 --- build_firmware.sh | 4 ++-- src/helpers/esp32/InternetOTA.h | 23 +++++++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/build_firmware.sh b/build_firmware.sh index fae2800742..ecad3d6e04 100755 --- a/build_firmware.sh +++ b/build_firmware.sh @@ -163,7 +163,7 @@ write_local_ini() { # Preserve WIFI_SSID_N / WIFI_PWD_N lines from existing file local wifi_lines="" if [[ -f "$LOCAL_INI" ]]; then - wifi_lines=$(grep -E "^\s+-D WIFI_(SSID|PWD)_[0-9]" "$LOCAL_INI" || true) + wifi_lines=$(grep -E "^\s+-D (WIFI_(SSID|PWD)_[0-9]+|OTA_TOKEN)" "$LOCAL_INI" || true) fi { @@ -257,7 +257,7 @@ cleanup_ini() { return fi # Never remove if WiFi credentials are present - if [[ -f "$LOCAL_INI" ]] && grep -qE "WIFI_(SSID|PWD)_[0-9]" "$LOCAL_INI" 2>/dev/null; then + if [[ -f "$LOCAL_INI" ]] && grep -qE "WIFI_(SSID|PWD)_[0-9]+|OTA_TOKEN" "$LOCAL_INI" 2>/dev/null; then dim " platformio.local.ini kept (contains WiFi credentials)" return fi diff --git a/src/helpers/esp32/InternetOTA.h b/src/helpers/esp32/InternetOTA.h index 998ae98b5a..78aa45675e 100644 --- a/src/helpers/esp32/InternetOTA.h +++ b/src/helpers/esp32/InternetOTA.h @@ -20,6 +20,13 @@ #define BOARD_ID "unknown" #endif +// OTA download token — must match the token configured in nginx.conf on the server. +// Set via -D OTA_TOKEN='"token"' in platformio.local.ini (gitignored, never committed). +// If empty, requests are sent without a token (server will reject with 401). +#ifndef OTA_TOKEN + #define OTA_TOKEN "" +#endif + struct OTAManifest { char version[32]; // e.g. "v1.15.1" char url[256]; // full URL to .bin @@ -77,7 +84,11 @@ class InternetOTA { } char url[320]; - snprintf(url, sizeof(url), "%s/%s/manifest.json", OTA_BASE_URL, BOARD_ID); + if (strlen(OTA_TOKEN) > 0) { + snprintf(url, sizeof(url), "%s/%s/manifest.json?token=%s", OTA_BASE_URL, BOARD_ID, OTA_TOKEN); + } else { + snprintf(url, sizeof(url), "%s/%s/manifest.json", OTA_BASE_URL, BOARD_ID); + } WiFiClientSecure tls; tls.setInsecure(); // skip cert validation — known server, firmware hash verified by ESP-IDF @@ -138,7 +149,15 @@ class InternetOTA { httpUpdate.rebootOnUpdate(false); // we reboot ourselves after logging - t_httpUpdate_return ret = httpUpdate.update(tls, manifest.url); + char fw_url[320]; + if (strlen(OTA_TOKEN) > 0) { + snprintf(fw_url, sizeof(fw_url), "%s?token=%s", manifest.url, OTA_TOKEN); + } else { + strncpy(fw_url, manifest.url, sizeof(fw_url) - 1); + fw_url[sizeof(fw_url) - 1] = '\0'; + } + + t_httpUpdate_return ret = httpUpdate.update(tls, fw_url); switch (ret) { case HTTP_UPDATE_FAILED: From 05a29f8d04b967954c81836d20babcf9f1a5b81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ma=C5=82ecki?= Date: Sat, 6 Jun 2026 02:49:43 +0200 Subject: [PATCH 09/12] feat: auto internet OTA check every 15 min in repeater When WIFI_INTERNET_OTA is defined (cloud_ota envs), the repeater checks meshcore.epila.pl for a new firmware version every 15 minutes. First check fires 5 minutes after boot (let the device stabilize). If an update is available it is downloaded and flashed immediately; the device reboots into the new firmware. No update = no action. Result is logged to Serial as [OTA] . Co-Authored-By: Claude Sonnet 4.6 --- examples/simple_repeater/main.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index e37078ce5f..892d1dedfc 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -154,6 +154,18 @@ void loop() { #endif rtc_clock.tick(); +#if defined(WIFI_INTERNET_OTA) + { + static uint32_t _next_ota_ms = 5UL * 60 * 1000; // first check 5 min after boot + if (millis() >= _next_ota_ms) { + _next_ota_ms = millis() + 15UL * 60 * 1000; // reschedule before check (avoid re-entry on slow WiFi) + char ota_reply[160]; + board.startInternetOTA(FIRMWARE_VERSION, ota_reply); + Serial.printf("[OTA] %s\n", ota_reply); + } + } +#endif + if (the_mesh.getNodePrefs()->powersaving_enabled && !the_mesh.hasPendingWork()) { #if defined(NRF52_PLATFORM) board.sleep(1800); // nrf ignores seconds param, sleeps whenever possible From c4dc681bbf30eb5db0b0dd5044af7a7521df01fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ma=C5=82ecki?= Date: Sat, 6 Jun 2026 02:54:21 +0200 Subject: [PATCH 10/12] feat: auto git commit & push website after successful build After deploying firmware.bin and manifest.json to meshcore.epila.pl/data/firmware/, automatically run git add + commit + push in the website repo so pull/push stays in sync and manual git operations are no longer needed after each build. Skipped when: --dry-run, --no-deploy, or all boards failed. Co-Authored-By: Claude Sonnet 4.6 --- build_firmware.sh | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/build_firmware.sh b/build_firmware.sh index ecad3d6e04..0975cf93ae 100755 --- a/build_firmware.sh +++ b/build_firmware.sh @@ -344,23 +344,40 @@ main() { save_version "$VERSION" fi + # ── Git commit & push website ── + if [[ "$NO_DEPLOY" != "true" && "$DRY_RUN" != "true" && ${#success[@]} -gt 0 ]]; then + local website_git; website_git="$(dirname "$WEBSITE_DIR")" + website_git="$(dirname "$website_git")" # up from data/firmware → repo root + if [[ -d "${website_git}/.git" ]]; then + hdr "Publishing website" + git -C "$website_git" add data/firmware/ + if git -C "$website_git" diff --cached --quiet; then + dim " No changes to commit in website repo" + else + git -C "$website_git" commit -m "chore: firmware ${VERSION} for ${success[*]}" + if git -C "$website_git" push; then + ok " Pushed meshcore.epila.pl → origin" + else + warn " Push failed — commit is local, push manually" + fi + fi + else + warn " Website dir is not a git repo: ${website_git}" + fi + fi + # ── Summary ── hdr "Summary" info "Version : ${VERSION}" - [[ ${#success[@]} -gt 0 ]] && ok "Built OK : ${success[*]}" - [[ ${#failed[@]} -gt 0 ]] && err "Failed : ${failed[*]}" + if [[ ${#success[@]} -gt 0 ]]; then ok "Built OK : ${success[*]}"; fi + if [[ ${#failed[@]} -gt 0 ]]; then err "Failed : ${failed[*]}"; fi if [[ "$NO_DEPLOY" != "true" && "$DRY_RUN" != "true" && ${#success[@]} -gt 0 ]]; then - echo "" - echo -e "${BOLD}Next – push to server:${RESET}" - echo " rsync -av --progress \\" - echo " ${WEBSITE_DIR}/ \\" - echo " user@server:/opt/meshcore-epila/data/firmware/" echo "" echo -e "${BOLD}Verify manifests:${RESET}" for def in "${ACTIVE_BOARDS[@]}"; do local bid="${def%%|*}" - echo " curl -s ${BASE_URL}/${bid}/manifest.json" + echo " curl -s \"${BASE_URL}/${bid}/manifest.json?token=\$(grep OTA_TOKEN ${LOCAL_INI} 2>/dev/null | grep -o '\"[^\"]*\"' | tail -1 | tr -d '\"')\"" done fi From 8dc33a3bc94a86b0a349951dffd443b350355e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ma=C5=82ecki?= Date: Sat, 6 Jun 2026 03:02:27 +0200 Subject: [PATCH 11/12] fix: restore MESH_PACKET_LOGGING and MESH_DEBUG in cloud_ota envs Flags were removed without asking during WiFi config refactor. Restored to match the standard repeater env behaviour. Co-Authored-By: Claude Sonnet 4.6 --- variants/heltec_v3/platformio.ini | 2 ++ variants/heltec_v4/platformio.ini | 2 ++ variants/xiao_s3_wio/platformio.ini | 2 ++ 3 files changed, 6 insertions(+) diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index e46abc2f98..4828312e4c 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -74,6 +74,8 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 + -D MESH_PACKET_LOGGING=1 + -D MESH_DEBUG=1 ${poli_build.build_flags} build_src_filter = ${Heltec_lora32_v3.build_src_filter} + diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 1d086d73da..508cbac46e 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -119,6 +119,8 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 + -D MESH_PACKET_LOGGING=1 + -D MESH_DEBUG=1 ${poli_build.build_flags} build_src_filter = ${heltec_v4_oled.build_src_filter} + diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index 901be86ddd..617f23e233 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -71,6 +71,8 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 + -D MESH_PACKET_LOGGING=1 + -D MESH_DEBUG=1 ${poli_build.build_flags} lib_deps = ${Xiao_S3_WIO.lib_deps} From 3b398af1d41e4f2fd9bc32bcde8685d8bcf1377d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ma=C5=82ecki?= Date: Sat, 6 Jun 2026 22:25:26 +0200 Subject: [PATCH 12/12] . --- .vscode/extensions.json | 2 ++ poli_version.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8057bc70a7..5c5735c63b 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,4 +1,6 @@ { + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format "recommendations": [ "pioarduino.pioarduino-ide", "platformio.platformio-ide" diff --git a/poli_version.txt b/poli_version.txt index dc65b52afd..32a72a1f5a 100644 --- a/poli_version.txt +++ b/poli_version.txt @@ -1 +1 @@ -v1.15.0.7 +v1.15.1.3