From 861cae8680c491f092a4de732d8021854faa53b8 Mon Sep 17 00:00:00 2001 From: Stas Alekseev <100800+salekseev@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:38:44 -0400 Subject: [PATCH 1/5] build(ble)!: migrate to esp-nimble-cpp on NanoPbComm (pioarduino dual-framework) (#681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * port(ble): NanoPbComm transport + ble_ota_dfu to esp-nimble-cpp 2.x API Port the BLE transport layer from NimBLE-Arduino 1.4.0 to the esp-nimble-cpp 2.x API surface, retargeting the esp-nimble-cpp migration onto master's NanoPbComm framed-protocol comms (which replaced NimBLEComm in #726). Client transport: - setPower(ESP_PWR_LVL_P9) -> setPower(9): 2.x takes int8_t dBm; the enum (=11 on ESP32-S3) quantizes to +12 dBm, 9 preserves +9 dBm. - NimBLEAdvertisedDeviceCallbacks -> NimBLEScanCallbacks base; const onResult. - setAdvertisedDeviceCallbacks(cb,wantDuplicates) -> setScanCallbacks(cb,false) (we own the object; wantDuplicates covered by setDuplicateFilter(false)). - scan start(0,nullptr,false) -> start(0,false,false); onDisconnect(+int reason). Server transport: - setPower(9); setScanResponse -> enableScanResponse. - onConnect/onDisconnect/onWrite/onSubscribe gain NimBLEConnInfo& (and int reason on disconnect); ble_gap_conn_desc* -> NimBLEConnInfo&. ble_ota_dfu already carries the 2.x onWrite(NimBLEConnInfo&) signature. Co-Authored-By: Claude Code * build(ble)!: re-migrate to pioarduino + esp-nimble-cpp on NanoPbComm Re-apply the esp-nimble-cpp / pioarduino build migration on top of master, which had diverged (replaced NimBLEComm with NanoPbComm, stayed on espressif32@6.12.0 + NimBLE-Arduino 1.4.0). Master never adopted pioarduino, so this is a re-migration, not a rebase. Platform / build: - platform -> pioarduino 55.03.31-2 (Arduino 3.3.1 / IDF 5.5.1); 55.03.32+ ships a libbtdm_app.a that corrupts TLSF metadata on ESP32-S3 v0.2. - framework = arduino, espidf (dual-framework); IDF drives the build via sdkconfig.common + per-class (gaggimate/controller) defaults. - src/idf_component.yml registers h2zero/esp-nimble-cpp (~2.5.0); drop h2zero/NimBLE-Arduino from all lib_deps + library.json. - scripts/pioarduino_env.py injects IDF bt/NimBLE + managed-component include paths into the Arduino C++ pass (LDF can't see IDF REQUIRES propagation). - C++17 -> C++20; root + src CMakeLists; partitions/*.csv. - Keep master's nanopb codegen (custom_nanopb_protos) + NanoPbComm + the native_autotune test env. Bump AsyncTCP 3.4.10 / ESPAsyncWebServer 3.10.3 / HomeSpan 2.1.7 / GFX 1.6.5; add esp-memoryMonitor; scales -> esp-nimble-cpp fork. BREAKING: remove display-headless-4m env + esp32-s3-supermini board (last pure-Arduino target). Migrate to display-headless (seeed_xiao_esp32s3, 8 MB). Source compat (Core 3.x / GCC 14 / IDF 5.5): - xTaskHandle -> TaskHandle_t; sntp_* -> esp_sntp_*; OTA setCACertBundle length arg; HomeSpan 2.x PImpl in HomekitPlugin; GFX 1.6.5 driver API; lambda [=]->[this] captures; LOG_TAG const char*. Observability + fixes folded in: esp-memoryMonitor heap monitor (MemoryMonitor + WS status fields), homekit WiFi-watchdog boot-loop fix + nano-newlib disable, OTA CA-bundle reattach, ble-scale 2 s reconnect throttle, deferred Settings NVS load. Co-Authored-By: Claude Code * fix(build): resolve esp-nimble-cpp dual-framework build errors Compile fixes found building -e controller and -e display green under pioarduino 55.03.31-2 (Python 3.13 penv via uv): - NanoPbComm/library.json: drop residual h2zero/NimBLE-Arduino dep — it dragged NimBLE-Arduino into the controller build alongside esp-nimble-cpp (duplicate NimBLE + nimble_npl_os.h esp_timer_handle_t error). - DigitalInput.h: xTaskHandle -> TaskHandle_t (residual; master's debounce rewrite reintroduced it post branch-merge; removed in Core 3.x). - BleClientTransport: clearDuplicateCache() -> clearResults(); esp-nimble-cpp 2.x NimBLEScan has no clearDuplicateCache(). - sdkconfig.gaggimate.defaults: CONFIG_COMPILER_CXX_EXCEPTIONS=y — the esp-arduino-ble-scales RemoteScales dtor uses try/catch; IDF defaults to -fno-exceptions (arduino-esp32 built with them on). Display-class only; controller stays -fno-exceptions (no scales link). Builds: controller 12.5% flash / 11.3% RAM; display 84.6% flash / 19.2% RAM. Co-Authored-By: Claude Code * refactor(build): dedup platformio env config + tidy BLE callback params /simplify cleanup (behavior-preserving; controller 12.5% / display 84.6% flash unchanged): - platformio.ini: hoist the duplicated custom_component_remove + lib_ignore + custom_nanopb_protos/options into a shared [idf_common] anchor (mirrors the existing [display_common] pattern); controller references it and appends its one extra strip (espressif/libsodium). Drop redundant per-env 'framework = arduino, espidf' (inherited from [env]) and the byte-identical cmake_extra_args in display-headless-8m (inherited from display-headless). - NanoPbComm BLE transports: drop (void) casts on unused NimBLE callback params in favour of unnamed parameters, matching the adjacent ble_ota_dfu style. Co-Authored-By: Claude Code * fix(ota,ble): code-review findings — controller OTA CA bundle + scan dup-filter - GitHubOTA::update(): attach the CA bundle before the controller .bin HTTPS download. The per-call-site CA-bundle refactor (replacing the one-time ctor attach) covered update_firmware/update_filesystem but missed the controller path, so controller OTA could hit GitHub with no trusted roots → TLS handshake failure (masked only when a prior call left the bundle attached to the client). - BleClientTransport::scan(): pass wantDuplicates=true to setScanCallbacks (2.x arg is wantDuplicates, NOT deleteCallbacks as the comment claimed). false set the duplicate filter ON, the opposite of the 1.x intent; it was saved only by the later setDuplicateFilter(false). Now both agree and the comment is correct. Both envs build green (controller 12.5% / display 84.6% flash). Co-Authored-By: Claude Code * fix: address PR review comments (CodeRabbit) - platformio.ini: pin esp-arduino-ble-scales fork to commit SHA 1daafb4 instead of the mutable feat/esp-nimble-cpp branch ref, for reproducible builds (temp, pending upstream gaggimate/esp-arduino-ble-scales#28). - platformio.ini: drop redundant -std=c++20 in display_common + controller build_flags (gnu++20 already wins; GCC uses the last -std). - MemoryMonitor.cpp: g_ready -> std::atomic (written by init() on the setup task, read from WebUI/OTA tasks — was a data race on a plain bool). - HomekitPlugin.cpp: actionRequired -> std::atomic (set in the HomeSpan autoPoll task callback, read+cleared in the Arduino loop task). - ble_ota_dfu.cpp: call pServiceOTA->start() in configure_OTA() — a no-op under esp-nimble-cpp 2.x (services register via NimBLEServer::start()) but kept for symmetry with the comms service and to document intent. Both envs build green (controller 12.5% / display 84.6% flash). Co-Authored-By: Claude Code * fix(ble-scale): drop runtime scan gates; rely on sdkconfig PSRAM routing Addresses maintainer review on PR #681. - Remove the AP-mode scan gate (autoScanAllowed() returned false in WiFi AP mode) — it blocked BLE scale use during setup/AP, a regression vs master. - Remove the kMinInternalForScan=40000 runtime heap-floor defer in scan(). Both gates (ff9ec5ee) were added on the old pure-Arduino build with no PSRAM routing. The dual-framework sdkconfig now routes the NimBLE host + WiFi/LWIP to PSRAM and reserves 16 KB internal for the DMA-bound BT controller, so the internal-heap pressure is handled at the config layer, not a runtime bandaid. - Remove the now-pointless controller:wifi:connect handler and unwrap the bluetooth:connect / mode:change handlers to master's unconditional scan(). - Kept: the 2 s reconnect throttle and scale-metadata polling (unrelated). - Reword the setPower(9) comments: +9 dBm preserves master's setPower(ESP_PWR_LVL_P9) (1.4.x was +9 dBm); the +12 note is the avoided trap of passing the enum value to the 2.x dBm API, not a behavior change. Both envs build green (controller 12.5% / display 84.6% flash). Co-Authored-By: Claude Code * refactor: drop orphaned Controller::isApMode() getter Its only caller was BLEScalePlugin::autoScanAllowed(), removed in the previous commit. The isApConnection member stays (used internally). Restores parity with master, which has no such getter. /code-review cleanup; no behavior change. * fix(review): address CodeRabbit findings - TemperatureSensor.h: add virtual destructor to the abstract interface (deleting a concrete sensor via TemperatureSensor* was UB); mirrors Pump.h. - platformio.ini: drop unused -DPARTITION_CSV_PATH from env:display cmake_extra_args. No CMakeLists reads it and the headless envs already omit it; board_build.partitions is the authoritative partition source. HomeSpan-ownership comment and ble_ota_dfu service start were already present; the -std=c++20 redundancy was already removed in the dedup refactor. Co-Authored-By: [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Code Co-authored-by: Jochen Niebuhr --- .gitignore | 19 ++ CMakeLists.txt | 3 + boards/esp32-s3-supermini.json | 54 ----- lib/GaggiMateController/library.json | 1 - .../src/peripherals/DigitalInput.h | 4 +- .../src/peripherals/DimmedPump.h | 2 +- .../src/peripherals/DistanceSensor.h | 2 +- .../src/peripherals/Heater.h | 2 +- .../src/peripherals/Max31855Thermocouple.h | 4 +- .../src/peripherals/PressureSensor.h | 4 +- .../src/peripherals/Pump.h | 6 +- .../src/peripherals/SimplePump.h | 2 +- .../src/peripherals/TemperatureSensor.h | 6 +- lib/NanoPbComm/library.json | 3 +- lib/NanoPbComm/src/ble/BleClientTransport.cpp | 24 ++- lib/NanoPbComm/src/ble/BleClientTransport.h | 6 +- lib/NanoPbComm/src/ble/BleServerTransport.cpp | 20 +- lib/NanoPbComm/src/ble/BleServerTransport.h | 8 +- lib/OTA/library.properties | 9 + lib/OTA/src/ControllerOTA.cpp | 1 + lib/OTA/src/GitHubOTA.cpp | 13 +- lib/OTA/src/GitHubOTA.h | 1 + lib/OTA/src/common.cpp | 9 + lib/OTA/src/common.h | 2 + lib/ble_ota_dfu/library.properties | 10 + lib/ble_ota_dfu/src/ble_ota_dfu.cpp | 61 +++--- lib/ble_ota_dfu/src/ble_ota_dfu.hpp | 3 +- partitions/default_16mb.csv | 7 + partitions/default_8mb.csv | 7 + platformio.ini | 186 +++++++++------- scripts/pioarduino_env.py | 126 +++++++++++ sdkconfig.common.defaults | 80 +++++++ sdkconfig.controller.defaults | 20 ++ sdkconfig.gaggimate.defaults | 56 +++++ src/CMakeLists.txt | 26 +++ src/display/core/Controller.cpp | 28 ++- src/display/core/Controller.h | 4 +- src/display/core/MemoryMonitor.cpp | 104 +++++++++ src/display/core/MemoryMonitor.h | 15 ++ src/display/core/Settings.cpp | 11 +- src/display/core/Settings.h | 19 +- src/display/core/utils.cpp | 102 +++++++-- src/display/core/utils.h | 21 ++ .../AmoledDisplay/Amoled_DisplayPanel.cpp | 14 +- src/display/drivers/AmoledDisplay/CO5300.cpp | 4 +- src/display/drivers/AmoledDisplay/CO5300.h | 4 +- src/display/drivers/AmoledDisplayDriver.cpp | 18 +- src/display/drivers/Driver.h | 10 +- .../drivers/LilyGo-T-RGB/LilyGo_RGBPanel.cpp | 20 +- .../drivers/Waveshare/WavesharePanel.cpp | 25 +-- src/display/plugins/AutoWakeupPlugin.cpp | 10 +- src/display/plugins/BLEScalePlugin.cpp | 6 +- src/display/plugins/BLEScalePlugin.h | 2 + src/display/plugins/HomekitPlugin.cpp | 198 ++++++++++++------ src/display/plugins/HomekitPlugin.h | 42 +--- src/display/plugins/MQTTPlugin.cpp | 14 +- src/display/plugins/ShotHistoryPlugin.h | 2 +- src/display/plugins/WebUIPlugin.cpp | 77 ++++++- src/display/plugins/WebUIPlugin.h | 1 + src/display/ui/default/DefaultUI.cpp | 188 +++++++++-------- src/display/ui/default/DefaultUI.h | 4 +- src/idf_component.yml | 6 + 62 files changed, 1230 insertions(+), 506 deletions(-) create mode 100644 CMakeLists.txt delete mode 100644 boards/esp32-s3-supermini.json create mode 100644 lib/OTA/library.properties create mode 100644 lib/ble_ota_dfu/library.properties create mode 100644 partitions/default_16mb.csv create mode 100644 partitions/default_8mb.csv create mode 100644 scripts/pioarduino_env.py create mode 100644 sdkconfig.common.defaults create mode 100644 sdkconfig.controller.defaults create mode 100644 sdkconfig.gaggimate.defaults create mode 100644 src/CMakeLists.txt create mode 100644 src/display/core/MemoryMonitor.cpp create mode 100644 src/display/core/MemoryMonitor.h create mode 100644 src/idf_component.yml diff --git a/.gitignore b/.gitignore index 5c6989713..51f6808be 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,24 @@ node_modules/ __pycache__ data/w .codebuddy +*.orig src/version.h src/display/ui/default/lvgl/project.info + +# pioarduino build artifacts (regenerated each build) +sdkconfig.display +sdkconfig.display-headless +sdkconfig.display-headless-8m +sdkconfig.controller +sdkconfig.defaults +managed_components/ +dependencies.lock +# pioarduino dual-framework hybrid-compile scaffold (auto-generated) +.dummy/ + +# Serial/device monitor captures +logs/ + +# Editor / agent workspaces +.serena/ +.claude/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 000000000..89df1498c --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,3 @@ +cmake_minimum_required(VERSION 3.16.0) +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(gaggimate) diff --git a/boards/esp32-s3-supermini.json b/boards/esp32-s3-supermini.json deleted file mode 100644 index 2ff2b4b49..000000000 --- a/boards/esp32-s3-supermini.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "build": { - "arduino":{ - "ldscript": "esp32s3_out.ld", - "partitions": "no_ota.csv", - "memory_type": "qio_qspi" - }, - "core": "esp32", - "extra_flags": [ - "-DARDUINO_ESP32S3_DEV", - "-DARDUINO_USB_MODE=1", - "-DARDUINO_RUNNING_CORE=1", - "-DARDUINO_EVENT_RUNNING_CORE=0", - "-DBOARD_HAS_PSRAM" - ], - "f_cpu": "240000000L", - "f_flash": "80000000L", - "flash_mode": "qio", - "psram_type": "qio", - "hwids": [ - [ - "0x303A", - "0x1001" - ] - ], - "mcu": "esp32s3", - "variant": "esp32s3" - }, - "connectivity": [ - "bluetooth", - "wifi" - ], - "debug": { - "default_tool": "esp-builtin", - "onboard_tools": [ - "esp-builtin" - ], - "openocd_target": "esp32s3.cfg" - }, - "frameworks": [ - "arduino", - "espidf" - ], - "name": "ESP32-S3-SuperMini (4MB Quad, 2MB Quad PSRAM)", - "upload": { - "flash_size": "4MB", - "maximum_ram_size": 327680, - "maximum_size": 4194304, - "require_upload_port": true, - "speed": 460800 - }, - "url": "https://docs.espressif.com/projects/esp-idf/en/latest/esp32s3/hw-reference/esp32s3/user-guide-devkitc-1.html", - "vendor": "Espressif" -} diff --git a/lib/GaggiMateController/library.json b/lib/GaggiMateController/library.json index d63bd81cf..dfe702ef4 100644 --- a/lib/GaggiMateController/library.json +++ b/lib/GaggiMateController/library.json @@ -17,7 +17,6 @@ "ble_ota_dfu": "file://lib/ble_ota_dfu", "NanoPbComm": "file://lib/NanoPbComm", "nanopb/Nanopb": "^0.4.9", - "h2zero/NimBLE-Arduino": "^1.4.0", "robtillaart/MAX31855": "^0.6.1", "bblanchon/ArduinoJson": "^7.2.1", "PSM": "https://github.com/gaggimate/PSM.Library.git", diff --git a/lib/GaggiMateController/src/peripherals/DigitalInput.h b/lib/GaggiMateController/src/peripherals/DigitalInput.h index b8ffb784b..828aa35f0 100644 --- a/lib/GaggiMateController/src/peripherals/DigitalInput.h +++ b/lib/GaggiMateController/src/peripherals/DigitalInput.h @@ -20,10 +20,10 @@ class DigitalInput { uint8_t _stable_counter = 0; int _current_state = 2; int _last_state = 2; - xTaskHandle taskHandle; + TaskHandle_t taskHandle; input_callback_t _callback; - const char *LOG_TAG = "Heater"; + const char *LOG_TAG = "DigitalInput"; static void loopTask(void *arg); }; diff --git a/lib/GaggiMateController/src/peripherals/DimmedPump.h b/lib/GaggiMateController/src/peripherals/DimmedPump.h index 8a516d9c4..c15a3983c 100644 --- a/lib/GaggiMateController/src/peripherals/DimmedPump.h +++ b/lib/GaggiMateController/src/peripherals/DimmedPump.h @@ -42,7 +42,7 @@ class DimmedPump : public Pump { PSM _psm; PressureSensor *_pressureSensor; PressureController _pressureController; - xTaskHandle taskHandle; + TaskHandle_t taskHandle; ControlMode _mode = ControlMode::POWER; float _power = 0.0f; diff --git a/lib/GaggiMateController/src/peripherals/DistanceSensor.h b/lib/GaggiMateController/src/peripherals/DistanceSensor.h index 831e90e3e..25531fd2f 100644 --- a/lib/GaggiMateController/src/peripherals/DistanceSensor.h +++ b/lib/GaggiMateController/src/peripherals/DistanceSensor.h @@ -17,7 +17,7 @@ class DistanceSensor { TwoWire *i2c; VL53L0X *tof; - xTaskHandle taskHandle; + TaskHandle_t taskHandle; distance_callback_t _callback; int measurements = 0; int currentMillis = 0; diff --git a/lib/GaggiMateController/src/peripherals/Heater.h b/lib/GaggiMateController/src/peripherals/Heater.h index ad2268b25..b0d377d52 100644 --- a/lib/GaggiMateController/src/peripherals/Heater.h +++ b/lib/GaggiMateController/src/peripherals/Heater.h @@ -46,7 +46,7 @@ class Heater { float calculateSafetyScaling(float tempError); TemperatureSensor *sensor; uint8_t heaterPin; - xTaskHandle taskHandle; + TaskHandle_t taskHandle; SimplePID *simplePid = nullptr; Autotune *autotuner = nullptr; diff --git a/lib/GaggiMateController/src/peripherals/Max31855Thermocouple.h b/lib/GaggiMateController/src/peripherals/Max31855Thermocouple.h index e01acb79c..777831d1f 100644 --- a/lib/GaggiMateController/src/peripherals/Max31855Thermocouple.h +++ b/lib/GaggiMateController/src/peripherals/Max31855Thermocouple.h @@ -27,7 +27,7 @@ class Max31855Thermocouple : public TemperatureSensor { private: MAX31855 *max31855; - xTaskHandle taskHandle; + TaskHandle_t taskHandle; int errorCount = 0; std::array resultBuffer{}; @@ -44,7 +44,7 @@ class Max31855Thermocouple : public TemperatureSensor { temperature_error_callback_t error_callback; const char *LOG_TAG = "Max31855Thermocouple"; - static void monitorTask(void *arg); + [[noreturn]] static void monitorTask(void *arg); }; #endif // MAX31855THERMOCOUPLE_H diff --git a/lib/GaggiMateController/src/peripherals/PressureSensor.h b/lib/GaggiMateController/src/peripherals/PressureSensor.h index b38d4913a..716df13c7 100644 --- a/lib/GaggiMateController/src/peripherals/PressureSensor.h +++ b/lib/GaggiMateController/src/peripherals/PressureSensor.h @@ -32,10 +32,10 @@ class PressureSensor { int16_t _adc_floor; ADS1115 *ads = nullptr; pressure_callback_t _callback; - xTaskHandle taskHandle; + TaskHandle_t taskHandle; const char *LOG_TAG = "PressureSensor"; - static void loopTask(void *arg); + [[noreturn]] static void loopTask(void *arg); }; #endif // PRESSURESENSOR_H diff --git a/lib/GaggiMateController/src/peripherals/Pump.h b/lib/GaggiMateController/src/peripherals/Pump.h index cd644eaa2..791ec1e4c 100644 --- a/lib/GaggiMateController/src/peripherals/Pump.h +++ b/lib/GaggiMateController/src/peripherals/Pump.h @@ -5,9 +5,9 @@ class Pump { public: virtual ~Pump() = default; - virtual void setup(); - virtual void loop(); - virtual void setPower(float setpoint); + virtual void setup() = 0; + virtual void loop() = 0; + virtual void setPower(float setpoint) = 0; }; #endif // PUMP_H diff --git a/lib/GaggiMateController/src/peripherals/SimplePump.h b/lib/GaggiMateController/src/peripherals/SimplePump.h index 2754e4a63..1bf077171 100644 --- a/lib/GaggiMateController/src/peripherals/SimplePump.h +++ b/lib/GaggiMateController/src/peripherals/SimplePump.h @@ -21,7 +21,7 @@ class SimplePump : public Pump { float _windowSize = 5000.0f; unsigned long windowStartTime = 0; unsigned long nextSwitchTime = 0; - xTaskHandle taskHandle; + TaskHandle_t taskHandle; const char *LOG_TAG = "SimplePump"; static void loopTask(void *arg); diff --git a/lib/GaggiMateController/src/peripherals/TemperatureSensor.h b/lib/GaggiMateController/src/peripherals/TemperatureSensor.h index d1073e6d9..41b8d75d8 100644 --- a/lib/GaggiMateController/src/peripherals/TemperatureSensor.h +++ b/lib/GaggiMateController/src/peripherals/TemperatureSensor.h @@ -3,8 +3,10 @@ class TemperatureSensor { public: - virtual float read(); - virtual bool isErrorState(); + virtual ~TemperatureSensor() = default; + + virtual float read() = 0; + virtual bool isErrorState() = 0; }; #endif // TEMPERATURESENSOR_H diff --git a/lib/NanoPbComm/library.json b/lib/NanoPbComm/library.json index 22bd4f729..472ab7efb 100644 --- a/lib/NanoPbComm/library.json +++ b/lib/NanoPbComm/library.json @@ -10,8 +10,7 @@ "frameworks": "arduino", "platforms": ["espressif32"], "dependencies": { - "nanopb/Nanopb": "^0.4.9", - "h2zero/NimBLE-Arduino": "^1.4.0" + "nanopb/Nanopb": "^0.4.9" }, "build": { "flags": ["-I src"] diff --git a/lib/NanoPbComm/src/ble/BleClientTransport.cpp b/lib/NanoPbComm/src/ble/BleClientTransport.cpp index 88acb3029..3862d7015 100644 --- a/lib/NanoPbComm/src/ble/BleClientTransport.cpp +++ b/lib/NanoPbComm/src/ble/BleClientTransport.cpp @@ -2,7 +2,11 @@ void BleClientTransport::init(const String &deviceName) { NimBLEDevice::init(deviceName.c_str()); - NimBLEDevice::setPower(ESP_PWR_LVL_P9); + // +9 dBm — preserves master's setPower(ESP_PWR_LVL_P9), which was +9 dBm + // under NimBLE 1.4.x. 2.x setPower takes int8_t dBm, so pass 9 directly + // (2.x quantizes 9 -> ESP_PWR_LVL_P9). Do NOT pass the enum: ESP_PWR_LVL_P9 + // is value 11 on ESP32-S3 and would round up to +12 dBm. + NimBLEDevice::setPower(9); NimBLEDevice::setMTU(256); _client = NimBLEDevice::createClient(); _scanner = NimBLEDevice::getScan(); @@ -16,8 +20,12 @@ void BleClientTransport::init(const String &deviceName) { void BleClientTransport::scan() { _readyForConnection = false; - _scanner->clearDuplicateCache(); - _scanner->setAdvertisedDeviceCallbacks(this, true); + _scanner->clearResults(); // esp-nimble-cpp 2.x has no clearDuplicateCache(); results vector is unused (setMaxResults(0)) + // 1.x setAdvertisedDeviceCallbacks(cb, wantDuplicates=true) -> 2.x + // setScanCallbacks(cb, wantDuplicates). wantDuplicates=true keeps duplicate + // adverts flowing (internally setDuplicateFilter(false)) — same as the explicit + // setDuplicateFilter(false) below, preserving the pre-2.x rediscovery behaviour. + _scanner->setScanCallbacks(this, true); // BLE and Wi-Fi share the single 2.4 GHz radio. A low-duty passive scan // (1.25% duty, listen-only) keeps Wi-Fi RTT responsive; discovery is a touch // slower but still reliable. (Carried over from the previous transport.) @@ -26,7 +34,7 @@ void BleClientTransport::scan() { _scanner->setMaxResults(0); _scanner->setDuplicateFilter(false); _scanner->setActiveScan(false); - _scanner->start(0, nullptr, false); // 0 = continuous + _scanner->start(0, false, false); // 2.x: start(duration=0 continuous, isContinue, restart) } void BleClientTransport::maintain() { @@ -137,7 +145,7 @@ bool BleClientTransport::send(const uint8_t *data, size_t length) { bool BleClientTransport::isConnected() const { return _client != nullptr && _client->isConnected(); } -void BleClientTransport::onResult(NimBLEAdvertisedDevice *advertisedDevice) { +void BleClientTransport::onResult(const NimBLEAdvertisedDevice *advertisedDevice) { if (!advertisedDevice->haveServiceUUID()) return; if (advertisedDevice->isAdvertisingService(NimBLEUUID(gm_proto::SERVICE_UUID))) { @@ -151,8 +159,7 @@ void BleClientTransport::onResult(NimBLEAdvertisedDevice *advertisedDevice) { } } -void BleClientTransport::onDisconnect(NimBLEClient *client) { - (void)client; +void BleClientTransport::onDisconnect(NimBLEClient *, int) { ESP_LOGI(LOG_TAG, "Disconnected, will rescan"); _writeChar = nullptr; _notifyChar = nullptr; @@ -161,7 +168,6 @@ void BleClientTransport::onDisconnect(NimBLEClient *client) { scan(); } -void BleClientTransport::notifyCallback(NimBLERemoteCharacteristic *characteristic, uint8_t *data, size_t length, bool) { - (void)characteristic; +void BleClientTransport::notifyCallback(NimBLERemoteCharacteristic *, uint8_t *data, size_t length, bool) { emitData(data, length); } diff --git a/lib/NanoPbComm/src/ble/BleClientTransport.h b/lib/NanoPbComm/src/ble/BleClientTransport.h index 8d8d6ba1c..af0c2a2d8 100644 --- a/lib/NanoPbComm/src/ble/BleClientTransport.h +++ b/lib/NanoPbComm/src/ble/BleClientTransport.h @@ -14,7 +14,7 @@ * client controller, including the low-duty passive scan tuned for Wi-Fi * coexistence. */ -class BleClientTransport : public Transport, public NimBLEAdvertisedDeviceCallbacks, public NimBLEClientCallbacks { +class BleClientTransport : public Transport, public NimBLEScanCallbacks, public NimBLEClientCallbacks { public: BleClientTransport() = default; @@ -72,8 +72,8 @@ class BleClientTransport : public Transport, public NimBLEAdvertisedDeviceCallba static constexpr uint16_t CONN_LATENCY = 0; static constexpr uint16_t CONN_TIMEOUT = 400; // 4 s - void onResult(NimBLEAdvertisedDevice *advertisedDevice) override; - void onDisconnect(NimBLEClient *client) override; + void onResult(const NimBLEAdvertisedDevice *advertisedDevice) override; + void onDisconnect(NimBLEClient *client, int reason) override; void notifyCallback(NimBLERemoteCharacteristic *characteristic, uint8_t *data, size_t length, bool isNotify); static constexpr const char *LOG_TAG = "BleClientTransport"; diff --git a/lib/NanoPbComm/src/ble/BleServerTransport.cpp b/lib/NanoPbComm/src/ble/BleServerTransport.cpp index db62fdaf1..0df5f893b 100644 --- a/lib/NanoPbComm/src/ble/BleServerTransport.cpp +++ b/lib/NanoPbComm/src/ble/BleServerTransport.cpp @@ -2,7 +2,11 @@ void BleServerTransport::init(const String &deviceName) { NimBLEDevice::init(deviceName.c_str()); - NimBLEDevice::setPower(ESP_PWR_LVL_P9); + // +9 dBm — preserves master's setPower(ESP_PWR_LVL_P9), which was +9 dBm + // under NimBLE 1.4.x. 2.x setPower takes int8_t dBm, so pass 9 directly + // (2.x quantizes 9 -> ESP_PWR_LVL_P9). Do NOT pass the enum: ESP_PWR_LVL_P9 + // is value 11 on ESP32-S3 and would round up to +12 dBm. + NimBLEDevice::setPower(9); NimBLEDevice::setMTU(256); // headroom for batched frames _server = NimBLEDevice::createServer(); @@ -23,7 +27,7 @@ void BleServerTransport::init(const String &deviceName) { _advertising = NimBLEDevice::getAdvertising(); _advertising->addServiceUUID(gm_proto::SERVICE_UUID); - _advertising->setScanResponse(true); + _advertising->enableScanResponse(true); // 2.x renamed setScanResponse _advertising->start(); ESP_LOGI(LOG_TAG, "BLE server started, advertising"); } @@ -43,27 +47,27 @@ bool BleServerTransport::send(const uint8_t *data, size_t length) { if (!_connected || _txChar == nullptr) return false; _txChar->setValue(data, length); - _txChar->notify(); // NimBLE-Arduino 1.4.0: notify() returns void + _txChar->notify(); // 2.x notify() returns bool; fire-and-forget here return true; } bool BleServerTransport::isConnected() const { return _connected; } -void BleServerTransport::onConnect(NimBLEServer *server) { +void BleServerTransport::onConnect(NimBLEServer *server, NimBLEConnInfo &) { _connected = true; server->stopAdvertising(); ESP_LOGI(LOG_TAG, "Client connected"); emitConnection(true); } -void BleServerTransport::onDisconnect(NimBLEServer *server) { +void BleServerTransport::onDisconnect(NimBLEServer *server, NimBLEConnInfo &, int) { _connected = false; ESP_LOGI(LOG_TAG, "Client disconnected"); emitConnection(false); server->startAdvertising(); } -void BleServerTransport::onWrite(NimBLECharacteristic *characteristic) { +void BleServerTransport::onWrite(NimBLECharacteristic *characteristic, NimBLEConnInfo &) { if (characteristic != _rxChar) return; NimBLEAttValue value = characteristic->getValue(); @@ -71,6 +75,4 @@ void BleServerTransport::onWrite(NimBLECharacteristic *characteristic) { emitData(value.data(), value.length()); } -void BleServerTransport::onSubscribe(NimBLECharacteristic *pCharacteristic, ble_gap_conn_desc *desc, uint16_t subValue) { - emitConnection(true); -} +void BleServerTransport::onSubscribe(NimBLECharacteristic *, NimBLEConnInfo &, uint16_t) { emitConnection(true); } diff --git a/lib/NanoPbComm/src/ble/BleServerTransport.h b/lib/NanoPbComm/src/ble/BleServerTransport.h index ffa0c0abe..1f6481e1e 100644 --- a/lib/NanoPbComm/src/ble/BleServerTransport.h +++ b/lib/NanoPbComm/src/ble/BleServerTransport.h @@ -38,10 +38,10 @@ class BleServerTransport : public Transport, public NimBLEServerCallbacks, publi String _info; BLE_OTA_DFU _otaDfu; - void onConnect(NimBLEServer *server) override; - void onDisconnect(NimBLEServer *server) override; - void onWrite(NimBLECharacteristic *characteristic) override; - void onSubscribe(NimBLECharacteristic *pCharacteristic, ble_gap_conn_desc *desc, uint16_t subValue) override; + void onConnect(NimBLEServer *server, NimBLEConnInfo &connInfo) override; + void onDisconnect(NimBLEServer *server, NimBLEConnInfo &connInfo, int reason) override; + void onWrite(NimBLECharacteristic *characteristic, NimBLEConnInfo &connInfo) override; + void onSubscribe(NimBLECharacteristic *pCharacteristic, NimBLEConnInfo &connInfo, uint16_t subValue) override; static constexpr const char *LOG_TAG = "BleServerTransport"; }; diff --git a/lib/OTA/library.properties b/lib/OTA/library.properties new file mode 100644 index 000000000..84ef5bed4 --- /dev/null +++ b/lib/OTA/library.properties @@ -0,0 +1,9 @@ +name=OTA +version=1.0.0 +author=GaggiMate +maintainer=GaggiMate +sentence=Firmware OTA over GitHub releases + BLE relay to controller +paragraph=Firmware OTA over GitHub releases + BLE relay to controller +category=Other +architectures=esp32 +depends=NimBLE-Arduino,ArduinoJson,HTTPClient,HTTPUpdate,NetworkClientSecure,Update diff --git a/lib/OTA/src/ControllerOTA.cpp b/lib/OTA/src/ControllerOTA.cpp index f986ccc64..549cce149 100644 --- a/lib/OTA/src/ControllerOTA.cpp +++ b/lib/OTA/src/ControllerOTA.cpp @@ -1,6 +1,7 @@ #include "ControllerOTA.h" #include #include +#include void ControllerOTA::init(NimBLEClient *client, const ctr_progress_callback_t &progress_callback) { this->client = client; diff --git a/lib/OTA/src/GitHubOTA.cpp b/lib/OTA/src/GitHubOTA.cpp index 51fbcbc19..bdc088f5d 100644 --- a/lib/OTA/src/GitHubOTA.cpp +++ b/lib/OTA/src/GitHubOTA.cpp @@ -10,8 +10,8 @@ GitHubOTA::GitHubOTA(const String &display_version, const String &controller_version, const String &release_url, const phase_callback_t &phase_callback, const progress_callback_t &progress_callback, const String &firmware_name, const String &filesystem_name, const String &controller_firmware_name) { - ESP_LOGV("GitHubOTA", "GitHubOTA(version: %s, firmware_name: %s, fetch_url_via_redirect: %d)\n", version.c_str(), - firmware_name.c_str(), fetch_url_via_redirect); + ESP_LOGV("GitHubOTA", "GitHubOTA(display_version: %s, controller_version: %s, firmware_name: %s)\n", + display_version.c_str(), controller_version.c_str(), firmware_name.c_str()); _version = from_string(display_version.substring(1).c_str()); _controller_version = from_string(controller_version.substring(1).c_str()); @@ -23,8 +23,6 @@ GitHubOTA::GitHubOTA(const String &display_version, const String &controller_ver _progress_callback = progress_callback; Updater.rebootOnUpdate(false); - _wifi_client.setCACertBundle(x509_crt_imported_bundle_bin_start); - Updater.onStart(update_started); Updater.onEnd(update_finished); Updater.onProgress([progress_callback, this](int bytesReceived, int totalBytes) { @@ -94,6 +92,11 @@ void GitHubOTA::update(bool controller, bool display) { ESP_LOGI(TAG, "Controller update is required, running firmware update."); this->phase = PHASE_CONTROLLER_FW; this->_phase_callback(PHASE_CONTROLLER_FW); + // ControllerOTA::update() does not attach the CA bundle itself; do it + // here like update_firmware/update_filesystem, else the HTTPS GET for the + // controller .bin hits GitHub with no trusted roots and the TLS handshake + // fails (the constructor no longer attaches it once for the client). + attach_ca_bundle(_wifi_client); _controller_ota.update(_wifi_client, _latest_url + _controller_firmware_name); ESP_LOGI(TAG, "Controller update successful. Restarting...\n"); updateExecuted = true; @@ -141,6 +144,7 @@ HTTPUpdateResult GitHubOTA::update_firmware(const String &url) { const char *TAG = "update_firmware"; ESP_LOGI(TAG, "Download URL: %s\n", url.c_str()); + attach_ca_bundle(_wifi_client); auto result = Updater.update(_wifi_client, url); print_update_result(Updater, result, TAG); @@ -151,6 +155,7 @@ HTTPUpdateResult GitHubOTA::update_filesystem(const String &url) { const char *TAG = "update_filesystem"; ESP_LOGI(TAG, "Download URL: %s\n", url.c_str()); + attach_ca_bundle(_wifi_client); auto result = Updater.updateSpiffs(_wifi_client, url); print_update_result(Updater, result, TAG); return result; diff --git a/lib/OTA/src/GitHubOTA.h b/lib/OTA/src/GitHubOTA.h index b027f123b..889e48082 100644 --- a/lib/OTA/src/GitHubOTA.h +++ b/lib/OTA/src/GitHubOTA.h @@ -17,6 +17,7 @@ using phase_callback_t = std::function; using progress_callback_t = std::function; extern const uint8_t x509_crt_imported_bundle_bin_start[] asm("_binary_x509_crt_bundle_start"); +extern const uint8_t x509_crt_imported_bundle_bin_end[] asm("_binary_x509_crt_bundle_end"); class GitHubOTA { public: diff --git a/lib/OTA/src/common.cpp b/lib/OTA/src/common.cpp index e361c5263..23b30274c 100644 --- a/lib/OTA/src/common.cpp +++ b/lib/OTA/src/common.cpp @@ -2,11 +2,18 @@ #include #include +#include "GitHubOTA.h" #include "common.h" #include "semver.h" #include "semver_extensions.h" #include +void attach_ca_bundle(WiFiClientSecure &client) { + client.setCACertBundle(x509_crt_imported_bundle_bin_start, + reinterpret_cast(x509_crt_imported_bundle_bin_end) - + reinterpret_cast(x509_crt_imported_bundle_bin_start)); +} + String get_updated_base_url_via_redirect(WiFiClientSecure &wifi_client, String &release_url) { const char *TAG = "get_updated_base_url_via_redirect"; @@ -30,6 +37,7 @@ String get_redirect_location(WiFiClientSecure &wifi_client, String &initial_url) const char *TAG = "get_redirect_location"; ESP_LOGV(TAG, "initial_url: %s\n", initial_url.c_str()); + attach_ca_bundle(wifi_client); HTTPClient https; https.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS); @@ -55,6 +63,7 @@ String get_redirect_location(WiFiClientSecure &wifi_client, String &initial_url) String get_updated_version_via_txt_file(WiFiClientSecure &wifi_client, String &_release_url) { const char *TAG = "get_updated_version_via_txt_file"; + attach_ca_bundle(wifi_client); HTTPClient https; https.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); diff --git a/lib/OTA/src/common.h b/lib/OTA/src/common.h index 037976924..05110a820 100644 --- a/lib/OTA/src/common.h +++ b/lib/OTA/src/common.h @@ -9,6 +9,8 @@ using Updater = HTTPUpdate; +void attach_ca_bundle(WiFiClientSecure &client); + String get_updated_base_url_via_redirect(WiFiClientSecure &wifi_client, String &release_url); String get_redirect_location(WiFiClientSecure &wifi_client, String &initial_url); String get_updated_version_via_txt_file(WiFiClientSecure &wifi_client, String &_release_url); diff --git a/lib/ble_ota_dfu/library.properties b/lib/ble_ota_dfu/library.properties new file mode 100644 index 000000000..7d46e5230 --- /dev/null +++ b/lib/ble_ota_dfu/library.properties @@ -0,0 +1,10 @@ +name=ble_ota_dfu +version=1.0.0 +author=Vincent STRAGIER +maintainer=Vincent STRAGIER +sentence=BLE OTA DFU +paragraph=BLE OTA DFU +category=Other +url=mailto:vincent.stragier@outlook.com +architectures=esp32 +depends=NimBLE-Arduino diff --git a/lib/ble_ota_dfu/src/ble_ota_dfu.cpp b/lib/ble_ota_dfu/src/ble_ota_dfu.cpp index 3deee4168..fb3dbb00d 100644 --- a/lib/ble_ota_dfu/src/ble_ota_dfu.cpp +++ b/lib/ble_ota_dfu/src/ble_ota_dfu.cpp @@ -4,6 +4,29 @@ QueueHandle_t start_update_queue; QueueHandle_t update_uploading_queue; +// Hex-dump the current value of a TX characteristic prior to notify(). +// Enabled by defining DEBUG_BLE_OTA_DFU_TX at build time; compiles to a no-op +// otherwise. Replaces the former NimBLECharacteristicCallbacks::onNotify hook +// (removed from the base class in NimBLE-Arduino 2.x). +static inline void logTxPacket(BLECharacteristic *pChar) { +#ifdef DEBUG_BLE_OTA_DFU_TX + if (pChar == nullptr) { + return; + } + std::string value = pChar->getValue(); + uint16_t len = value.length(); + const auto *pData = reinterpret_cast(value.data()); + ESP_LOGD(TAG, "TX on %s length=%u", pChar->getUUID().toString().c_str(), (unsigned)len); + Serial.print("TX "); + for (uint16_t i = 0; i < len; i++) { + Serial.printf("%02X ", pData[i]); + } + Serial.println(); +#else + (void)pChar; +#endif +} + void task_install_update(void *parameters) { FS file_system = FLASH; const char path[] = "/update.bin"; @@ -150,29 +173,7 @@ uint16_t BLEOverTheAirDeviceFirmwareUpdate::write_binary(fs::FS *file_system, co } } -void BLEOverTheAirDeviceFirmwareUpdate::onNotify(BLECharacteristic *pCharacteristic) { -#ifdef DEBUG_BLE_OTA_DFU_TX - // uint8_t *pData; - std::string value = pCharacteristic->getValue(); - uint16_t len = value.length(); - // pData = pCharacteristic->getData(); - uint8_t *pData = (uint8_t *)value.data(); - - if (pData != NULL) { - ESP_LOGD(TAG, "Notify callback for characteristic %s of data length %d", pCharacteristic->getUUID().toString().c_str(), - len); - - // Print transferred packets - Serial.print("TX "); - for (uint16_t i = 0; i < len; i++) { - Serial.printf("%02X ", pData[i]); - } - Serial.println(); - } -#endif -} - -void BLEOverTheAirDeviceFirmwareUpdate::onWrite(BLECharacteristic *pCharacteristic) { +void BLEOverTheAirDeviceFirmwareUpdate::onWrite(BLECharacteristic *pCharacteristic, NimBLEConnInfo & /*connInfo*/) { // uint8_t *pData; std::string value = pCharacteristic->getValue(); uint16_t len = value.length(); @@ -187,7 +188,7 @@ void BLEOverTheAirDeviceFirmwareUpdate::onWrite(BLECharacteristic *pCharacterist len); Serial.print("RX "); for (uint16_t index = 0; index < len; index++) { - Serial.printf("%02X ", pData[i]); + Serial.printf("%02X ", pData[index]); } Serial.println(); #endif @@ -207,6 +208,7 @@ void BLEOverTheAirDeviceFirmwareUpdate::onWrite(BLECharacteristic *pCharacterist static_cast(used_size >> 8), static_cast(used_size)}; OTA_DFU_BLE->pCharacteristic_BLE_OTA_DFU_TX->setValue(flash_size, 7); + logTxPacket(OTA_DFU_BLE->pCharacteristic_BLE_OTA_DFU_TX); OTA_DFU_BLE->pCharacteristic_BLE_OTA_DFU_TX->notify(); delay(10); } break; @@ -231,6 +233,7 @@ void BLEOverTheAirDeviceFirmwareUpdate::onWrite(BLECharacteristic *pCharacterist uint8_t progression[] = {0xF1, (uint8_t)((current_progression + 1) / 256), (uint8_t)((current_progression + 1) % 256)}; OTA_DFU_BLE->pCharacteristic_BLE_OTA_DFU_TX->setValue(progression, 3); + logTxPacket(OTA_DFU_BLE->pCharacteristic_BLE_OTA_DFU_TX); OTA_DFU_BLE->pCharacteristic_BLE_OTA_DFU_TX->notify(); delay(10); } @@ -241,6 +244,7 @@ void BLEOverTheAirDeviceFirmwareUpdate::onWrite(BLECharacteristic *pCharacterist uint8_t progression[] = {0xF2, (uint8_t)((current_progression + 1) / 256), (uint8_t)((current_progression + 1) % 256)}; OTA_DFU_BLE->pCharacteristic_BLE_OTA_DFU_TX->setValue(progression, 3); + logTxPacket(OTA_DFU_BLE->pCharacteristic_BLE_OTA_DFU_TX); OTA_DFU_BLE->pCharacteristic_BLE_OTA_DFU_TX->notify(); delay(10); @@ -275,6 +279,7 @@ void BLEOverTheAirDeviceFirmwareUpdate::onWrite(BLECharacteristic *pCharacterist // Send mode ("fast" or "slow") uint8_t mode[] = {0xAA, FASTMODE}; OTA_DFU_BLE->pCharacteristic_BLE_OTA_DFU_TX->setValue(mode, 2); + logTxPacket(OTA_DFU_BLE->pCharacteristic_BLE_OTA_DFU_TX); OTA_DFU_BLE->pCharacteristic_BLE_OTA_DFU_TX->notify(); delay(10); } break; @@ -346,7 +351,10 @@ bool BLE_OTA_DFU::configure_OTA(NimBLEServer *pServer) { return false; } - // Start the BLE UART service + // No-op under esp-nimble-cpp 2.x (services are registered by + // NimBLEServer::start(), invoked from NimBLEAdvertising::start()), but kept + // for symmetry with the comms service in BleServerTransport and to document + // intent. pServiceOTA->start(); return true; } @@ -401,15 +409,18 @@ bool BLE_OTA_DFU::connected() { void BLE_OTA_DFU::send_OTA_DFU(uint8_t value) { uint8_t _value = value; this->pCharacteristic_BLE_OTA_DFU_TX->setValue(&_value, 1); + logTxPacket(this->pCharacteristic_BLE_OTA_DFU_TX); this->pCharacteristic_BLE_OTA_DFU_TX->notify(); } void BLE_OTA_DFU::send_OTA_DFU(uint8_t *value, size_t size) { this->pCharacteristic_BLE_OTA_DFU_TX->setValue(value, size); + logTxPacket(this->pCharacteristic_BLE_OTA_DFU_TX); this->pCharacteristic_BLE_OTA_DFU_TX->notify(); } void BLE_OTA_DFU::send_OTA_DFU(String value) { this->pCharacteristic_BLE_OTA_DFU_TX->setValue(value.c_str()); + logTxPacket(this->pCharacteristic_BLE_OTA_DFU_TX); this->pCharacteristic_BLE_OTA_DFU_TX->notify(); } diff --git a/lib/ble_ota_dfu/src/ble_ota_dfu.hpp b/lib/ble_ota_dfu/src/ble_ota_dfu.hpp index 4b4136036..268c0485e 100644 --- a/lib/ble_ota_dfu/src/ble_ota_dfu.hpp +++ b/lib/ble_ota_dfu/src/ble_ota_dfu.hpp @@ -56,8 +56,7 @@ class BLEOverTheAirDeviceFirmwareUpdate final : public BLECharacteristicCallback uint16_t write_binary(fs::FS *file_system, const char *path, uint8_t *data, uint16_t length, bool keep_open = true); - void onNotify(BLECharacteristic *pCharacteristic) override; - void onWrite(BLECharacteristic *pCharacteristic) override; + void onWrite(BLECharacteristic *pCharacteristic, NimBLEConnInfo &connInfo) override; }; class BLE_OTA_DFU { diff --git a/partitions/default_16mb.csv b/partitions/default_16mb.csv new file mode 100644 index 000000000..67d773728 --- /dev/null +++ b/partitions/default_16mb.csv @@ -0,0 +1,7 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x640000, +app1, app, ota_1, 0x650000,0x640000, +spiffs, data, spiffs, 0xc90000,0x360000, +coredump, data, coredump,0xFF0000,0x10000, diff --git a/partitions/default_8mb.csv b/partitions/default_8mb.csv new file mode 100644 index 000000000..7eefa0eb3 --- /dev/null +++ b/partitions/default_8mb.csv @@ -0,0 +1,7 @@ +# 8 MB flash — single factory slot, no dual-OTA (5.2 MB × 2 won't fit). +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x6E0000, +spiffs, data, spiffs, 0x6F0000,0x100000, +coredump, data, coredump,0x7F0000,0x10000, diff --git a/platformio.ini b/platformio.ini index cda83793d..7d12c0150 100644 --- a/platformio.ini +++ b/platformio.ini @@ -9,122 +9,158 @@ ; https://docs.platformio.org/page/projectconf.html [env] -platform = espressif32@6.12.0 -framework = arduino +; Pinned to 55.03.31-2 (Arduino 3.3.1 / IDF 5.5.1 + backports). IDF 5.5.2+ +; (pioarduino 55.03.32 onward) ships a precompiled libbtdm_app.a whose +; btdm_controller_init → r_llm_env_init overflows its internal allocations +; on ESP32-S3 rev v0.2, corrupting TLSF metadata and crashing NimBLE bring-up. +platform = https://github.com/pioarduino/platform-espressif32.git#55.03.31-2 +framework = arduino, espidf monitor_speed = 115200 +lib_ldf_mode = deep+ build_unflags = - -std=gnu++11 -check_flags = + -std=gnu++11 + -std=gnu++17 + -std=gnu++2b +check_flags = cppcheck: --enable=all --inline-suppr --suppress=*:*/.pio/* monitor_filters = - esp32_exception_decoder - time - default + esp32_exception_decoder + time + default [display_common] build_flags = - -std=c++17 - -std=gnu++17 - -DLV_CONF_INCLUDE_SIMPLE - -DLV_CONF_PATH="${platformio.src_dir}/display/lv_conf.h" - -DDISABLE_ALL_LIBRARY_WARNINGS - -DLV_CONF_SUPPRESS_DEFINE_CHECK - -DARDUINO_USB_CDC_ON_BOOT=1 - -DCORE_DEBUG_LEVEL=3 - -DCONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN - -Os - -DCONFIG_MAX_FILENAME_LEN=64 - -DCONFIG_MAX_URL_LEN=128 - -DCONFIG_NIMBLE_CPP_LOG_LEVEL=2 - -DCONFIG_BT_NIMBLE_PINNED_TO_CORE=0 - ; Run AsyncTCP on core 0, same core as the WiFi MAC and LWIP tcpip thread. - ; Driving LWIP from a different core than the WiFi/RX path (core 1) risks - ; cross-core LWIP races under load that wedge the whole IP stack (web + ICMP - ; die together). v1.8.1 used core 0 and was stable; the BLE load that - ; originally motivated moving it off core 0 is now much lower (idle 30-50ms - ; connection interval + fire-and-forget telemetry). - -DCONFIG_ASYNC_TCP_RUNNING_CORE=0 - -DCONFIG_ASYNC_TCP_MAX_ACK_TIME=5000 - -DCONFIG_ASYNC_TCP_PRIORITY=10 - -DCONFIG_ASYNC_TCP_QUEUE_SIZE=64 - -DCONFIG_ASYNC_TCP_STACK_SIZE=8192 - -DDEFAULT_MAX_WS_CLIENTS=3 + -std=gnu++20 + -DLV_CONF_INCLUDE_SIMPLE + -DLV_CONF_PATH="${platformio.src_dir}/display/lv_conf.h" + -DDISABLE_ALL_LIBRARY_WARNINGS + -DLV_CONF_SUPPRESS_DEFINE_CHECK + -DARDUINO_USB_CDC_ON_BOOT=1 + -DCORE_DEBUG_LEVEL=3 + -DCONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN + -Os + -DCONFIG_MAX_FILENAME_LEN=64 + -DCONFIG_MAX_URL_LEN=128 + -DCONFIG_BT_NIMBLE_PINNED_TO_CORE=0 + -DCONFIG_ASYNC_TCP_RUNNING_CORE=0 + -DCONFIG_ASYNC_TCP_MAX_ACK_TIME=5000 + -DCONFIG_ASYNC_TCP_PRIORITY=10 + -DCONFIG_ASYNC_TCP_QUEUE_SIZE=64 + -DCONFIG_ASYNC_TCP_STACK_SIZE=8192 + -DDEFAULT_MAX_WS_CLIENTS=3 lib_deps_default = - FS - SPIFFS - Wire - SPI - ble_ota_dfu - NanoPbComm - h2zero/NimBLE-Arduino@^1.4.0 - nanopb/Nanopb@^0.4.9 - https://github.com/ESP32Async/AsyncTCP.git#v3.4.9 - https://github.com/ESP32Async/ESPAsyncWebServer.git#v3.9.1 - bblanchon/ArduinoJson@^7.2.1 - 256dpi/MQTT@^2.5.2 - https://github.com/gaggimate/esp-arduino-ble-scales#v1 - homespan/HomeSpan@1.9.1 + FS + SPIFFS + Wire + SPI + Networking + NetworkClientSecure + WiFi + HTTPClient + HTTPUpdate + Update + ble_ota_dfu + NanoPbComm + OTA + nanopb/Nanopb@^0.4.9 + https://github.com/ESP32Async/AsyncTCP.git#v3.4.10 + https://github.com/ESP32Async/ESPAsyncWebServer.git#v3.10.3 + bblanchon/ArduinoJson@^7.2.1 + 256dpi/MQTT@^2.5.2 + https://github.com/gaggimate/esp-arduino-ble-scales.git#v2.0.0 + homespan/HomeSpan@^2.1.7 + https://github.com/ESPToolKit/esp-memoryMonitor.git#v1.0.1 + +; Shared IDF / dual-framework keys referenced by the display and controller +; envs (the env-section equivalent of [display_common]). nanopb generates +; gaggimate.pb.{c,h} from the .proto at build time; the component_remove list +; strips managed components we never use to cut first-build time + flash. +[idf_common] +custom_nanopb_protos = + + +custom_nanopb_options = + --error-on-unmatched +lib_ignore = + ESP_SR +custom_component_remove = + chmorgan/esp-libhelix-mp3 + espressif/cbor + espressif/esp-dsp + espressif/esp-modbus + espressif/esp-sr + espressif/esp-zboss-lib + espressif/esp-zigbee-lib + espressif/esp_diag_data_store + espressif/esp_diagnostics + espressif/esp_insights + espressif/esp_modem + espressif/esp_rainmaker + espressif/qrcode + espressif/rmaker_common + espressif/lan867x [env:display] board = LilyGo-T-RGB upload_speed = 921600 +board_build.filesystem = spiffs +board_build.partitions = partitions/default_16mb.csv +board_build.cmake_extra_args = -DEXECUTABLE_COMPONENT_NAME=src -DSDKCONFIG_DEFAULTS=sdkconfig.common.defaults;sdkconfig.gaggimate.defaults build_src_filter = -<*> + + +custom_nanopb_protos = ${idf_common.custom_nanopb_protos} +custom_nanopb_options = ${idf_common.custom_nanopb_options} +lib_ignore = ${idf_common.lib_ignore} +custom_component_remove = ${idf_common.custom_component_remove} extra_scripts = pre:scripts/auto_firmware_version.py -; nanopb's official PlatformIO integration generates gaggimate.pb.{c,h} from the -; .proto at build time (no committed generated files, no separate generator -; script). The matching gaggimate.options is picked up automatically. -custom_nanopb_protos = - + -custom_nanopb_options = - --error-on-unmatched + pre:scripts/pioarduino_env.py lib_deps = - ${display_common.lib_deps_default} - lewisxhe/SensorLib @ 0.2.3 - bodmer/TFT_eSPI @ 2.5.43 + ${display_common.lib_deps_default} + lewisxhe/SensorLib @ 0.2.3 + bodmer/TFT_eSPI @ 2.5.43 lvgl/lvgl @ 8.4.0 - moononournation/GFX Library for Arduino @ 1.5.9 + moononournation/GFX Library for Arduino @ ^1.6.5 build_flags = - ${display_common.build_flags} + ${display_common.build_flags} [env:display-headless] extends = env:display -lib_deps = - ${display_common.lib_deps_default} +board_build.cmake_extra_args = -DEXECUTABLE_COMPONENT_NAME=src -DSDKCONFIG_DEFAULTS=sdkconfig.common.defaults;sdkconfig.gaggimate.defaults -DGAGGIMATE_HEADLESS_BUILD=1 +lib_deps = ${display_common.lib_deps_default} build_flags = - ${display_common.build_flags} - -DGAGGIMATE_HEADLESS + ${display_common.build_flags} + -DGAGGIMATE_HEADLESS build_src_filter = -<*> + + - - - [env:display-headless-8m] extends = env:display-headless board = seeed_xiao_esp32s3 - -[env:display-headless-4m] -extends = env:display-headless -board = esp32-s3-supermini +board_build.partitions = partitions/default_8mb.csv [env:controller] board = Gaggimate-Controller +board_build.partitions = partitions/default_8mb.csv +board_build.cmake_extra_args = -DEXECUTABLE_COMPONENT_NAME=src -DSDKCONFIG_DEFAULTS=sdkconfig.common.defaults;sdkconfig.controller.defaults -DGAGGIMATE_CONTROLLER_BUILD=1 build_src_filter = -<*> + + +custom_nanopb_protos = ${idf_common.custom_nanopb_protos} +custom_nanopb_options = ${idf_common.custom_nanopb_options} +lib_ignore = ${idf_common.lib_ignore} +; Controller also strips libsodium (no HomeSpan HAP crypto on this side). +custom_component_remove = + ${idf_common.custom_component_remove} + espressif/libsodium extra_scripts = - pre:scripts/auto_firmware_version.py -custom_nanopb_protos = - + -custom_nanopb_options = - --error-on-unmatched + pre:scripts/auto_firmware_version.py + pre:scripts/pioarduino_env.py lib_deps = GaggiMateController FS SPIFFS ble_ota_dfu NanoPbComm - h2zero/NimBLE-Arduino@^1.4.0 nanopb/Nanopb@^0.4.9 bblanchon/ArduinoJson@^7.2.1 build_flags = - -std=c++17 - -std=gnu++17 + -std=gnu++20 -DCORE_DEBUG_LEVEL=3 -DARDUINO_USB_CDC_ON_BOOT=1 diff --git a/scripts/pioarduino_env.py b/scripts/pioarduino_env.py new file mode 100644 index 000000000..8e10ac3b3 --- /dev/null +++ b/scripts/pioarduino_env.py @@ -0,0 +1,126 @@ +# ruff: noqa: F821 — `env` is injected by PlatformIO/SCons at runtime +import os + +Import("env") + +env.Append(CXXFLAGS=["-Wno-deprecated-enum-enum-conversion"]) + +# pioarduino 55.x split WiFi → Network/NetworkClientSecure. Arduino-bundled libs +# declare transitive deps only via `#include`, not `depends=` in +# library.properties, so LDF (even `deep+`) won't wire up sibling include paths +# for every compile unit. Inject a narrow allowlist of Arduino-bundled libs +# we actually use. Keep this list tight — globbing every library pulls in +# paths for framework libs we don't need (protocol stacks, radio helpers, DSP, +# etc.) and can collide or inflate compile time. +# +# CXXFLAGS, not CPPPATH: under dual-framework or hybrid-compile builds IDF C +# sources (e.g. spiffs_api.c, sdmmc_*.c) share the project include path set +# with our C++ code. Arduino's FS.h / SPIFFS's spiffs.h are C++ headers — +# leaking them into C compilation trips `#include ` with "No such +# file or directory". `-I` via CXXFLAGS applies to C++ only. +ARDUINO_LIBS_ALLOWLIST = [ + "Network", + "NetworkClientSecure", + "WiFi", + "HTTPClient", + "HTTPUpdate", + "Update", + "SPI", + "Wire", + "FS", + "SPIFFS", + "SD_MMC", + "Preferences", + "DNSServer", + "AsyncUDP", + "ESPmDNS", + "Ethernet", + "ArduinoOTA", + "Hash", +] + +platform = env.PioPlatform() +framework_dir = platform.get_package_dir("framework-arduinoespressif32") +if framework_dir: + libraries_dir = os.path.join(framework_dir, "libraries") + for name in ARDUINO_LIBS_ALLOWLIST: + src = os.path.join(libraries_dir, name, "src") + if os.path.isdir(src): + env.Append(CXXFLAGS=["-I" + src]) + +# IDF BT/NimBLE include paths for esp-nimble-cpp. +# esp-nimble-cpp REQUIRES bt (IDF component), which exposes NimBLE C headers +# (host/ble_gap.h, syscfg/syscfg.h, etc.) transitively. IDF's CMake resolves +# this automatically for the IDF compilation pass, but the Arduino lib +# compilation pass doesn't receive IDF component REQUIRES propagation. +# Inject via CXXFLAGS (same cross-pass mechanism as ARDUINO_LIBS_ALLOWLIST). +# +# Paths extracted from bt/CMakeLists.txt include_dirs declarations — +# IDF uses container dirs (e.g. porting/nimble/include) not leaf dirs, +# so a naive os.walk would add wrong paths and miss header resolution. +_IDF_BT_NIMBLE_PATHS = [ + # Common BT + "bt/common/osi/include", + "bt/common/api/include/api", + "bt/common/btc/profile/esp/blufi/include", + "bt/common/btc/profile/esp/include", + "bt/common/hci_log/include", + "bt/common/ble_log/include", + "bt/common/tinycrypt/include", + "bt/common/tinycrypt/port", + # NimBLE host + "bt/host/nimble/nimble/nimble/host/include", + "bt/host/nimble/nimble/nimble/include", + "bt/host/nimble/nimble/nimble/host/services/ans/include", + "bt/host/nimble/nimble/nimble/host/services/bas/include", + "bt/host/nimble/nimble/nimble/host/services/dis/include", + "bt/host/nimble/nimble/nimble/host/services/gap/include", + "bt/host/nimble/nimble/nimble/host/services/gatt/include", + "bt/host/nimble/nimble/nimble/host/services/hr/include", + "bt/host/nimble/nimble/nimble/host/services/htp/include", + "bt/host/nimble/nimble/nimble/host/services/ias/include", + "bt/host/nimble/nimble/nimble/host/services/ipss/include", + "bt/host/nimble/nimble/nimble/host/services/lls/include", + "bt/host/nimble/nimble/nimble/host/services/prox/include", + "bt/host/nimble/nimble/nimble/host/services/cts/include", + "bt/host/nimble/nimble/nimble/host/services/tps/include", + "bt/host/nimble/nimble/nimble/host/services/hid/include", + "bt/host/nimble/nimble/nimble/host/services/sps/include", + "bt/host/nimble/nimble/nimble/host/services/cte/include", + "bt/host/nimble/nimble/nimble/host/services/ras/include", + "bt/host/nimble/nimble/nimble/host/util/include", + "bt/host/nimble/nimble/nimble/host/store/ram/include", + "bt/host/nimble/nimble/nimble/host/store/config/include", + # NimBLE porting layer (syscfg/syscfg.h lives here) + "bt/host/nimble/nimble/porting/nimble/include", + "bt/host/nimble/port/include", + "bt/host/nimble/nimble/nimble/transport/include", + "bt/porting/include", + "bt/host/nimble/nimble/porting/npl/freertos/include", + "bt/host/nimble/esp-hci/include", +] +# Target-specific BT controller API. ESP32-S3 uses the esp32c3-family ABI; +# other targets (S2, C6, H2, P4) would need a different subdir — gate by MCU +# so the wrong header doesn't get pulled in on future ports. +_mcu = env.subst("$BOARD_MCU") +if _mcu in ("esp32s3", "esp32c3", "esp32c2"): + _IDF_BT_NIMBLE_PATHS.insert(0, "bt/include/esp32c3/include") + +idf_dir = platform.get_package_dir("framework-espidf") +if idf_dir: + components_dir = os.path.join(idf_dir, "components") + for rel_path in _IDF_BT_NIMBLE_PATHS: + abs_path = os.path.join(components_dir, rel_path) + if os.path.isdir(abs_path): + env.Append(CXXFLAGS=["-I" + abs_path]) + +# esp-nimble-cpp managed component: C++ wrapper headers (NimBLEDevice.h etc.). +# Managed components live at project root, not inside the PlatformIO package +# tree, so they must be referenced via PROJECT_DIR. +_project_dir = env.get("PROJECT_DIR", "") +if _project_dir: + _nimble_cpp_src = os.path.join( + _project_dir, "managed_components", "h2zero__esp-nimble-cpp", "src" + ) + if os.path.isdir(_nimble_cpp_src): + env.Append(CXXFLAGS=["-I" + _nimble_cpp_src]) diff --git a/sdkconfig.common.defaults b/sdkconfig.common.defaults new file mode 100644 index 000000000..faebaed4f --- /dev/null +++ b/sdkconfig.common.defaults @@ -0,0 +1,80 @@ +# Shared sdkconfig base for all dual-framework envs. +# Applied as the first entry in SDKCONFIG_DEFAULTS; env-specific files follow +# and override only what differs (MAX_CONNECTIONS, LWIP, nano-newlib). +# +# Workflow: pioarduino caches a merged sdkconfig. at project root and +# reuses it on subsequent builds. Edits to any .defaults file don't propagate +# until that per-env file is regenerated. After editing, run: +# pio run -e -t fullclean +# else the build ignores your change and you will chase ghosts. + +CONFIG_IDF_TARGET="esp32s3" + +# PSRAM config lives in sdkconfig.gaggimate.defaults — only the display-class +# boards (LilyGo-T-RGB, seeed_xiao_esp32s3) have PSRAM. The Gaggimate-Controller +# board has none; enabling SPIRAM here would abort boot on "PSRAM chip is not +# connected". + +# --- NimBLE: all roles (NanoPbComm BLE transport compiles client+server code) --- +CONFIG_BT_ENABLED=y +CONFIG_BT_NIMBLE_ENABLED=y +# NimBLE host PSRAM routing lives in sdkconfig.gaggimate.defaults. +CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y +CONFIG_BT_NIMBLE_ROLE_CENTRAL=y +CONFIG_BT_NIMBLE_ROLE_OBSERVER=y +CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y +CONFIG_BT_CTRL_RUN_IN_FLASH_ONLY=y + +# Must explicitly unset template's default=INFO for Kconfig choice to honour WARN. +# CONFIG_BT_NIMBLE_LOG_LEVEL_INFO is not set +CONFIG_BT_NIMBLE_LOG_LEVEL_WARNING=y +# esp-nimble-cpp C++ wrapper log level. 0=DEBUG 1=INFO 2=WARNING 3=ERROR. +CONFIG_NIMBLE_CPP_LOG_LEVEL=2 + +# --- mbedTLS --- +# MBEDTLS_EXTERNAL_MEM_ALLOC lives in sdkconfig.gaggimate.defaults (needs PSRAM). +CONFIG_MBEDTLS_HARDWARE_AES=y +CONFIG_MBEDTLS_HARDWARE_MPI=y +CONFIG_MBEDTLS_HARDWARE_SHA=y +# Arduino-as-component always compiles ssl_client.cpp; it unconditionally +# references esp_crt_bundle_attach and gates all code behind PSK modes. +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN=y +CONFIG_MBEDTLS_PSK_MODES=y +CONFIG_MBEDTLS_KEY_EXCHANGE_PSK=y +CONFIG_MBEDTLS_KEY_EXCHANGE_DHE_PSK=y +CONFIG_MBEDTLS_KEY_EXCHANGE_ECDHE_PSK=y +CONFIG_MBEDTLS_KEY_EXCHANGE_RSA_PSK=y + +# --- Task WDT --- +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n + +# --- FreeRTOS --- +CONFIG_FREERTOS_HZ=1000 +CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH=y +CONFIG_RINGBUF_PLACE_FUNCTIONS_INTO_FLASH=y + +# --- Stacks --- +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# --- Logging --- +CONFIG_LOG_DEFAULT_LEVEL_INFO=y +CONFIG_LOG_MAXIMUM_LEVEL_VERBOSE=y + +# --- Arduino (as-component) trims --- +# SELECTIVE_COMPILATION=y + per-lib =n gates out libs that break IDF 5.5+GCC 14 +# or add unused flash. ESP_HostedOTA has no SELECTIVE Kconfig guard, but its +# entire body is wrapped in #if CONFIG_ESP_WIFI_REMOTE_ENABLED (esp32p4-only), +# so it compiles to a zero-symbol stub on esp32s3 — no exclusion needed. +CONFIG_ARDUINO_SELECTIVE_COMPILATION=y +CONFIG_ARDUINO_SELECTIVE_ESP_SR=n +CONFIG_ARDUINO_SELECTIVE_Zigbee=n +CONFIG_ARDUINO_SELECTIVE_BluetoothSerial=n +CONFIG_ARDUINO_SELECTIVE_Matter=n +CONFIG_ARDUINO_SELECTIVE_Insights=n +CONFIG_ARDUINO_SELECTIVE_RainMaker=n +CONFIG_ARDUINO_SELECTIVE_OpenThread=n +CONFIG_ARDUINO_SELECTIVE_SimpleBLE=n +# Arduino's cores/esp32/main.cpp only defines app_main when =y; src/ has no app_main. +CONFIG_AUTOSTART_ARDUINO=y diff --git a/sdkconfig.controller.defaults b/sdkconfig.controller.defaults new file mode 100644 index 000000000..eb7fa5e92 --- /dev/null +++ b/sdkconfig.controller.defaults @@ -0,0 +1,20 @@ +# Controller-env sdkconfig overrides (layered on top of sdkconfig.common.defaults). +# Controller is BLE peripheral-only (display is the central). No WiFi network +# stacks, no HomeKit, no WebServer — no LWIP tuning or HKDF needed. + +# 1 connection: single display consumer. +CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 + +# 2 BLE link-layer activity slots: 1 peripheral connection + 1 advertising set. +# Common base leaves the default (6), sized for the display's central + peripheral +# workload. Narrowing it here reclaims ~1 KB static BSS. +CONFIG_BT_CTRL_BLE_MAX_ACT=2 + +# Nano newlib safe here: controller has no GNU %m scanf users. +CONFIG_LIBC_NEWLIB_NANO_FORMAT=y + +# Re-enable idle-task watchdog: controller has no LVGL/WebServer pinning idle, +# so a genuine idle-starvation loop should be caught. Display env keeps it off +# inherited from common.defaults. +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=y diff --git a/sdkconfig.gaggimate.defaults b/sdkconfig.gaggimate.defaults new file mode 100644 index 000000000..130bf0547 --- /dev/null +++ b/sdkconfig.gaggimate.defaults @@ -0,0 +1,56 @@ +# Display-env sdkconfig overrides (layered on top of sdkconfig.common.defaults). +# Routes NimBLE host + WiFi/LWIP + BSS to PSRAM and keeps 16 KB internal +# reserved for DMA-bound callers. Avoids BLE_INIT malloc storms under the +# combined pressure of WiFi AP + HomeSpan + BLE GATT links on ESP32-S3. + +# Flash mode/freq/size intentionally omitted — boards are heterogeneous +# (16 MB LilyGo, 8 MB seeed xiao). PlatformIO sets these from board.json. + +# --- PSRAM (octal 80MHz) — display-class boards only --- +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_TYPE_AUTO=y +CONFIG_SPIRAM_BOOT_INIT=y +CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY=y +CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=512 +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=16384 + +# Route NimBLE host + mbedTLS allocations to PSRAM (reclaims ~40 KB internal). +CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_INTERNAL=n +CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_EXTERNAL=y +CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y + +# 4 connections: display acts as central for BLE scales + peripheral for HomeKit. +CONFIG_BT_NIMBLE_MAX_CONNECTIONS=4 + +# Bump host task stack from 4096: continuous scale scanning drives the NimBLE +# host callback context hot. 4 KB runs close to the high-water mark during +# scan-complete bursts. +CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=5120 + +# HKDF used by HomeSpan for HomeKit pairing key derivation. +CONFIG_MBEDTLS_HKDF_C=y + +# --- LWIP (socket tuning borrowed from ESPHome PR #10647) --- +CONFIG_LWIP_TCPIP_CORE_LOCKING=y +CONFIG_LWIP_CHECK_THREAD_SAFETY=y + +# --- IPv6 --- +CONFIG_LWIP_IPV6_AUTOCONFIG=y +CONFIG_LWIP_IPV6_NUM_ADDRESSES=6 + +# --- Nano newlib --- +# Disabled: HomeSpan 2.1.7 uses GNU %m scanf in Span::configureNetwork() — +# nano-newlib drops it, leaving `d` uninitialised and crashing on strlen(d). +# Full newlib costs ~15-25 KB flash. +# NOTE: IDF 5.5 renamed NEWLIB_NANO_FORMAT → LIBC_NEWLIB_NANO_FORMAT. +CONFIG_LIBC_NEWLIB_NANO_FORMAT=n + +# --- C++ exceptions --- +# esp-arduino-ble-scales' RemoteScales uses try/catch (e.g. the noexcept dtor +# wraps clientCleanup()). IDF defaults to -fno-exceptions; arduino-esp32 built +# with exceptions on, so the display class enables them here. Controller env +# does not link the scales lib and stays -fno-exceptions. +CONFIG_COMPILER_CXX_EXCEPTIONS=y diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 000000000..04312edff --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,26 @@ +# GAGGIMATE_CONTROLLER_BUILD=1 selects src/controller/**; default is src/display/**. +if(DEFINED GAGGIMATE_CONTROLLER_BUILD AND GAGGIMATE_CONTROLLER_BUILD) + FILE(GLOB_RECURSE app_sources + ${CMAKE_SOURCE_DIR}/src/controller/*.c + ${CMAKE_SOURCE_DIR}/src/controller/*.cpp + ) +else() + FILE(GLOB_RECURSE app_sources + ${CMAKE_SOURCE_DIR}/src/display/*.c + ${CMAKE_SOURCE_DIR}/src/display/*.cpp + ) + # Headless envs opt out of driver/ui code via -DGAGGIMATE_HEADLESS_BUILD=1. + if(DEFINED GAGGIMATE_HEADLESS_BUILD AND GAGGIMATE_HEADLESS_BUILD) + list(FILTER app_sources EXCLUDE REGEX "/src/display/drivers/") + list(FILTER app_sources EXCLUDE REGEX "/src/display/ui/") + endif() +endif() + +idf_component_register(SRCS ${app_sources} + INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/src + REQUIRES esp-nimble-cpp +) + +target_compile_options(${COMPONENT_LIB} PRIVATE + $<$:-std=gnu++20> +) diff --git a/src/display/core/Controller.cpp b/src/display/core/Controller.cpp index e506dab15..8fea2ca7e 100644 --- a/src/display/core/Controller.cpp +++ b/src/display/core/Controller.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -30,27 +32,39 @@ #include #endif -const String LOG_TAG = F("Controller"); +static constexpr const char *LOG_TAG = "Controller"; void Controller::setup() { + gaggimate::memmon::init(); + heap_checkpoint_reset(); + heap_checkpoint("setup/enter"); + + // NVS read deferred out of the Settings ctor (which runs during C++ global + // init, before Arduino's init()) — call it here once the runtime is ready. + settings.load(); mode = settings.getStartupMode(); + heap_checkpoint("setup/after-settings-load"); if (!SPIFFS.begin(true)) { Serial.println(F("An Error has occurred while mounting SPIFFS")); } + heap_checkpoint("setup/after-spiffs-mount"); #ifndef GAGGIMATE_HEADLESS setupPanel(); + heap_checkpoint("setup/after-setup-panel"); #endif pluginManager = new PluginManager(); #ifndef GAGGIMATE_HEADLESS ui = new DefaultUI(this, driver, pluginManager); + heap_checkpoint("setup/after-default-ui-ctor"); if (driver->supportsSDCard() && driver->installSDCard()) { sdcard = true; ESP_LOGI(LOG_TAG, "SD Card detected and mounted"); ESP_LOGI(LOG_TAG, "Used: %lluMB, Capacity: %lluMB", SD_MMC.usedBytes() / 1024 / 1024, SD_MMC.cardSize() / 1024 / 1024); } + heap_checkpoint("setup/after-sd-probe"); #endif FS *fs = &SPIFFS; if (sdcard) { @@ -58,6 +72,7 @@ void Controller::setup() { } profileManager = new ProfileManager(fs, "/p", settings, pluginManager); profileManager->setup(); + heap_checkpoint("setup/after-profile-manager"); if (settings.isHomekit()) pluginManager->registerPlugin(new HomekitPlugin(settings.getWifiSsid(), settings.getWifiPassword())); else @@ -76,7 +91,9 @@ void Controller::setup() { pluginManager->registerPlugin(&BLEScales); pluginManager->registerPlugin(new LedControlPlugin()); pluginManager->registerPlugin(new AutoWakeupPlugin()); + heap_checkpoint("setup/after-plugin-register"); pluginManager->setup(this); + heap_checkpoint("setup/after-plugin-setup"); pluginManager->on("profiles:profile:save", [this](Event const &event) { String id = event.getString("id"); @@ -89,12 +106,14 @@ void Controller::setup() { #ifndef GAGGIMATE_HEADLESS ui->init(); + heap_checkpoint("setup/after-ui-init"); #endif this->onScreenReady(); updateLastAction(); xTaskCreatePinnedToCore(loopTask, "Controller::loopControl", configMINIMAL_STACK_SIZE * 6, this, 2, &taskHandle, 0); xTaskCreatePinnedToCore(loopLogicTask, "Controller::loopLogic", configMINIMAL_STACK_SIZE * 6, this, 3, &logicTaskHandle, 0); + heap_checkpoint("setup/end"); } void Controller::onScreenReady() { screenReady = true; } @@ -108,8 +127,11 @@ void Controller::connect() { connectStartTime = millis(); pluginManager->trigger("controller:startup"); + heap_checkpoint("connect/before-wifi"); setupWifi(); + heap_checkpoint("connect/after-wifi"); setupBluetooth(); + heap_checkpoint("connect/after-bluetooth"); pluginManager->on("ota:update:start", [this](Event const &) { this->updating = true; }); pluginManager->on("ota:update:end", [this](Event const &) { this->updating = false; }); @@ -389,8 +411,8 @@ void Controller::setupWifi() { setenv("TZ", resolve_timezone(settings.getTimezone()), 1); tzset(); sntp_set_sync_mode(SNTP_SYNC_MODE_SMOOTH); - sntp_setservername(0, NTP_SERVER); - sntp_init(); + esp_sntp_setservername(0, NTP_SERVER); + esp_sntp_init(); } else { WiFi.disconnect(true, true); ESP_LOGI(LOG_TAG, "Timed out while connecting to WiFi"); diff --git a/src/display/core/Controller.h b/src/display/core/Controller.h index f99154109..a80364f69 100644 --- a/src/display/core/Controller.h +++ b/src/display/core/Controller.h @@ -198,8 +198,8 @@ class Controller { static const unsigned long BLUETOOTH_GRACE_PERIOD_MS = 1500; // 1.5 second grace period static const unsigned long CONTROLLER_WAITING_TIMEOUT_MS = 10000; - xTaskHandle taskHandle; - xTaskHandle logicTaskHandle; + TaskHandle_t taskHandle; + TaskHandle_t logicTaskHandle; static void loopTask(void *arg); static void loopLogicTask(void *arg); diff --git a/src/display/core/MemoryMonitor.cpp b/src/display/core/MemoryMonitor.cpp new file mode 100644 index 000000000..37e19d7e2 --- /dev/null +++ b/src/display/core/MemoryMonitor.cpp @@ -0,0 +1,104 @@ +#include "MemoryMonitor.h" +#include +#include + +namespace { + +constexpr const char *TAG = "MemMon"; + +ESPMemoryMonitor g_monitor; +// Written once by init() (Controller setup task), read from other tasks +// (WebUI status push, OTA). Atomic to avoid a data race on the non-atomic bool. +std::atomic g_ready{false}; + +const char *regionName(MemoryRegion r) { return r == MemoryRegion::Psram ? "psram" : "int"; } + +const char *stateName(ThresholdState s) { + switch (s) { + case ThresholdState::Critical: + return "CRITICAL"; + case ThresholdState::Warn: + return "WARN"; + case ThresholdState::Normal: + default: + return "OK"; + } +} + +} // namespace + +namespace gaggimate::memmon { + +void init() { + if (g_ready) + return; + + MemoryMonitorConfig cfg; + cfg.sampleIntervalMs = 60000; + cfg.historySize = 15; + cfg.internal = {40 * 1024, 20 * 1024}; + cfg.psram = {500 * 1024, 200 * 1024}; + cfg.thresholdHysteresisBytes = 4 * 1024; + cfg.enableSamplerTask = true; + cfg.enableFragmentation = true; + cfg.enableMinEverFree = true; + cfg.enablePerTaskStacks = false; + cfg.enableFailedAllocEvents = true; + cfg.enableScopes = true; + cfg.maxScopesInHistory = 8; + cfg.windowStatsSize = 5; + cfg.stackSize = 4096; + cfg.priority = 1; + cfg.coreId = MemoryMonitorConfig::any; + cfg.usePSRAMBuffers = true; + + if (!g_monitor.init(cfg)) { + ESP_LOGE(TAG, "init failed"); + return; + } + + g_monitor.onSample([](const MemorySnapshot &snap) { + for (const auto &r : snap.regions) { + ESP_LOGI(TAG, "heartbeat %s free=%u min=%u largest=%u frag=%.2f slope=%.1fB/s t_warn=%us", + regionName(r.region), (unsigned)r.freeBytes, (unsigned)r.minimumFreeBytes, + (unsigned)r.largestFreeBlock, r.fragmentation, r.freeBytesSlope, (unsigned)r.secondsToWarn); + } + }); + + g_monitor.onThreshold([](const ThresholdEvent &evt) { + const char *region = regionName(evt.region); + const char *state = stateName(evt.state); + if (evt.state == ThresholdState::Critical) { + ESP_LOGE(TAG, "%s %s free=%u largest=%u frag=%.2f", region, state, (unsigned)evt.stats.freeBytes, + (unsigned)evt.stats.largestFreeBlock, evt.stats.fragmentation); + } else if (evt.state == ThresholdState::Warn) { + ESP_LOGW(TAG, "%s %s free=%u largest=%u frag=%.2f", region, state, (unsigned)evt.stats.freeBytes, + (unsigned)evt.stats.largestFreeBlock, evt.stats.fragmentation); + } else { + ESP_LOGI(TAG, "%s %s free=%u largest=%u", region, state, (unsigned)evt.stats.freeBytes, + (unsigned)evt.stats.largestFreeBlock); + } + }); + + g_monitor.onFailedAlloc([](const FailedAllocEvent &evt) { + ESP_LOGE(TAG, "FAILED ALLOC size=%u caps=0x%08x fn=%s", (unsigned)evt.requestedBytes, (unsigned)evt.caps, + evt.functionName ? evt.functionName : "?"); + }); + + g_monitor.installPanicHook([](const MemorySnapshot &snap) { + for (const auto &r : snap.regions) { + ESP_LOGE(TAG, "PANIC %s free=%u min=%u largest=%u frag=%.2f", regionName(r.region), + (unsigned)r.freeBytes, (unsigned)r.minimumFreeBytes, (unsigned)r.largestFreeBlock, r.fragmentation); + } + }); + + g_ready = true; + ESP_LOGI(TAG, "ready: int warn=%u crit=%u | psram warn=%u crit=%u | sample=%ums", (unsigned)cfg.internal.warnBytes, + (unsigned)cfg.internal.criticalBytes, (unsigned)cfg.psram.warnBytes, (unsigned)cfg.psram.criticalBytes, + (unsigned)cfg.sampleIntervalMs); +} + +ESPMemoryMonitor &instance() { return g_monitor; } +bool isReady() { return g_ready; } + +} // namespace gaggimate::memmon diff --git a/src/display/core/MemoryMonitor.h b/src/display/core/MemoryMonitor.h new file mode 100644 index 000000000..3634dcb30 --- /dev/null +++ b/src/display/core/MemoryMonitor.h @@ -0,0 +1,15 @@ +#pragma once +#ifndef GAGGIMATE_MEMORY_MONITOR_H +#define GAGGIMATE_MEMORY_MONITOR_H + +#include + +namespace gaggimate::memmon { + +void init(); +ESPMemoryMonitor &instance(); +bool isReady(); + +} // namespace gaggimate::memmon + +#endif // GAGGIMATE_MEMORY_MONITOR_H diff --git a/src/display/core/Settings.cpp b/src/display/core/Settings.cpp index 4cc55bf53..4050c561f 100644 --- a/src/display/core/Settings.cpp +++ b/src/display/core/Settings.cpp @@ -3,7 +3,12 @@ #include #include -Settings::Settings() { +Settings::Settings() = default; + +void Settings::load() { + if (taskHandle != nullptr) { + return; // already loaded — guard against re-entry spawning a second save task + } preferences.begin(PREFERENCES_KEY, true); startupMode = preferences.getInt("sm", MODE_STANDBY); targetSteamTemp = preferences.getInt("ts", 145); @@ -114,10 +119,6 @@ Settings::Settings() { xTaskCreate(loopTask, "Settings::loop", configMINIMAL_STACK_SIZE * 6, this, 1, &taskHandle); } -void Settings::batchUpdate(const SettingsCallback &callback) { - callback(this); - save(); -} void Settings::save(bool noDelay) { if (noDelay) { diff --git a/src/display/core/Settings.h b/src/display/core/Settings.h index 4506012ec..08b1bc769 100644 --- a/src/display/core/Settings.h +++ b/src/display/core/Settings.h @@ -43,14 +43,21 @@ struct AutoWakeupSchedule { } }; -class Settings; -using SettingsCallback = std::function; - class Settings { public: Settings(); - void batchUpdate(const SettingsCallback &callback); + // Open NVS + populate fields + start the async-save task. + // Split from the ctor so we don't touch NVS during C++ global init + // (Controller is a global; under IDF 5.5 the NVS driver is only + // guaranteed ready once Arduino's init() has run). + void load(); + + template + void batchUpdate(const F &callback) { + callback(this); + save(); + } void save(bool noDelay = false); // Getters and setters @@ -248,8 +255,8 @@ class Settings { std::vector buttonBehavior; void doSave(); - xTaskHandle taskHandle; - static void loopTask(void *arg); + TaskHandle_t taskHandle = nullptr; + [[noreturn]] static void loopTask(void *arg); }; #endif // SETTINGS_H diff --git a/src/display/core/utils.cpp b/src/display/core/utils.cpp index 5de1bf84a..524bb5dfe 100644 --- a/src/display/core/utils.cpp +++ b/src/display/core/utils.cpp @@ -1,5 +1,6 @@ #include "utils.h" #include +#include #include #include #include @@ -35,7 +36,7 @@ std::vector explode(const String &input, char delim) { } String implode(const std::vector &strings, String delim) { - if (strings.size() == 0) { + if (strings.empty()) { return ""; } if (strings.size() == 1) { @@ -46,23 +47,98 @@ String implode(const std::vector &strings, String delim) { } void measure_heap(const String &label, std::function callback) { +#if GAGGIMATE_HEAP_PROFILE ESP_LOGI("Common", "%s measurement started", label.c_str()); - size_t freeBefore = heap_caps_get_free_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); - size_t largestBefore = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); - size_t totalBefore = heap_caps_get_total_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); - float usedPercentBefore = (totalBefore - freeBefore) / (float)totalBefore * 100; - float fragmentationBefore = 100 - (largestBefore * 100) / freeBefore; + const size_t intFreeBefore = heap_caps_get_free_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); + const size_t intLargestBefore = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); + const size_t intTotalBefore = heap_caps_get_total_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); + const size_t psramFreeBefore = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + const float intUsedBefore = intTotalBefore ? (intTotalBefore - intFreeBefore) / (float)intTotalBefore * 100 : 0.0f; + const float intFragBefore = intFreeBefore ? 100 - (intLargestBefore * 100) / intFreeBefore : 0.0f; callback(); - size_t freeAfter = heap_caps_get_free_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); - size_t largestAfter = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); - size_t totalAfter = heap_caps_get_total_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); - float usedPercentAfter = (totalAfter - freeAfter) / (float)totalAfter * 100; - float fragmentationAfter = 100 - (largestAfter * 100) / freeAfter; + const size_t intFreeAfter = heap_caps_get_free_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); + const size_t intLargestAfter = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); + const size_t intTotalAfter = heap_caps_get_total_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); + const size_t psramFreeAfter = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + const float intUsedAfter = intTotalAfter ? (intTotalAfter - intFreeAfter) / (float)intTotalAfter * 100 : 0.0f; + const float intFragAfter = intFreeAfter ? 100 - (intLargestAfter * 100) / intFreeAfter : 0.0f; - ESP_LOGI("Common", "%s changed heap usage from %.2f%% to %.2f%% by %dkB (%.2f%% to %.2f%% fragmentation)", label.c_str(), - usedPercentBefore, usedPercentAfter, (freeBefore - freeAfter) / 1024, fragmentationBefore, fragmentationAfter); + ESP_LOGI("Common", "%s int=%.2f%%→%.2f%% (Δ%+dkB, frag %.0f%%→%.0f%%) psramΔ=%+dkB", label.c_str(), intUsedBefore, + intUsedAfter, (int)((int)intFreeBefore - (int)intFreeAfter) / 1024, intFragBefore, intFragAfter, + (int)((int)psramFreeBefore - (int)psramFreeAfter) / 1024); +#else + (void)label; + callback(); +#endif +} + +#if GAGGIMATE_HEAP_PROFILE +namespace { + +struct HeapSnapshot { + size_t intFree = 0; + size_t intLargest = 0; + size_t psramFree = 0; + size_t psramLargest = 0; + bool valid = false; +}; + +HeapSnapshot s_lastCheckpoint; + +HeapSnapshot takeHeapSnapshot() { + HeapSnapshot snap; + snap.intFree = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + snap.intLargest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL); + snap.psramFree = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + snap.psramLargest = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM); + snap.valid = true; + return snap; +} + +} // namespace +#endif + +void heap_checkpoint_reset() { +#if GAGGIMATE_HEAP_PROFILE + s_lastCheckpoint = HeapSnapshot{}; +#endif +} + +void heap_checkpoint(const char *label) { +#if GAGGIMATE_HEAP_PROFILE + const HeapSnapshot now = takeHeapSnapshot(); + const float intFrag = now.intFree ? 100.0f - (now.intLargest * 100.0f) / now.intFree : 0.0f; + const float psramFrag = now.psramFree ? 100.0f - (now.psramLargest * 100.0f) / now.psramFree : 0.0f; + + if (!s_lastCheckpoint.valid) { + ESP_LOGI("HeapProfile", "[%s] int: free=%u largest=%u frag=%.0f%% | psram: free=%u largest=%u frag=%.0f%%", label, + (unsigned)now.intFree, (unsigned)now.intLargest, intFrag, (unsigned)now.psramFree, + (unsigned)now.psramLargest, psramFrag); + } else { + const int dInt = (int)now.intFree - (int)s_lastCheckpoint.intFree; + const int dPsram = (int)now.psramFree - (int)s_lastCheckpoint.psramFree; + ESP_LOGI("HeapProfile", + "[%s] int: free=%u (Δ%+dB) largest=%u frag=%.0f%% | psram: free=%u (Δ%+dB) largest=%u frag=%.0f%%", + label, (unsigned)now.intFree, dInt, (unsigned)now.intLargest, intFrag, (unsigned)now.psramFree, dPsram, + (unsigned)now.psramLargest, psramFrag); + } + s_lastCheckpoint = now; +#else + (void)label; +#endif +} + +void heap_dump(const char *label) { +#if GAGGIMATE_HEAP_PROFILE + ESP_LOGI("HeapProfile", "[%s] heap_caps_print_heap_info(MALLOC_CAP_INTERNAL):", label); + heap_caps_print_heap_info(MALLOC_CAP_INTERNAL); + ESP_LOGI("HeapProfile", "[%s] heap_caps_print_heap_info(MALLOC_CAP_SPIRAM):", label); + heap_caps_print_heap_info(MALLOC_CAP_SPIRAM); +#else + (void)label; +#endif } bool is_task_healthy(const eTaskState task_state) { diff --git a/src/display/core/utils.h b/src/display/core/utils.h index 396358a5c..a9005e73f 100644 --- a/src/display/core/utils.h +++ b/src/display/core/utils.h @@ -2,7 +2,11 @@ #ifndef UTILS_H #define UTILS_H #include +#include #include +#include +#include +#include template std::unique_ptr make_unique(Args &&...args) { return std::unique_ptr(new T(std::forward(args)...)); @@ -23,7 +27,24 @@ extern uint8_t randomByte(); extern String generateShortID(uint8_t length = 10); extern std::vector explode(const String &input, char delim); extern String implode(const std::vector &strings, String delim); +// Runtime heap observability (60 s sampler, onFailedAlloc, panic hook) lives +// in src/display/core/MemoryMonitor.* and is always compiled. +// +// The helpers below are debug-only boot/trace profilers, compiled to no-ops +// unless the build defines -DGAGGIMATE_HEAP_PROFILE=1 (see platformio.ini). extern void measure_heap(const String &label, std::function callback); extern bool is_task_healthy(const eTaskState task_state); +// Per-subsystem heap checkpoint. Logs current internal + PSRAM free/largest/ +// fragmentation, plus delta since the previous call. No-op unless +// GAGGIMATE_HEAP_PROFILE=1. +extern void heap_checkpoint(const char *label); + +// Reset the heap_checkpoint() baseline. No-op unless GAGGIMATE_HEAP_PROFILE=1. +extern void heap_checkpoint_reset(); + +// Heavyweight dump of heap_caps_print_heap_info() for both regions. No-op +// unless GAGGIMATE_HEAP_PROFILE=1. +extern void heap_dump(const char *label); + #endif // UTILS_H diff --git a/src/display/drivers/AmoledDisplay/Amoled_DisplayPanel.cpp b/src/display/drivers/AmoledDisplay/Amoled_DisplayPanel.cpp index a3b188fa6..0a62e17f2 100644 --- a/src/display/drivers/AmoledDisplay/Amoled_DisplayPanel.cpp +++ b/src/display/drivers/AmoledDisplay/Amoled_DisplayPanel.cpp @@ -1,7 +1,6 @@ #include "Amoled_DisplayPanel.h" #include "Arduino_GFX_Library.h" #include "pin_config.h" -#include Amoled_DisplayPanel::Amoled_DisplayPanel(AmoledHwConfig hw_config) : hwConfig(hw_config), displayBus(nullptr), display(nullptr), _touchDrv(nullptr), _wakeupMethod(WAKEUP_FROM_NONE), @@ -200,18 +199,13 @@ uint16_t Amoled_DisplayPanel::getBattVoltage(void) { if (hwConfig.battery_voltage_adc_data == -1) { return 0; } - esp_adc_cal_characteristics_t adc_chars; - esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_12, ADC_WIDTH_BIT_12, 1100, &adc_chars); - const int number_of_samples = 20; uint32_t sum = 0; for (int i = 0; i < number_of_samples; i++) { - sum += analogRead(hwConfig.battery_voltage_adc_data); + sum += analogReadMilliVolts(hwConfig.battery_voltage_adc_data); delay(2); } - sum = sum / number_of_samples; - - return esp_adc_cal_raw_to_voltage(sum, &adc_chars) * 2; + return (sum / number_of_samples) * 2; } void Amoled_DisplayPanel::pushColors(uint16_t x, uint16_t y, uint16_t width, uint16_t height, uint16_t *data) { @@ -271,7 +265,7 @@ bool Amoled_DisplayPanel::initDisplay(Amoled_Display_Panel_Color_Order colorOrde new Arduino_ESP32QSPI(hwConfig.lcd_cs /* CS */, hwConfig.lcd_sclk /* SCK */, hwConfig.lcd_sdio0 /* SDIO0 */, hwConfig.lcd_sdio1 /* SDIO1 */, hwConfig.lcd_sdio2 /* SDIO2 */, hwConfig.lcd_sdio3 /* SDIO3 */); - display = new CO5300(displayBus, hwConfig.lcd_rst /* RST */, _rotation /* rotation */, false /* IPS */, + display = new CO5300(displayBus, hwConfig.lcd_rst /* RST */, _rotation /* rotation */, hwConfig.lcd_width, hwConfig.lcd_height, hwConfig.lcd_gram_offset_x /* col offset 1 */, 0 /* row offset 1 */, hwConfig.lcd_gram_offset_y /* col_offset2 */, 0 /* row_offset2 */, colorOrder); } @@ -300,7 +294,7 @@ bool Amoled_DisplayPanel::initDisplay(Amoled_Display_Panel_Color_Order colorOrde // required for correct GRAM initialization displayBus->writeCommand(CO5300_C_PTLON); - display->fillScreen(BLACK); + display->fillScreen(RGB565_BLACK); return success; } diff --git a/src/display/drivers/AmoledDisplay/CO5300.cpp b/src/display/drivers/AmoledDisplay/CO5300.cpp index 1b5b362fa..a8037b78e 100644 --- a/src/display/drivers/AmoledDisplay/CO5300.cpp +++ b/src/display/drivers/AmoledDisplay/CO5300.cpp @@ -1,8 +1,8 @@ #include "CO5300.h" -CO5300::CO5300(Arduino_DataBus *bus, int8_t rst, uint8_t r, bool ips, int16_t w, int16_t h, uint8_t col_offset1, +CO5300::CO5300(Arduino_DataBus *bus, int8_t rst, uint8_t r, int16_t w, int16_t h, uint8_t col_offset1, uint8_t row_offset1, uint8_t col_offset2, uint8_t row_offset2, uint8_t color_order) - : Arduino_CO5300(bus, rst, r, ips, w, h, col_offset1, row_offset1, col_offset2, row_offset2), _color_order(color_order) {} + : Arduino_CO5300(bus, rst, r, w, h, col_offset1, row_offset1, col_offset2, row_offset2), _color_order(color_order) {} void CO5300::setRotation(uint8_t r) { Arduino_TFT::setRotation(r); diff --git a/src/display/drivers/AmoledDisplay/CO5300.h b/src/display/drivers/AmoledDisplay/CO5300.h index 22f771f89..fbfd3b5cc 100644 --- a/src/display/drivers/AmoledDisplay/CO5300.h +++ b/src/display/drivers/AmoledDisplay/CO5300.h @@ -6,8 +6,8 @@ class CO5300 : public Arduino_CO5300 { public: - CO5300(Arduino_DataBus *bus, int8_t rst = GFX_NOT_DEFINED, uint8_t r = 0, bool ips = false, int16_t w = CO5300_MAXWIDTH, - int16_t h = CO5300_MAXHEIGHT, uint8_t col_offset1 = 0, uint8_t row_offset1 = 0, uint8_t col_offset2 = 0, + CO5300(Arduino_DataBus *bus, int8_t rst = GFX_NOT_DEFINED, uint8_t r = 0, int16_t w = CO5300_TFTWIDTH, + int16_t h = CO5300_TFTHEIGHT, uint8_t col_offset1 = 0, uint8_t row_offset1 = 0, uint8_t col_offset2 = 0, uint8_t row_offset2 = 0, uint8_t color_order = CO5300_MADCTL_RGB); void setRotation(uint8_t r) override; diff --git a/src/display/drivers/AmoledDisplayDriver.cpp b/src/display/drivers/AmoledDisplayDriver.cpp index c6baa9699..ae9c25485 100644 --- a/src/display/drivers/AmoledDisplayDriver.cpp +++ b/src/display/drivers/AmoledDisplayDriver.cpp @@ -60,9 +60,23 @@ bool AmoledDisplayDriver::supportsSDCard() { return true; } bool AmoledDisplayDriver::installSDCard() { return panel->installSD(); } bool AmoledDisplayDriver::testHw(AmoledHwConfig hwConfig) { - // No Wire on these pins, definitely wrong board - if (!Wire.begin(hwConfig.i2c_sda, hwConfig.i2c_scl)) + // Cold-boot peripherals need a moment; also recover from any prior + // Wire.end()/begin() that left hal-i2c-ng in ESP_ERR_INVALID_STATE. + Wire.end(); + delay(20); + + // Assert board power rail (shared by LCD + touch on LilyGo T-Display-S3 + // AMOLED) before probing I2C; touch chip won't ACK unpowered on cold boot. + if (hwConfig.lcd_en != -1) { + pinMode(hwConfig.lcd_en, OUTPUT); + digitalWrite(hwConfig.lcd_en, HIGH); + delay(50); + } + + if (!Wire.begin(hwConfig.i2c_sda, hwConfig.i2c_scl)) { return false; + } + delay(20); // Required: PCF8563 (RTC) when present, and a touch sensor // Touch sensor: Either CST92XX (1.75 inch) or FT3168 (1.43 inch) diff --git a/src/display/drivers/Driver.h b/src/display/drivers/Driver.h index 5303863ca..2bc5558fb 100644 --- a/src/display/drivers/Driver.h +++ b/src/display/drivers/Driver.h @@ -6,11 +6,11 @@ class Driver { public: virtual ~Driver() = default; - virtual bool isCompatible(); - virtual void init(); - virtual void setBrightness(int brightness); - virtual bool supportsSDCard(); - virtual bool installSDCard(); + virtual bool isCompatible() { return false; } + virtual void init() {} + virtual void setBrightness(int /*brightness*/) {} + virtual bool supportsSDCard() { return false; } + virtual bool installSDCard() { return false; } }; #endif // DRIVER_H diff --git a/src/display/drivers/LilyGo-T-RGB/LilyGo_RGBPanel.cpp b/src/display/drivers/LilyGo-T-RGB/LilyGo_RGBPanel.cpp index 014a7e9a9..fc166f776 100644 --- a/src/display/drivers/LilyGo-T-RGB/LilyGo_RGBPanel.cpp +++ b/src/display/drivers/LilyGo-T-RGB/LilyGo_RGBPanel.cpp @@ -10,7 +10,6 @@ #include "LilyGo_RGBPanel.h" #include "utilities.h" #include -#include static void TouchDrvDigitalWrite(uint32_t gpio, uint8_t level); static int TouchDrvDigitalRead(uint32_t gpio); @@ -229,7 +228,7 @@ void LilyGo_RGBPanel::sleep() { } if (_panelDrv) { - esp_lcd_panel_disp_off(_panelDrv, true); + esp_lcd_panel_disp_on_off(_panelDrv, false); esp_lcd_panel_del(_panelDrv); } @@ -280,22 +279,13 @@ bool LilyGo_RGBPanel::isPressed() { } uint16_t LilyGo_RGBPanel::getBattVoltage() { - esp_adc_cal_characteristics_t adc_chars; - esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_12, ADC_WIDTH_BIT_12, 1100, &adc_chars); - const int number_of_samples = 20; uint32_t sum = 0; - uint16_t raw_buffer[number_of_samples] = {0}; for (int i = 0; i < number_of_samples; i++) { - raw_buffer[i] = analogRead(BOARD_ADC_DET); + sum += analogReadMilliVolts(BOARD_ADC_DET); delay(2); } - for (int i = 0; i < number_of_samples; i++) { - sum += raw_buffer[i]; - } - sum = sum / number_of_samples; - - return esp_adc_cal_raw_to_voltage(sum, &adc_chars) * 2; + return (sum / number_of_samples) * 2; } void LilyGo_RGBPanel::initBUS() { @@ -385,6 +375,7 @@ void LilyGo_RGBPanel::initBUS() { .vsync_gpio_num = BOARD_TFT_VSYNC, .de_gpio_num = BOARD_TFT_DE, .pclk_gpio_num = BOARD_TFT_PCLK, + .disp_gpio_num = GPIO_NUM_NC, .data_gpio_nums = { // BOARD_TFT_DATA0, @@ -408,9 +399,6 @@ void LilyGo_RGBPanel::initBUS() { BOARD_TFT_DATA4, BOARD_TFT_DATA5, }, - .disp_gpio_num = GPIO_NUM_NC, - .on_frame_trans_done = NULL, - .user_ctx = NULL, .flags = { .fb_in_psram = 1, // allocate frame buffer in PSRAM diff --git a/src/display/drivers/Waveshare/WavesharePanel.cpp b/src/display/drivers/Waveshare/WavesharePanel.cpp index 1623a2618..8b5afe214 100644 --- a/src/display/drivers/Waveshare/WavesharePanel.cpp +++ b/src/display/drivers/Waveshare/WavesharePanel.cpp @@ -5,7 +5,6 @@ #include "driver/spi_master.h" #include "utilities.h" #include -#include static void TouchDrvDigitalWrite(uint32_t gpio, uint8_t level); static int TouchDrvDigitalRead(uint32_t gpio); @@ -50,8 +49,7 @@ bool WavesharePanel::begin(WS_RGBPanel_Color_Order order) { _order = order; - ledcSetup(WS_PWM_CHANNEL, WS_PWM_FREQ, WS_PWM_RESOLUTION); - ledcAttachPin(WS_BOARD_TFT_BL, WS_PWM_CHANNEL); + ledcAttach(WS_BOARD_TFT_BL, WS_PWM_FREQ, WS_PWM_RESOLUTION); initExtension(); Set_EXIO(EXIO_PIN8, Low); @@ -112,7 +110,7 @@ void WavesharePanel::uninstallSD() { void WavesharePanel::setBrightness(uint8_t value) { value = constrain(value, 0, WS_BACKLIGHT_MAX); _brightness = value; - ledcWrite(WS_PWM_CHANNEL, _brightness); + ledcWrite(WS_BOARD_TFT_BL, _brightness); } uint8_t WavesharePanel::getBrightness() const { return _brightness; } @@ -202,7 +200,7 @@ void WavesharePanel::sleep() { } if (_panelDrv) { - esp_lcd_panel_disp_off(_panelDrv, true); + esp_lcd_panel_disp_on_off(_panelDrv, false); esp_lcd_panel_del(_panelDrv); } @@ -253,22 +251,13 @@ bool WavesharePanel::isPressed() const { } uint16_t WavesharePanel::getBattVoltage() { - esp_adc_cal_characteristics_t adc_chars; - esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_12, ADC_WIDTH_BIT_12, 1100, &adc_chars); - const int number_of_samples = 20; uint32_t sum = 0; - uint16_t raw_buffer[number_of_samples] = {0}; for (int i = 0; i < number_of_samples; i++) { - raw_buffer[i] = analogRead(WS_BOARD_ADC_DET); + sum += analogReadMilliVolts(WS_BOARD_ADC_DET); delay(2); } - for (int i = 0; i < number_of_samples; i++) { - sum += raw_buffer[i]; - } - sum = sum / number_of_samples; - - return esp_adc_cal_raw_to_voltage(sum, &adc_chars) * 2; + return (sum / number_of_samples) * 2; } void WavesharePanel::initBUS() { @@ -845,6 +834,7 @@ void WavesharePanel::initBUS() { .vsync_gpio_num = WS_BOARD_TFT_VSYNC, .de_gpio_num = WS_BOARD_TFT_DE, .pclk_gpio_num = WS_BOARD_TFT_PCLK, + .disp_gpio_num = GPIO_NUM_NC, .data_gpio_nums = { ESP_PANEL_LCD_PIN_NUM_RGB_DATA0, @@ -864,9 +854,6 @@ void WavesharePanel::initBUS() { ESP_PANEL_LCD_PIN_NUM_RGB_DATA14, ESP_PANEL_LCD_PIN_NUM_RGB_DATA15, }, - .disp_gpio_num = GPIO_NUM_NC, - .on_frame_trans_done = NULL, - .user_ctx = NULL, .flags = { .fb_in_psram = 1, // allocate frame buffer in PSRAM diff --git a/src/display/plugins/AutoWakeupPlugin.cpp b/src/display/plugins/AutoWakeupPlugin.cpp index 3a5b4e865..138342f51 100644 --- a/src/display/plugins/AutoWakeupPlugin.cpp +++ b/src/display/plugins/AutoWakeupPlugin.cpp @@ -2,7 +2,7 @@ #include #include -const String LOG_TAG = F("AutoWakeupPlugin"); +static constexpr const char *LOG_TAG = "AutoWakeupPlugin"; AutoWakeupPlugin::AutoWakeupPlugin() {} @@ -11,14 +11,14 @@ void AutoWakeupPlugin::setup(Controller *controller, PluginManager *pluginManage this->pluginManager = pluginManager; this->settings = &controller->getSettings(); - ESP_LOGI(LOG_TAG.c_str(), "Auto-wakeup plugin initialized"); + ESP_LOGI(LOG_TAG, "Auto-wakeup plugin initialized"); // Listen for settings changes to log configuration pluginManager->on("settings:changed", [this](const Event &event) { if (settings->isAutoWakeupEnabled()) { - ESP_LOGI(LOG_TAG.c_str(), "Auto-wakeup enabled with %d schedule(s)", settings->getAutoWakeupSchedules().size()); + ESP_LOGI(LOG_TAG, "Auto-wakeup enabled with %d schedule(s)", settings->getAutoWakeupSchedules().size()); } else { - ESP_LOGI(LOG_TAG.c_str(), "Auto-wakeup disabled"); + ESP_LOGI(LOG_TAG, "Auto-wakeup disabled"); } }); } @@ -58,7 +58,7 @@ void AutoWakeupPlugin::checkAutoWakeup() { // Check if current time and day matches any of the schedules for (const AutoWakeupSchedule &schedule : settings->getAutoWakeupSchedules()) { if (schedule.time == currentTime && schedule.isDayEnabled(currentDayOfWeek)) { - ESP_LOGI(LOG_TAG.c_str(), "Auto-wakeup schedule matched (time: %s, day: %d), switching to brew mode", + ESP_LOGI(LOG_TAG, "Auto-wakeup schedule matched (time: %s, day: %d), switching to brew mode", schedule.time.c_str(), currentDayOfWeek); controller->setMode(MODE_BREW); diff --git a/src/display/plugins/BLEScalePlugin.cpp b/src/display/plugins/BLEScalePlugin.cpp index 0c2367295..e521149ce 100644 --- a/src/display/plugins/BLEScalePlugin.cpp +++ b/src/display/plugins/BLEScalePlugin.cpp @@ -120,7 +120,11 @@ void BLEScalePlugin::setup(Controller *controller, PluginManager *manager) { void BLEScalePlugin::loop() { if (doConnect && scale == nullptr) { - establishConnection(); + const unsigned long now = millis(); + if (lastConnectAttempt == 0 || now - lastConnectAttempt >= CONNECT_RETRY_INTERVAL_MS) { + lastConnectAttempt = now; + establishConnection(); + } } const unsigned long now = millis(); if (now - lastUpdate > UPDATE_INTERVAL_MS) { diff --git a/src/display/plugins/BLEScalePlugin.h b/src/display/plugins/BLEScalePlugin.h index 6ce681d9e..d97455225 100644 --- a/src/display/plugins/BLEScalePlugin.h +++ b/src/display/plugins/BLEScalePlugin.h @@ -72,7 +72,9 @@ class BLEScalePlugin : public Plugin { std::string uuid; unsigned long lastUpdate = 0; + unsigned long lastConnectAttempt = 0; unsigned int reconnectionTries = 0; + static constexpr unsigned long CONNECT_RETRY_INTERVAL_MS = 2000; // Cached scale-metadata values used to avoid firing an event for each // unchanged poll tick. Reset when the scale disconnects. diff --git a/src/display/plugins/HomekitPlugin.cpp b/src/display/plugins/HomekitPlugin.cpp index b676fa86a..434355a3f 100644 --- a/src/display/plugins/HomekitPlugin.cpp +++ b/src/display/plugins/HomekitPlugin.cpp @@ -1,104 +1,168 @@ #include "HomekitPlugin.h" +// HomeSpan 2.x defines `class Controller` at global scope — rename it via the +// preprocessor so it doesn't collide with our own Controller (used everywhere). +#define Controller HomeSpan_Controller +#include +#undef Controller #include "../core/Controller.h" +#include "../core/Event.h" +#include "../core/PluginManager.h" #include "../core/constants.h" +#include "../core/utils.h" +#include #include -HomekitAccessory::HomekitAccessory(change_callback_t callback) - : callback(nullptr), state(nullptr), targetState(nullptr), currentTemperature(nullptr), targetTemperature(nullptr), - displayUnits(nullptr) { - this->callback = std::move(callback); - state = new Characteristic::CurrentHeatingCoolingState(); - targetState = new Characteristic::TargetHeatingCoolingState(); - targetState->setValidValues(2, 0, 1); - currentTemperature = new Characteristic::CurrentTemperature(); - currentTemperature->setRange(0, 160); - targetTemperature = new Characteristic::TargetTemperature(); - targetTemperature->setRange(0, 160); - displayUnits = new Characteristic::TemperatureDisplayUnits(); - displayUnits->setVal(0); -} - -boolean HomekitAccessory::update() { - if (targetState->getVal() != targetState->getNewVal()) { - state->setVal(targetState->getNewVal()); - this->callback(); - } - if (targetTemperature->getVal() != targetTemperature->getNewVal()) { - this->callback(); +// HomeSpan's Span::pollTask() calls verifyRollbackLater() once as a diagnostic +// log (HomeSpan.cpp:608). Two upstream definitions exist, both excluded from +// our build: +// 1. arduino-esp32 core (cores/esp32/esp32-hal-misc.c) — weak default +// returning false, gated by #ifdef CONFIG_APP_ROLLBACK_ENABLE which we +// don't set in sdkconfig.gaggimate.defaults. +// 2. Arduino RainMaker (libraries/RainMaker/src/RMaker.cpp) — strong +// override returning true, excluded via CONFIG_ARDUINO_SELECTIVE_RainMaker=n. +// HomeSpan's reference is unconditional, so we must provide the symbol. Weak +// so a real RainMaker link (if selective-compilation ever changes) wins. +// Return false to match Arduino's own default; HomeSpan then prints +// "Auto Rollback: Disabled" — truthful for a build with no rollback verifier. +extern "C" __attribute__((weak)) bool verifyRollbackLater() { return false; } + +namespace { + +constexpr uint16_t kHomeSpanPort = 8080; +constexpr const char *kDeviceName = "GaggiMate"; + +using ChangeCallback = std::function; + +class HomekitAccessory : public Service::Thermostat { + public: + explicit HomekitAccessory(ChangeCallback cb) : callback(std::move(cb)) { + state = new Characteristic::CurrentHeatingCoolingState(); + targetState = new Characteristic::TargetHeatingCoolingState(); + targetState->setValidValues(2, 0, 1); + currentTemperature = new Characteristic::CurrentTemperature(); + currentTemperature->setRange(0, 160); + targetTemperature = new Characteristic::TargetTemperature(); + targetTemperature->setRange(0, 160); + displayUnits = new Characteristic::TemperatureDisplayUnits(); + displayUnits->setVal(0); } - return true; -} - -boolean HomekitAccessory::getState() const { return targetState->getVal() == 1; } -void HomekitAccessory::setState(bool active) const { - this->targetState->setVal(active ? 1 : 0, true); - this->state->setVal(active ? 1 : 0, true); -} - -void HomekitAccessory::setCurrentTemperature(float temperatureValue) const { currentTemperature->setVal(temperatureValue, true); } + boolean update() override { + if (targetState->getVal() != targetState->getNewVal()) { + state->setVal(targetState->getNewVal()); + callback(); + } + if (targetTemperature->getVal() != targetTemperature->getNewVal()) { + callback(); + } + return true; + } -void HomekitAccessory::setTargetTemperature(float temperatureValue) const { targetTemperature->setVal(temperatureValue, true); } + bool getState() const { return targetState->getVal() == 1; } -float HomekitAccessory::getTargetTemperature() const { return targetTemperature->getVal(); } + void setState(bool active) const { + targetState->setVal(active ? 1 : 0, true); + state->setVal(active ? 1 : 0, true); + } -HomekitPlugin::HomekitPlugin(String wifiSsid, String wifiPassword) - : spanAccessory(nullptr), accessoryInformation(nullptr), identify(nullptr), accessory(nullptr), controller(nullptr) { - this->wifiSsid = std::move(wifiSsid); - this->wifiPassword = std::move(wifiPassword); + void setCurrentTemperature(float v) const { currentTemperature->setVal(v, true); } + void setTargetTemperature(float v) const { targetTemperature->setVal(v, true); } + float getTargetTemperature() const { return targetTemperature->getVal(); } + + private: + ChangeCallback callback; + SpanCharacteristic *state = nullptr; + SpanCharacteristic *targetState = nullptr; + SpanCharacteristic *currentTemperature = nullptr; + SpanCharacteristic *targetTemperature = nullptr; + SpanCharacteristic *displayUnits = nullptr; +}; + +} // namespace + +struct HomekitPlugin::Impl { + String wifiSsid; + String wifiPassword; + SpanAccessory *spanAccessory = nullptr; + Service::AccessoryInformation *accessoryInformation = nullptr; + Characteristic::Identify *identify = nullptr; + HomekitAccessory *accessory = nullptr; + ::Controller *controller = nullptr; // qualify: HomeSpan also declares ::Controller + // Set in the HomeSpan autoPoll() task callback, read+cleared in the Arduino + // loop task — atomic to avoid a cross-task data race on a plain bool. + std::atomic actionRequired{false}; +}; + +HomekitPlugin::HomekitPlugin(String wifiSsid, String wifiPassword) : impl(std::make_unique()) { + impl->wifiSsid = std::move(wifiSsid); + impl->wifiPassword = std::move(wifiPassword); } -bool HomekitPlugin::hasAction() const { return actionRequired; } +HomekitPlugin::~HomekitPlugin() = default; -void HomekitPlugin::clearAction() { actionRequired = false; } +bool HomekitPlugin::hasAction() const { return impl->actionRequired; } -void HomekitPlugin::setup(Controller *controller, PluginManager *pluginManager) { - this->controller = controller; +void HomekitPlugin::clearAction() { impl->actionRequired = false; } - pluginManager->on("controller:wifi:connect", [this](Event &event) { - int apMode = event.getInt("AP"); - if (apMode) - return; - if (accessory != nullptr) +void HomekitPlugin::setup(::Controller *controller, PluginManager *pluginManager) { + impl->controller = controller; + + pluginManager->on("controller:wifi:connect", [this](Event const &event) { + if (impl->spanAccessory != nullptr) + return; // already begun — handler must be idempotent + if (event.getInt("AP")) return; + heap_checkpoint("homekit/before-homespan-begin"); homeSpan.setHostNameSuffix(""); - homeSpan.setPortNum(HOMESPAN_PORT); - homeSpan.begin(Category::Thermostats, DEVICE_NAME, this->controller->getSettings().getMdnsName().c_str()); - homeSpan.setWifiCredentials(wifiSsid.c_str(), wifiPassword.c_str()); - spanAccessory = new SpanAccessory(); - accessoryInformation = new Service::AccessoryInformation(); - identify = new Characteristic::Identify(); - accessory = new HomekitAccessory([this]() { this->actionRequired = true; }); + homeSpan.setPortNum(kHomeSpanPort); + // Credentials before begin() so HomeSpan's NVS has them before its + // WiFi supervisor starts — otherwise the watchdog fires on first drop. + homeSpan.setWifiCredentials(impl->wifiSsid.c_str(), impl->wifiPassword.c_str()); + // Give HomeSpan a no-op WiFi-begin callback: GaggiMate's Controller + // already owns the STA lifecycle. Without this, HomeSpan's internal + // WIFI_ALARM watchdog calls ESP.restart() on every WiFi disconnect. + homeSpan.setWifiBegin([](const char *, const char *) { + // GaggiMate's Controller owns STA lifecycle; suppress HomeSpan WiFi restarts. + }); + homeSpan.begin(Category::Thermostats, kDeviceName, impl->controller->getSettings().getMdnsName().c_str()); + // HomeSpan takes ownership of every SpanAccessory/Service/Characteristic + // created with raw new — it registers them internally and manages their + // lifetime, so these are not leaks (do not delete them manually). + impl->spanAccessory = new SpanAccessory(); + impl->accessoryInformation = new Service::AccessoryInformation(); + impl->identify = new Characteristic::Identify(); + impl->accessory = new HomekitAccessory([this]() { impl->actionRequired = true; }); homeSpan.autoPoll(); + heap_checkpoint("homekit/after-homespan-begin"); }); pluginManager->on("boiler:targetTemperature:change", [this](Event const &event) { - if (accessory == nullptr) + if (impl->accessory == nullptr) return; - accessory->setTargetTemperature(event.getFloat("value")); + impl->accessory->setTargetTemperature(event.getFloat("value")); }); pluginManager->on("boiler:currentTemperature:change", [this](Event const &event) { - if (accessory == nullptr) + if (impl->accessory == nullptr) return; - accessory->setCurrentTemperature(event.getFloat("value")); + impl->accessory->setCurrentTemperature(event.getFloat("value")); }); pluginManager->on("controller:mode:change", [this](Event const &event) { - if (accessory == nullptr) + if (impl->accessory == nullptr) return; - accessory->setState(event.getInt("value") != MODE_STANDBY); + impl->accessory->setState(event.getInt("value") != MODE_STANDBY); }); } void HomekitPlugin::loop() { - if (!actionRequired || controller == nullptr || accessory == nullptr) + if (!impl->actionRequired || impl->controller == nullptr || impl->accessory == nullptr) return; - if (accessory->getState() && controller->getMode() == MODE_STANDBY) { - controller->deactivateStandby(); - } else if (!accessory->getState() && controller->getMode() != MODE_STANDBY) { - controller->activateStandby(); + if (impl->accessory->getState() && impl->controller->getMode() == MODE_STANDBY) { + impl->controller->deactivateStandby(); + } else if (!impl->accessory->getState() && impl->controller->getMode() != MODE_STANDBY) { + impl->controller->activateStandby(); } - controller->setTargetTemp(accessory->getTargetTemperature()); - actionRequired = false; + impl->controller->setTargetTemp(impl->accessory->getTargetTemperature()); + impl->actionRequired = false; } diff --git a/src/display/plugins/HomekitPlugin.h b/src/display/plugins/HomekitPlugin.h index 88c2ccb04..5ae1f06a5 100644 --- a/src/display/plugins/HomekitPlugin.h +++ b/src/display/plugins/HomekitPlugin.h @@ -1,49 +1,25 @@ #ifndef HOMEKITPLUGIN_H #define HOMEKITPLUGIN_H -#include "../core/Plugin.h" -#include "HomeSpan.h" - -#define HOMESPAN_PORT 8080 -#define DEVICE_NAME "GaggiMate" - -typedef std::function change_callback_t; -class HomekitAccessory : public Service::Thermostat { - public: - HomekitAccessory(change_callback_t callback); - boolean getState() const; - void setState(bool active) const; - boolean update() override; - void setCurrentTemperature(float temperatureValue) const; - void setTargetTemperature(float temperatureValue) const; - float getTargetTemperature() const; - private: - change_callback_t callback; - SpanCharacteristic *state; - SpanCharacteristic *targetState; - SpanCharacteristic *currentTemperature; - SpanCharacteristic *targetTemperature; - SpanCharacteristic *displayUnits; -}; +#include "../core/Plugin.h" +#include +#include +// HomeSpan 2.x defines a global `class Controller`; keep all HomeSpan types +// behind a PImpl so its header never pollutes ours. class HomekitPlugin : public Plugin { public: HomekitPlugin(String wifiSsid, String wifiPassword); + ~HomekitPlugin() override; void setup(Controller *controller, PluginManager *pluginManager) override; void loop() override; - boolean hasAction() const; + bool hasAction() const; void clearAction(); private: - String wifiSsid; - String wifiPassword; - SpanAccessory *spanAccessory; - Service::AccessoryInformation *accessoryInformation; - Characteristic::Identify *identify; - HomekitAccessory *accessory; - bool actionRequired = false; - Controller *controller; + struct Impl; + std::unique_ptr impl; }; #endif // HOMEKITPLUGIN_H diff --git a/src/display/plugins/MQTTPlugin.cpp b/src/display/plugins/MQTTPlugin.cpp index 848c019f7..3e98a321f 100644 --- a/src/display/plugins/MQTTPlugin.cpp +++ b/src/display/plugins/MQTTPlugin.cpp @@ -5,7 +5,7 @@ #include #include -const String LOG_TAG = F("MQTTPlugin"); +static constexpr const char *LOG_TAG = "MQTTPlugin"; bool MQTTPlugin::connect(Controller *controller) { const Settings settings = controller->getSettings(); @@ -17,16 +17,16 @@ bool MQTTPlugin::connect(Controller *controller) { client.begin(ip.c_str(), haPort, net); client.setKeepAlive(10); - ESP_LOGI(LOG_TAG.c_str(), "Connecting to %s:%d", ip.c_str(), haPort); + ESP_LOGI(LOG_TAG, "Connecting to %s:%d", ip.c_str(), haPort); for (int i = 0; i < MQTT_CONNECTION_RETRIES; i++) { - ESP_LOGD(LOG_TAG.c_str(), "Attempt (%d/%d)", i + 1, MQTT_CONNECTION_RETRIES); + ESP_LOGD(LOG_TAG, "Attempt (%d/%d)", i + 1, MQTT_CONNECTION_RETRIES); if (client.connect(clientId.c_str(), haUser.c_str(), haPassword.c_str())) { - ESP_LOGI(LOG_TAG.c_str(), "Successfully connected"); + ESP_LOGI(LOG_TAG, "Successfully connected"); return true; } delay(MQTT_CONNECTION_DELAY); } - ESP_LOGW(LOG_TAG.c_str(), "Connection failed"); + ESP_LOGW(LOG_TAG, "Connection failed"); return false; } @@ -104,7 +104,7 @@ void MQTTPlugin::publishDiscovery(Controller *controller) { String payloadStr; serializeJson(payload, payloadStr); - ESP_LOGD(LOG_TAG.c_str(), "Publishing discovery %s: %s", publishTopic, payloadStr.c_str()); + ESP_LOGD(LOG_TAG, "Publishing discovery %s: %s", publishTopic, payloadStr.c_str()); client.publish(publishTopic, payloadStr); } @@ -117,7 +117,7 @@ void MQTTPlugin::publish(const std::string &topic, const std::string &message) { char publishTopic[80]; snprintf(publishTopic, sizeof(publishTopic), "gaggimate/%s/%s", cmac, topic.c_str()); - ESP_LOGD(LOG_TAG.c_str(), "Publishing %s: %s", publishTopic, message.c_str()); + ESP_LOGD(LOG_TAG, "Publishing %s: %s", publishTopic, message.c_str()); client.publish(publishTopic, message.c_str()); } void MQTTPlugin::publishBrewState(const char *state) { diff --git a/src/display/plugins/ShotHistoryPlugin.h b/src/display/plugins/ShotHistoryPlugin.h index 227ddc467..d482022c8 100644 --- a/src/display/plugins/ShotHistoryPlugin.h +++ b/src/display/plugins/ShotHistoryPlugin.h @@ -87,7 +87,7 @@ class ShotHistoryPlugin : public Plugin { // Async rebuild state bool rebuildInProgress = false; - xTaskHandle taskHandle; + TaskHandle_t taskHandle; void flushBuffer(); static void loopTask(void *arg); }; diff --git a/src/display/plugins/WebUIPlugin.cpp b/src/display/plugins/WebUIPlugin.cpp index 825264f8d..84657004b 100644 --- a/src/display/plugins/WebUIPlugin.cpp +++ b/src/display/plugins/WebUIPlugin.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -90,10 +91,15 @@ void WebUIPlugin::loop() { } const long now = millis(); if ((lastUpdateCheck == 0 || now > lastUpdateCheck + UPDATE_CHECK_INTERVAL)) { - ota->checkForUpdates(); - pluginManager->trigger("ota:update:status", "value", ota->isUpdateAvailable()); + // Skip OTA check in AP mode: no route to GitHub, and mbedtls handshake + // allocates ~30 KB internal heap that the pioarduino 55.x prebuilt + // can't spare — spike triggers BLE_INIT malloc failures. + if (!apMode) { + ota->checkForUpdates(); + pluginManager->trigger("ota:update:status", "value", ota->isUpdateAvailable()); + updateOTAStatus(ota->getCurrentVersion()); + } lastUpdateCheck = now; - updateOTAStatus(ota->getCurrentVersion()); } if (now > lastStatus + STATUS_PERIOD && !ws.getClients().empty()) { lastStatus = now; @@ -227,6 +233,7 @@ void WebUIPlugin::setupServer() { server.on("/api/scales/connect", [this](AsyncWebServerRequest *request) { handleBLEScaleConnect(request); }); server.on("/api/scales/scan", [this](AsyncWebServerRequest *request) { handleBLEScaleScan(request); }); server.on("/api/scales/info", [this](AsyncWebServerRequest *request) { handleBLEScaleInfo(request); }); + server.on("/api/debug/heap", [this](AsyncWebServerRequest *request) { handleDebugHeap(request); }); FS *fs = &SPIFFS; if (controller->isSDCard()) { fs = &SD_MMC; @@ -786,6 +793,43 @@ void WebUIPlugin::handleBLEScaleInfo(AsyncWebServerRequest *request) { request->send(response); } +void WebUIPlugin::handleDebugHeap(AsyncWebServerRequest *request) { + AsyncResponseStream *response = request->beginResponseStream("application/json"); + JsonDocument doc; + MemorySnapshot snap; + if (gaggimate::memmon::isReady()) { + snap = gaggimate::memmon::instance().sampleNow(); + } + const RegionStats *ri = nullptr; + const RegionStats *rp = nullptr; + for (const auto &rs : snap.regions) { + if (rs.region == MemoryRegion::Internal) + ri = &rs; + else if (rs.region == MemoryRegion::Psram) + rp = &rs; + } + JsonObject internalObj = doc["internal"].to(); + constexpr uint32_t kInternalCaps = MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL; + internalObj["free"] = ri ? ri->freeBytes : heap_caps_get_free_size(kInternalCaps); + internalObj["largest"] = ri ? ri->largestFreeBlock : heap_caps_get_largest_free_block(kInternalCaps); + internalObj["total"] = heap_caps_get_total_size(kInternalCaps); + internalObj["minimum_free"] = ri ? ri->minimumFreeBytes : heap_caps_get_minimum_free_size(kInternalCaps); + JsonObject psObj = doc["psram"].to(); + psObj["free"] = rp ? rp->freeBytes : heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + psObj["largest"] = rp ? rp->largestFreeBlock : heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM); + psObj["total"] = heap_caps_get_total_size(MALLOC_CAP_SPIRAM); + psObj["minimum_free"] = rp ? rp->minimumFreeBytes : heap_caps_get_minimum_free_size(MALLOC_CAP_SPIRAM); + doc["fragmentation_internal"] = ri ? ri->fragmentation : 0.0f; + doc["fragmentation_psram"] = rp ? rp->fragmentation : 0.0f; + if (ri) { + internalObj["slope"] = ri->freeBytesSlope; + internalObj["seconds_to_warn"] = ri->secondsToWarn; + internalObj["seconds_to_critical"] = ri->secondsToCritical; + } + serializeJson(doc, *response); + request->send(response); +} + void WebUIPlugin::updateOTAStatus(const String &version) { if (ws.getClients().empty()) { return; @@ -814,14 +858,29 @@ void WebUIPlugin::updateOTAStatus(const String &version) { doc["spiffsUsedPct"] = static_cast((used * 100) / total); } } - // Memory usage metrics + // Memory usage metrics — sourced from ESPMemoryMonitor so the settings UI + // shares a single source of truth with /api/debug/heap and the 60 s sampler + // task. Falls back to heap_caps_* during the boot window before init(). { - size_t free = heap_caps_get_free_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); - size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); - size_t total = heap_caps_get_total_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); - doc["heapFree"] = static_cast(free); - doc["heapLargest"] = static_cast(largest); + const RegionStats *ri = nullptr; + MemorySnapshot snap; + if (gaggimate::memmon::isReady()) { + snap = gaggimate::memmon::instance().sampleNow(); + for (const auto &rs : snap.regions) { + if (rs.region == MemoryRegion::Internal) { + ri = &rs; + break; + } + } + } + const size_t total = heap_caps_get_total_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL); + doc["heapFree"] = static_cast(ri ? ri->freeBytes + : heap_caps_get_free_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL)); + doc["heapLargest"] = static_cast( + ri ? ri->largestFreeBlock : heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL)); doc["heapTotal"] = static_cast(total); + doc["heapMinimum"] = static_cast( + ri ? ri->minimumFreeBytes : heap_caps_get_minimum_free_size(MALLOC_CAP_DEFAULT | MALLOC_CAP_INTERNAL)); } doc["controllerTaskHealth"] = controller->isTaskHealthy(); #ifndef GAGGIMATE_HEADLESS diff --git a/src/display/plugins/WebUIPlugin.h b/src/display/plugins/WebUIPlugin.h index 7223a3934..7b08bb1dc 100644 --- a/src/display/plugins/WebUIPlugin.h +++ b/src/display/plugins/WebUIPlugin.h @@ -47,6 +47,7 @@ class WebUIPlugin : public Plugin { void handleBLEScaleScan(AsyncWebServerRequest *request); void handleBLEScaleConnect(AsyncWebServerRequest *request); void handleBLEScaleInfo(AsyncWebServerRequest *request); + void handleDebugHeap(AsyncWebServerRequest *request); void updateOTAStatus(const String &version); void updateOTAProgress(uint8_t phase, int progress); void sendAutotuneResult(); diff --git a/src/display/ui/default/DefaultUI.cpp b/src/display/ui/default/DefaultUI.cpp index fb65d872a..4e283e6f6 100644 --- a/src/display/ui/default/DefaultUI.cpp +++ b/src/display/ui/default/DefaultUI.cpp @@ -83,40 +83,40 @@ DefaultUI::DefaultUI(Controller *controller, Driver *driver, PluginManager *plug void DefaultUI::init() { profileManager = controller->getProfileManager(); auto triggerRender = [this](Event const &) { rerender = true; }; - pluginManager->on("boiler:currentTemperature:change", [=](Event const &event) { + pluginManager->on("boiler:currentTemperature:change", [this](Event const &event) { int newTemp = static_cast(event.getFloat("value")); if (newTemp != currentTemp) { currentTemp = newTemp; rerender = true; } }); - pluginManager->on("boiler:pressure:change", [=](Event const &event) { + pluginManager->on("boiler:pressure:change", [this](Event const &event) { float newPressure = event.getFloat("value"); if (round(newPressure * 10.0f) != round(pressure * 10.0f)) { pressure = newPressure; rerender = true; } }); - pluginManager->on("boiler:targetTemperature:change", [=](Event const &event) { + pluginManager->on("boiler:targetTemperature:change", [this](Event const &event) { int newTemp = static_cast(event.getFloat("value")); if (newTemp != targetTemp) { targetTemp = newTemp; rerender = true; } }); - pluginManager->on("controller:targetVolume:change", [=](Event const &event) { + pluginManager->on("controller:targetVolume:change", [this](Event const &event) { targetVolume = event.getFloat("value"); rerender = true; }); - pluginManager->on("controller:targetDuration:change", [=](Event const &event) { + pluginManager->on("controller:targetDuration:change", [this](Event const &event) { targetDuration = event.getFloat("value"); rerender = true; }); - pluginManager->on("controller:grindDuration:change", [=](Event const &event) { + pluginManager->on("controller:grindDuration:change", [this](Event const &event) { grindDuration = event.getInt("value"); rerender = true; }); - pluginManager->on("controller:grindVolume:change", [=](Event const &event) { + pluginManager->on("controller:grindVolume:change", [this](Event const &event) { grindVolume = event.getFloat("value"); rerender = true; }); @@ -226,7 +226,7 @@ void DefaultUI::init() { pluginManager->on("profiles:profile:favorite", [this](Event const &event) { reloadProfiles(); }); pluginManager->on("profiles:profile:unfavorite", [this](Event const &event) { reloadProfiles(); }); pluginManager->on("profiles:profile:save", [this](Event const &event) { reloadProfiles(); }); - pluginManager->on("controller:volumetric-measurement:bluetooth:change", [=](Event const &event) { + pluginManager->on("controller:volumetric-measurement:bluetooth:change", [this](Event const &event) { double newWeight = event.getFloat("value"); if (round(newWeight * 10.0) != round(bluetoothWeight * 10.0)) { bluetoothWeight = newWeight; @@ -391,137 +391,137 @@ void DefaultUI::setupState() { } void DefaultUI::setupReactive() { - effect_mgr.use_effect([=] { return currentScreen == ui_MenuScreen; }, [=]() { adjustDials(ui_MenuScreen_dials); }, + effect_mgr.use_effect([this] { return currentScreen == ui_MenuScreen; }, [this]() { adjustDials(ui_MenuScreen_dials); }, &pressureAvailable); - effect_mgr.use_effect([=] { return currentScreen == ui_StatusScreen; }, [=]() { adjustDials(ui_StatusScreen_dials); }, + effect_mgr.use_effect([this] { return currentScreen == ui_StatusScreen; }, [this]() { adjustDials(ui_StatusScreen_dials); }, &pressureAvailable); - effect_mgr.use_effect([=] { return currentScreen == ui_BrewScreen; }, [=]() { adjustDials(ui_BrewScreen_dials); }, + effect_mgr.use_effect([this] { return currentScreen == ui_BrewScreen; }, [this]() { adjustDials(ui_BrewScreen_dials); }, &pressureAvailable); - effect_mgr.use_effect([=] { return currentScreen == ui_GrindScreen; }, [=]() { adjustDials(ui_GrindScreen_dials); }, + effect_mgr.use_effect([this] { return currentScreen == ui_GrindScreen; }, [this]() { adjustDials(ui_GrindScreen_dials); }, &pressureAvailable); - effect_mgr.use_effect([=] { return currentScreen == ui_SimpleProcessScreen; }, - [=]() { adjustDials(ui_SimpleProcessScreen_dials); }, &pressureAvailable); - effect_mgr.use_effect([=] { return currentScreen == ui_ProfileScreen; }, [=]() { adjustDials(ui_ProfileScreen_dials); }, + effect_mgr.use_effect([this] { return currentScreen == ui_SimpleProcessScreen; }, + [this]() { adjustDials(ui_SimpleProcessScreen_dials); }, &pressureAvailable); + effect_mgr.use_effect([this] { return currentScreen == ui_ProfileScreen; }, [this]() { adjustDials(ui_ProfileScreen_dials); }, &pressureAvailable); - effect_mgr.use_effect([=] { return currentScreen == ui_BrewScreen; }, [=]() { adjustHeatingIndicator(ui_BrewScreen_dials); }, + effect_mgr.use_effect([this] { return currentScreen == ui_BrewScreen; }, [this]() { adjustHeatingIndicator(ui_BrewScreen_dials); }, &isTemperatureStable, &heatingFlash); - effect_mgr.use_effect([=] { return currentScreen == ui_SimpleProcessScreen; }, - [=]() { adjustHeatingIndicator(ui_SimpleProcessScreen_dials); }, &isTemperatureStable, &heatingFlash); - effect_mgr.use_effect([=] { return currentScreen == ui_MenuScreen; }, [=]() { adjustHeatingIndicator(ui_MenuScreen_dials); }, + effect_mgr.use_effect([this] { return currentScreen == ui_SimpleProcessScreen; }, + [this]() { adjustHeatingIndicator(ui_SimpleProcessScreen_dials); }, &isTemperatureStable, &heatingFlash); + effect_mgr.use_effect([this] { return currentScreen == ui_MenuScreen; }, [this]() { adjustHeatingIndicator(ui_MenuScreen_dials); }, &isTemperatureStable, &heatingFlash); - effect_mgr.use_effect([=] { return currentScreen == ui_ProfileScreen; }, - [=]() { adjustHeatingIndicator(ui_ProfileScreen_dials); }, &isTemperatureStable, &heatingFlash); - effect_mgr.use_effect([=] { return currentScreen == ui_GrindScreen; }, - [=]() { adjustHeatingIndicator(ui_GrindScreen_dials); }, &isTemperatureStable, &heatingFlash); - effect_mgr.use_effect([=] { return currentScreen == ui_StatusScreen; }, - [=]() { adjustHeatingIndicator(ui_StatusScreen_dials); }, &isTemperatureStable, &heatingFlash); - effect_mgr.use_effect([=] { return currentScreen == ui_SimpleProcessScreen; }, - [=]() { lv_label_set_text(ui_SimpleProcessScreen_mainLabel5, mode == MODE_STEAM ? "Steam" : "Water"); }, + effect_mgr.use_effect([this] { return currentScreen == ui_ProfileScreen; }, + [this]() { adjustHeatingIndicator(ui_ProfileScreen_dials); }, &isTemperatureStable, &heatingFlash); + effect_mgr.use_effect([this] { return currentScreen == ui_GrindScreen; }, + [this]() { adjustHeatingIndicator(ui_GrindScreen_dials); }, &isTemperatureStable, &heatingFlash); + effect_mgr.use_effect([this] { return currentScreen == ui_StatusScreen; }, + [this]() { adjustHeatingIndicator(ui_StatusScreen_dials); }, &isTemperatureStable, &heatingFlash); + effect_mgr.use_effect([this] { return currentScreen == ui_SimpleProcessScreen; }, + [this]() { lv_label_set_text(ui_SimpleProcessScreen_mainLabel5, mode == MODE_STEAM ? "Steam" : "Water"); }, &mode); - effect_mgr.use_effect([=] { return currentScreen == ui_MenuScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_MenuScreen; }, + [this]() { lv_arc_set_value(uic_MenuScreen_dials_tempGauge, currentTemp); lv_label_set_text_fmt(uic_MenuScreen_dials_tempText, "%d°C", currentTemp); }, ¤tTemp); - effect_mgr.use_effect([=] { return currentScreen == ui_StatusScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_StatusScreen; }, + [this]() { lv_arc_set_value(uic_StatusScreen_dials_tempGauge, currentTemp); lv_label_set_text_fmt(uic_StatusScreen_dials_tempText, "%d°C", currentTemp); }, ¤tTemp); - effect_mgr.use_effect([=] { return currentScreen == ui_BrewScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_BrewScreen; }, + [this]() { lv_arc_set_value(uic_BrewScreen_dials_tempGauge, currentTemp); lv_label_set_text_fmt(uic_BrewScreen_dials_tempText, "%d°C", currentTemp); }, ¤tTemp); - effect_mgr.use_effect([=] { return currentScreen == ui_GrindScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_GrindScreen; }, + [this]() { lv_arc_set_value(uic_GrindScreen_dials_tempGauge, currentTemp); lv_label_set_text_fmt(uic_GrindScreen_dials_tempText, "%d°C", currentTemp); }, ¤tTemp); - effect_mgr.use_effect([=] { return currentScreen == ui_SimpleProcessScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_SimpleProcessScreen; }, + [this]() { lv_arc_set_value(uic_SimpleProcessScreen_dials_tempGauge, currentTemp); lv_label_set_text_fmt(uic_SimpleProcessScreen_dials_tempText, "%d°C", currentTemp); }, ¤tTemp); - effect_mgr.use_effect([=] { return currentScreen == ui_ProfileScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_ProfileScreen; }, + [this]() { lv_arc_set_value(uic_ProfileScreen_dials_tempGauge, currentTemp); lv_label_set_text_fmt(uic_ProfileScreen_dials_tempText, "%d°C", currentTemp); }, ¤tTemp); - effect_mgr.use_effect([=] { return currentScreen == ui_MenuScreen; }, [=]() { adjustTempTarget(ui_MenuScreen_dials); }, + effect_mgr.use_effect([this] { return currentScreen == ui_MenuScreen; }, [this]() { adjustTempTarget(ui_MenuScreen_dials); }, &targetTemp); - effect_mgr.use_effect([=] { return currentScreen == ui_StatusScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_StatusScreen; }, + [this]() { lv_label_set_text_fmt(ui_StatusScreen_targetTemp, "%d°C", targetTemp); adjustTempTarget(ui_StatusScreen_dials); }, &targetTemp); - effect_mgr.use_effect([=] { return currentScreen == ui_BrewScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_BrewScreen; }, + [this]() { lv_label_set_text_fmt(ui_BrewScreen_targetTemp, "%d°C", targetTemp); adjustTempTarget(ui_BrewScreen_dials); }, &targetTemp); - effect_mgr.use_effect([=] { return currentScreen == ui_GrindScreen; }, [=]() { adjustTempTarget(ui_GrindScreen_dials); }, + effect_mgr.use_effect([this] { return currentScreen == ui_GrindScreen; }, [this]() { adjustTempTarget(ui_GrindScreen_dials); }, &targetTemp); - effect_mgr.use_effect([=] { return currentScreen == ui_SimpleProcessScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_SimpleProcessScreen; }, + [this]() { lv_label_set_text_fmt(ui_SimpleProcessScreen_targetTemp, "%d°C", targetTemp); adjustTempTarget(ui_SimpleProcessScreen_dials); }, &targetTemp); - effect_mgr.use_effect([=] { return currentScreen == ui_ProfileScreen; }, [=]() { adjustTempTarget(ui_ProfileScreen_dials); }, + effect_mgr.use_effect([this] { return currentScreen == ui_ProfileScreen; }, [this]() { adjustTempTarget(ui_ProfileScreen_dials); }, &targetTemp); - effect_mgr.use_effect([=] { return currentScreen == ui_MenuScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_MenuScreen; }, + [this]() { lv_arc_set_value(uic_MenuScreen_dials_pressureGauge, pressure * 10.0f); lv_label_set_text_fmt(uic_MenuScreen_dials_pressureText, "%.1f bar", pressure); }, &pressure); - effect_mgr.use_effect([=] { return currentScreen == ui_StatusScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_StatusScreen; }, + [this]() { lv_arc_set_value(uic_StatusScreen_dials_pressureGauge, pressure * 10.0f); lv_label_set_text_fmt(uic_StatusScreen_dials_pressureText, "%.1f bar", pressure); }, &pressure); - effect_mgr.use_effect([=] { return currentScreen == ui_BrewScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_BrewScreen; }, + [this]() { lv_arc_set_value(uic_BrewScreen_dials_pressureGauge, pressure * 10.0f); lv_label_set_text_fmt(uic_BrewScreen_dials_pressureText, "%.1f bar", pressure); }, &pressure); - effect_mgr.use_effect([=] { return currentScreen == ui_GrindScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_GrindScreen; }, + [this]() { lv_arc_set_value(uic_GrindScreen_dials_pressureGauge, pressure * 10.0f); lv_label_set_text_fmt(uic_GrindScreen_dials_pressureText, "%.1f bar", pressure); }, &pressure); - effect_mgr.use_effect([=] { return currentScreen == ui_SimpleProcessScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_SimpleProcessScreen; }, + [this]() { lv_arc_set_value(uic_SimpleProcessScreen_dials_pressureGauge, pressure * 10.0f); lv_label_set_text_fmt(uic_SimpleProcessScreen_dials_pressureText, "%.1f bar", pressure); }, &pressure); - effect_mgr.use_effect([=] { return currentScreen == ui_ProfileScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_ProfileScreen; }, + [this]() { lv_arc_set_value(uic_ProfileScreen_dials_pressureGauge, pressure * 10.0f); lv_label_set_text_fmt(uic_ProfileScreen_dials_pressureText, "%.1f bar", pressure); }, &pressure); - effect_mgr.use_effect([=] { return currentScreen == ui_StandbyScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_StandbyScreen; }, + [this]() { updateAvailable ? lv_obj_clear_flag(ui_StandbyScreen_updateIcon, LV_OBJ_FLAG_HIDDEN) : lv_obj_add_flag(ui_StandbyScreen_updateIcon, LV_OBJ_FLAG_HIDDEN); }, &updateAvailable); - effect_mgr.use_effect([=] { return currentScreen == ui_StandbyScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_StandbyScreen; }, + [this]() { bool deactivated = true; if (updateActive) { lv_label_set_text_fmt(ui_StandbyScreen_mainLabel, "Updating..."); @@ -543,8 +543,8 @@ void DefaultUI::setupReactive() { _ui_flag_modify(ui_StandbyScreen_statusContainer, LV_OBJ_FLAG_HIDDEN, !deactivated); }, &updateAvailable, &error, &protocolMismatch, &autotuning, &waitingForController, &initialized); - effect_mgr.use_effect([=] { return currentScreen == ui_BrewScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_BrewScreen; }, + [this]() { if (brewVolumetric) { lv_label_set_text_fmt(ui_BrewScreen_targetDuration, "%.1fg", targetVolume); } else { @@ -555,8 +555,8 @@ void DefaultUI::setupReactive() { } }, &targetDuration, &targetVolume, &brewVolumetric); - effect_mgr.use_effect([=] { return currentScreen == ui_GrindScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_GrindScreen; }, + [this]() { if (volumetricMode) { lv_label_set_text_fmt(ui_GrindScreen_targetDuration, "%.1fg", grindVolume); } else { @@ -567,15 +567,15 @@ void DefaultUI::setupReactive() { } }, &grindDuration, &grindVolume, &volumetricMode); - effect_mgr.use_effect([=] { return currentScreen == ui_BrewScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_BrewScreen; }, + [this]() { lv_img_set_src(ui_BrewScreen_Image4, brewVolumetric ? &ui_img_1424216268 : &ui_img_360122106); _ui_flag_modify(ui_BrewScreen_byTimeButton, LV_OBJ_FLAG_HIDDEN, brewVolumetric); }, &brewVolumetric); effect_mgr.use_effect( - [=] { return currentScreen == ui_GrindScreen; }, - [=]() { + [this] { return currentScreen == ui_GrindScreen; }, + [this]() { lv_img_set_src(ui_GrindScreen_targetSymbol, volumetricMode ? &ui_img_1424216268 : &ui_img_360122106); ui_object_set_themeable_style_property(ui_GrindScreen_weightLabel, LV_PART_MAIN | LV_STATE_DEFAULT, LV_STYLE_TEXT_COLOR, @@ -587,11 +587,11 @@ void DefaultUI::setupReactive() { volumetricMode ? _ui_theme_color_NiceWhite : _ui_theme_color_Dark); }, &volumetricMode); - effect_mgr.use_effect([=] { return currentScreen == ui_GrindScreen; }, - [=]() { _ui_flag_modify(ui_GrindScreen_modeSwitch, LV_OBJ_FLAG_HIDDEN, volumetricAvailable); }, + effect_mgr.use_effect([this] { return currentScreen == ui_GrindScreen; }, + [this]() { _ui_flag_modify(ui_GrindScreen_modeSwitch, LV_OBJ_FLAG_HIDDEN, volumetricAvailable); }, &volumetricAvailable); - effect_mgr.use_effect([=] { return currentScreen == ui_SimpleProcessScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_SimpleProcessScreen; }, + [this]() { if (mode == MODE_STEAM) { _ui_flag_modify(ui_SimpleProcessScreen_goButton, LV_OBJ_FLAG_HIDDEN, active); lv_imgbtn_set_src(ui_SimpleProcessScreen_goButton, LV_IMGBTN_STATE_RELEASED, nullptr, @@ -602,19 +602,19 @@ void DefaultUI::setupReactive() { } }, &active, &mode); - effect_mgr.use_effect([=] { return currentScreen == ui_GrindScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_GrindScreen; }, + [this]() { lv_imgbtn_set_src(ui_GrindScreen_startButton, LV_IMGBTN_STATE_RELEASED, nullptr, grindActive ? &ui_img_1456692430 : &ui_img_445946954, nullptr); }, &grindActive); - effect_mgr.use_effect([=] { return currentScreen == ui_BrewScreen; }, - [=] { lv_label_set_text(ui_BrewScreen_profileName, selectedProfile.label.c_str()); }, + effect_mgr.use_effect([this] { return currentScreen == ui_BrewScreen; }, + [this] { lv_label_set_text(ui_BrewScreen_profileName, selectedProfile.label.c_str()); }, &selectedProfileId); effect_mgr.use_effect( - [=] { return currentScreen == ui_ProfileScreen; }, - [=] { + [this] { return currentScreen == ui_ProfileScreen; }, + [this] { if (profileLoaded) { _ui_flag_modify(ui_ProfileScreen_profileDetails, LV_OBJ_FLAG_HIDDEN, _UI_MODIFY_FLAG_REMOVE); _ui_flag_modify(ui_ProfileScreen_loadingSpinner, LV_OBJ_FLAG_HIDDEN, _UI_MODIFY_FLAG_ADD); @@ -651,14 +651,14 @@ void DefaultUI::setupReactive() { ¤tProfileIdx, &profileLoaded); // Show/hide grind button based on SmartGrind setting or Alt Relay function - effect_mgr.use_effect([=] { return currentScreen == ui_MenuScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_MenuScreen; }, + [this]() { grindAvailable ? lv_obj_clear_flag(ui_MenuScreen_grindBtn, LV_OBJ_FLAG_HIDDEN) : lv_obj_add_flag(ui_MenuScreen_grindBtn, LV_OBJ_FLAG_HIDDEN); }, &grindAvailable); - effect_mgr.use_effect([=] { return currentScreen == ui_BrewScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_BrewScreen; }, + [this]() { if (volumetricAvailable && bluetoothScales) { lv_label_set_text_fmt(ui_BrewScreen_weightLabel, "%.1fg", bluetoothWeight); } else { @@ -666,8 +666,8 @@ void DefaultUI::setupReactive() { } }, &bluetoothWeight, &volumetricAvailable, &bluetoothScales); - effect_mgr.use_effect([=] { return currentScreen == ui_GrindScreen; }, - [=]() { + effect_mgr.use_effect([this] { return currentScreen == ui_GrindScreen; }, + [this]() { if (volumetricAvailable && bluetoothScales) { lv_label_set_text_fmt(ui_GrindScreen_weightLabel, "%.1fg", bluetoothWeight); } else { @@ -676,8 +676,8 @@ void DefaultUI::setupReactive() { }, &bluetoothWeight, &volumetricAvailable, &bluetoothScales); effect_mgr.use_effect( - [=] { return currentScreen == ui_BrewScreen; }, - [=]() { + [this] { return currentScreen == ui_BrewScreen; }, + [this]() { _ui_flag_modify(ui_BrewScreen_adjustments, LV_OBJ_FLAG_HIDDEN, brewScreenState == BrewScreenState::Settings); _ui_flag_modify(ui_BrewScreen_acceptButton, LV_OBJ_FLAG_HIDDEN, brewScreenState == BrewScreenState::Settings); _ui_flag_modify(ui_BrewScreen_saveButton, LV_OBJ_FLAG_HIDDEN, brewScreenState == BrewScreenState::Settings); @@ -692,8 +692,8 @@ void DefaultUI::setupReactive() { }, &brewScreenState, &volumetricAvailable, &bluetoothScales); effect_mgr.use_effect( - [=] { return currentScreen == ui_BrewScreen; }, - [=]() { + [this] { return currentScreen == ui_BrewScreen; }, + [this]() { ui_object_set_themeable_style_property(ui_BrewScreen_saveButton, LV_PART_MAIN | LV_STATE_DEFAULT, LV_STYLE_IMG_RECOLOR, profileDirty ? _ui_theme_color_NiceWhite : _ui_theme_color_SemiDark); @@ -722,7 +722,9 @@ void DefaultUI::handleScreenChange() { } _ui_screen_change(targetScreen, LV_SCR_LOAD_ANIM_NONE, 0, 0, targetScreenInit); - lv_obj_del(current); + if (current && lv_obj_is_valid(current) && current != *targetScreen) { + lv_obj_del(current); + } rerender = true; } } diff --git a/src/display/ui/default/DefaultUI.h b/src/display/ui/default/DefaultUI.h index 84511310c..aefd3f51d 100644 --- a/src/display/ui/default/DefaultUI.h +++ b/src/display/ui/default/DefaultUI.h @@ -143,9 +143,9 @@ class DefaultUI { // Standby brightness control unsigned long standbyEnterTime = 0; - xTaskHandle taskHandle; + TaskHandle_t taskHandle; static void loopTask(void *arg); - xTaskHandle profileTaskHandle; + TaskHandle_t profileTaskHandle; static void profileLoopTask(void *arg); }; diff --git a/src/idf_component.yml b/src/idf_component.yml new file mode 100644 index 000000000..d72bc8691 --- /dev/null +++ b/src/idf_component.yml @@ -0,0 +1,6 @@ +dependencies: + # Pure-IDF C++ wrapper for NimBLE. Links directly against IDF's bt component; + # no bundled host C code and no duplicate NimBLE symbols. Also the only path + # that works for ESP32-P4 / esp_hosted-mcu. + h2zero/esp-nimble-cpp: + version: "~2.5.0" From bbec46a9ca5670ad41ee99674a7d38f086493a93 Mon Sep 17 00:00:00 2001 From: Jochen Ullrich Date: Mon, 1 Jun 2026 08:22:23 +0200 Subject: [PATCH 2/5] fix: Fix touch driver spam --- src/display/drivers/LilyGo-T-RGB/LilyGo_RGBPanel.cpp | 4 ++++ src/display/drivers/Waveshare/WavesharePanel.cpp | 1 + 2 files changed, 5 insertions(+) diff --git a/src/display/drivers/LilyGo-T-RGB/LilyGo_RGBPanel.cpp b/src/display/drivers/LilyGo-T-RGB/LilyGo_RGBPanel.cpp index fc166f776..890150154 100644 --- a/src/display/drivers/LilyGo-T-RGB/LilyGo_RGBPanel.cpp +++ b/src/display/drivers/LilyGo-T-RGB/LilyGo_RGBPanel.cpp @@ -431,6 +431,10 @@ bool LilyGo_RGBPanel::initTouch() { _touchDrv->setPins(touch_reset_pin, touch_irq_pin); result = _touchDrv->begin(Wire, CST816_SLAVE_ADDRESS, BOARD_I2C_SDA, BOARD_I2C_SCL); if (result) { + // Keep CST816/CST820 awake: it auto-sleeps when idle and then NACKs every + // poll, which IDF 5.x's i2c.master driver logs at ERROR level. No-op on + // non-CST816 chips. + static_cast(_touchDrv)->disableAutoSleep(); _init_cmd = st7701_2_1_inches; diff --git a/src/display/drivers/Waveshare/WavesharePanel.cpp b/src/display/drivers/Waveshare/WavesharePanel.cpp index 8b5afe214..1489ef59f 100644 --- a/src/display/drivers/Waveshare/WavesharePanel.cpp +++ b/src/display/drivers/Waveshare/WavesharePanel.cpp @@ -879,6 +879,7 @@ bool WavesharePanel::initTouch() { _touchDrv->setPins(0x00, touch_irq_pin); result = _touchDrv->begin(Wire, CST816_SLAVE_ADDRESS, WS_BOARD_I2C_SDA, WS_BOARD_I2C_SCL); if (result) { + static_cast(_touchDrv)->disableAutoSleep(); const char *model = _touchDrv->getModelName(); log_i("Successfully initialized %s, using %s Driver!\n", model, model); return true; From d9574653f8c3c3956dc24f2838a26330da940614 Mon Sep 17 00:00:00 2001 From: Jochen Ullrich Date: Mon, 1 Jun 2026 08:22:33 +0200 Subject: [PATCH 3/5] fix: Fix connection issue with nimble 2 --- lib/NanoPbComm/src/ble/BleClientTransport.cpp | 17 +++++++++++------ lib/NanoPbComm/src/ble/BleClientTransport.h | 12 +++++++----- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/lib/NanoPbComm/src/ble/BleClientTransport.cpp b/lib/NanoPbComm/src/ble/BleClientTransport.cpp index 3862d7015..65c0eb543 100644 --- a/lib/NanoPbComm/src/ble/BleClientTransport.cpp +++ b/lib/NanoPbComm/src/ble/BleClientTransport.cpp @@ -50,7 +50,7 @@ bool BleClientTransport::connectToServer() { if (!_haveServerAddress) return false; - ESP_LOGI(LOG_TAG, "Connecting to advertised device"); + ESP_LOGI(LOG_TAG, "Connecting to advertised device: %s", _serverAddress.toString().c_str()); unsigned int tries = 0; do { if (tries >= MAX_CONNECT_RETRIES) { @@ -59,7 +59,8 @@ bool BleClientTransport::connectToServer() { return false; } if (!_client->connect(_serverAddress)) { - ESP_LOGW(LOG_TAG, "Connect failed, retrying"); + int error = _client->getLastError(); + ESP_LOGW(LOG_TAG, "Connect failed: %d, retrying", error); delay(500); } tries++; @@ -149,12 +150,16 @@ void BleClientTransport::onResult(const NimBLEAdvertisedDevice *advertisedDevice if (!advertisedDevice->haveServiceUUID()) return; if (advertisedDevice->isAdvertisingService(NimBLEUUID(gm_proto::SERVICE_UUID))) { - ESP_LOGI(LOG_TAG, "Found controller, ready to connect"); - _scanner->stop(); - // Take a value copy of the address now -- the device object is freed as - // soon as this callback returns (see _serverAddress note in the header). + // Copy everything we need off advertisedDevice BEFORE stopping the scan. + // With setMaxResults(0), NimBLEScan::stop() calls clearResults(), which + // deletes the very advertisedDevice handed to this callback -- so reading + // it after stop() is a use-after-free that returns a garbage peer address + // (the connect then fails with BLE_HS_EINVAL). _serverAddress = advertisedDevice->getAddress(); _haveServerAddress = true; + ESP_LOGI(LOG_TAG, "Found controller at address %s with name %s, ready to connect", + _serverAddress.toString().c_str(), advertisedDevice->getName().c_str()); + _scanner->stop(); _readyForConnection = true; } } diff --git a/lib/NanoPbComm/src/ble/BleClientTransport.h b/lib/NanoPbComm/src/ble/BleClientTransport.h index af0c2a2d8..b9710e4a5 100644 --- a/lib/NanoPbComm/src/ble/BleClientTransport.h +++ b/lib/NanoPbComm/src/ble/BleClientTransport.h @@ -48,11 +48,13 @@ class BleClientTransport : public Transport, public NimBLEScanCallbacks, public NimBLEScan *_scanner = nullptr; // Copy of the controller's address taken in onResult(). We must NOT keep the // NimBLEAdvertisedDevice* itself: with setMaxResults(0) NimBLE deletes that - // object the moment onResult() returns (NimBLEScan erase()), so dereferencing - // it later in connectToServer() -- which runs on the main loop task -- is a - // use-after-free that reads whatever string now occupies the freed heap slot - // back as a bogus peer address. NimBLEAddress is a value type, so copying it - // while the device is still alive is safe and survives the deletion. + // object out from under us -- both when onResult() returns (NimBLEScan erase()) + // and, crucially, the instant we call _scanner->stop() inside the callback + // (stop() -> clearResults() -> delete). So the value copy must be taken BEFORE + // stop(); dereferencing the device after that -- or later in connectToServer() + // on the loop task -- is a use-after-free that reads whatever now occupies the + // freed heap slot back as a bogus peer address. NimBLEAddress is a value type, + // so the copy survives the deletion. NimBLEAddress _serverAddress{}; bool _haveServerAddress = false; NimBLERemoteCharacteristic *_writeChar = nullptr; // to server (RX_CHAR_UUID) From 7c8c800279257de089383e3e45c7c0ec063db718 Mon Sep 17 00:00:00 2001 From: Stas Alekseev <100800+salekseev@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:56:04 -0400 Subject: [PATCH 4/5] fix: SD shot parsing + HTTP throughput/fragmentation + Arduino-baseline Kconfig restore (supersedes #743) (#740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sdkconfig): enable FATFS long filenames for SD reads IDF 5.5's Kconfig default is FATFS_LFN_NONE (8.3 names only). The dual-framework pioarduino build compiles IDF from source and inherits that default, where the old pure-Arduino build inherited arduino-esp32's prebuilt sdkconfig with LFN enabled. Under LFN_NONE the SD card cannot open or list 4-char extensions (.slog/.json), so /api/history/.slog GETs miss serveStatic and fall through to the SPA catch-all serving index.html — the web UI then fails with "Bad magic: ... got 0x6f64213c" (the bytes " * perf(sdkconfig): restore 240 MHz, -Os, and PSRAM malloc threshold The pioarduino dual-framework build compiles ESP-IDF from source and inherits IDF Kconfig defaults for anything not pinned in our .defaults, which differs from the prebuilt sdkconfig the old pure-Arduino build used. Three such drifts caused the maintainer's HTTP regressions vs the arduino "Nightly 77" baseline: - CPU ran at 160 MHz, not 240. board.json's f_cpu does not set the IDF Kconfig in a dual-framework build, so the unpinned default leaked through — a ~33% clock cut behind the uniform ~34% HTTP throughput loss across all endpoints. Pin CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240. (240 MHz is the ESP32-S3 rated max and the board's declared clock.) - Compiler built at -Og (IDF debug default) instead of -Os. Pin CONFIG_COMPILER_OPTIMIZATION_SIZE to match arduino-esp32. - CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL was 512 (arduino baseline 4096), shattering internal DRAM into tiny blocks — startup fragmentation 15% -> 64%, starving the contiguous internal allocs AsyncTCP needs. Restore 4096. Extend scripts/assert_sdkconfig.py to assert CPU 240 MHz and -Os so these can't silently regress to IDF defaults again. Also correct the header comment in sdkconfig.common.defaults: the merged sdkconfig. cache is gitignored and `-t fullclean` does NOT regenerate it — you must delete it to re-merge .defaults edits. Refs #681 Co-Authored-By: Claude Code * fix(sdkconfig): restore CONFIG_LCD_RGB_RESTART_IN_VSYNC dropped on the dual-framework move Arduino's precompiled framework-arduinoespressif32-libs ship CONFIG_LCD_RGB_RESTART_IN_VSYNC=y in their sdkconfig, so arduino-only builds were getting per-VBlank GDMA restart for free. The dual-framework sdkconfig defaults didn't carry this flag, and the IDF Kconfig default is n — so any single PSRAM-bus contention event (WiFi/BT alloc, TLS handshake, big WS payload) could desync LCD_CAM from the panel's HSYNC and permanently shift the RGB-panel image until reboot. Same regression shape as CONFIG_FATFS_LFN_STACK. Symptom HW-observed on a Pro running the integration branch after ~16h uptime: image suddenly shifted right ~30-50 px, dial bars clipped on the left, pressure reading clipped on the right. Power-cycle restored. No reboot, no errors in serial log, heap healthy. ESP-IDF docs explicitly recommend this flag to prevent the "permanently shifted image" failure mode; the alternative is the bounce-buffer mode, which would also need a per-driver code change in the panel configs. The Kconfig is panel-width-independent so this covers both the LilyGo T-RGB and Waveshare RGB-panel display boards. * fix(sdkconfig): restore the rest of the Arduino-baseline Kconfig defaults Continuing the cleanup started by the LCD_RGB_RESTART_IN_VSYNC commit. All of these are flags the precompiled framework-arduinoespressif32-libs sets implicitly; the dual-framework move silently inherits the IDF Kconfig defaults, which are more conservative / off for several user-visible behaviors. - CPU freq dropped 240 -> 160 MHz (~33% slower across CPU-bound paths; confirmed on the daily-driver boot log: `cpu freq: 160000000 Hz`). - Task WDT no longer panics; offending tasks soft-wedge instead of clean-rebooting. - Stack-overflow detection (compiler + FreeRTOS HW watchpoint) disabled, so overflows surface as random data corruption. - Heap canary bytes disabled (HEAP_POISONING_LIGHT), so small overflows / use-after-free silently propagate. - esp_timer + FreeRTOS sw-timer task stacks shrunk to the Kconfig minimum. - lwIP TCPIP task: stack reduced 4096 -> 3072, max sockets 16 -> 10, and affinity floats across cores instead of pinning to CPU0. - BT_NIMBLE_NVS_PERSIST off: every paired BLE peer re-pairs on each boot. Excluded intentionally: - APP_ROLLBACK_ENABLE — needs an app-side esp_ota_mark_app_valid_cancel_rollback() call, or every OTA boot rolls back. Separate change. - ARDUHAL_ESP_LOG — flipping changes log routing, entangled with our remote-syslog plugin. Needs design pass. - ESPTOOLPY_FLASHMODE_QIO — per-board concern via board.json / board_build.flash_mode, not appropriate to force in sdkconfig. Cross-reference for impact and IDF docs: see INTEGRATION-KCONFIG-AUDIT.html. * refactor(sdkconfig): dedup extra_scripts into idf_common The `post:scripts/assert_sdkconfig.py` guard wiring (added alongside the pre: scripts) was duplicated verbatim in [env:display] and [env:controller]. Hoist the whole extra_scripts block into the shared [idf_common] namespace and reference it via ${idf_common.extra_scripts} from both envs — matching the existing ${idf_common.*} pattern. Single source of truth: any future script addition now propagates to every dual-framework env (incl. the display-headless variants that inherit it via `extends`) instead of being special-cased per env. Resolved `pio project config` is byte-identical across display, display-headless, display-headless-8m, and controller — behavior-preserving. Also normalize the guard's success print to an f-string (was the lone %-format among f-strings in the file). Refs #681 Co-Authored-By: Claude Code --------- Co-authored-by: Claude Code Co-authored-by: Dave --- platformio.ini | 12 +++--- scripts/assert_sdkconfig.py | 67 +++++++++++++++++++++++++++++ sdkconfig.common.defaults | 82 ++++++++++++++++++++++++++++++++++-- sdkconfig.gaggimate.defaults | 25 ++++++++++- 4 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 scripts/assert_sdkconfig.py diff --git a/platformio.ini b/platformio.ini index 7d12c0150..b580933a6 100644 --- a/platformio.ini +++ b/platformio.ini @@ -76,6 +76,10 @@ lib_deps_default = ; gaggimate.pb.{c,h} from the .proto at build time; the component_remove list ; strips managed components we never use to cut first-build time + flash. [idf_common] +extra_scripts = + pre:scripts/auto_firmware_version.py + pre:scripts/pioarduino_env.py + post:scripts/assert_sdkconfig.py custom_nanopb_protos = + custom_nanopb_options = @@ -110,9 +114,7 @@ custom_nanopb_protos = ${idf_common.custom_nanopb_protos} custom_nanopb_options = ${idf_common.custom_nanopb_options} lib_ignore = ${idf_common.lib_ignore} custom_component_remove = ${idf_common.custom_component_remove} -extra_scripts = - pre:scripts/auto_firmware_version.py - pre:scripts/pioarduino_env.py +extra_scripts = ${idf_common.extra_scripts} lib_deps = ${display_common.lib_deps_default} lewisxhe/SensorLib @ 0.2.3 @@ -148,9 +150,7 @@ lib_ignore = ${idf_common.lib_ignore} custom_component_remove = ${idf_common.custom_component_remove} espressif/libsodium -extra_scripts = - pre:scripts/auto_firmware_version.py - pre:scripts/pioarduino_env.py +extra_scripts = ${idf_common.extra_scripts} lib_deps = GaggiMateController FS diff --git a/scripts/assert_sdkconfig.py b/scripts/assert_sdkconfig.py new file mode 100644 index 000000000..3c9f61a59 --- /dev/null +++ b/scripts/assert_sdkconfig.py @@ -0,0 +1,67 @@ +# ruff: noqa: F821 — `env` is injected by PlatformIO/SCons at runtime +# +# Post-build guard against silent sdkconfig drift. +# +# The dual-framework (pioarduino) build compiles ESP-IDF from source, so any +# config we don't pin in sdkconfig.*.defaults falls back to IDF's Kconfig +# default — which differs from the prebuilt sdkconfig the old pure-Arduino build +# used. That bit us once already: IDF 5.5 defaults FATFS to LFN_NONE (8.3 names +# only), which made SD-card files with 4-char extensions (".slog"/".json") +# unreadable and broke shot loading in the web UI ("Bad magic"). This guard +# fails the build if a required invariant regresses, so the class can't ship +# again unnoticed. Add new invariants to REQUIRED below as they're discovered. +import os + +Import("env") + +# Each entry: (human description, predicate(text) -> bool, remediation hint). +# `text` is the merged sdkconfig.h emitted into the build's config/ dir. +REQUIRED = [ + ( + "FATFS long filenames enabled (LFN_HEAP or LFN_STACK; NOT LFN_NONE)", + lambda t: ("#define CONFIG_FATFS_LFN_HEAP 1" in t + or "#define CONFIG_FATFS_LFN_STACK 1" in t) + and "#define CONFIG_FATFS_LFN_NONE 1" not in t, + "Set CONFIG_FATFS_LFN_HEAP=y + CONFIG_FATFS_MAX_LFN=255 in " + "sdkconfig.common.defaults, then `pio run -e -t fullclean`.", + ), + ( + "CPU at 240 MHz (IDF default 160 MHz leaks through if unpinned)", + lambda t: "#define CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ 240" in t, + "Set CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y in sdkconfig.common.defaults, " + "delete the cached sdkconfig., then rebuild.", + ), + ( + "Compiler optimization = SIZE (-Os), not the slower IDF default -Og", + lambda t: "#define CONFIG_COMPILER_OPTIMIZATION_SIZE 1" in t, + "Set CONFIG_COMPILER_OPTIMIZATION_SIZE=y in sdkconfig.common.defaults, " + "then `pio run -e -t fullclean`.", + ), +] + + +def assert_sdkconfig(*_args, **_kwargs): + sdkconfig_h = os.path.join(env.subst("$BUILD_DIR"), "config", "sdkconfig.h") + if not os.path.isfile(sdkconfig_h): + # No merged config (e.g. native env) — nothing to assert. + return + with open(sdkconfig_h, "r", encoding="utf-8", errors="replace") as f: + text = f.read() + + failures = [] + for desc, predicate, hint in REQUIRED: + if not predicate(text): + failures.append((desc, hint)) + + if failures: + print("\n*** sdkconfig guard FAILED — merged config violates required invariants:") + for desc, hint in failures: + print(f" - {desc}\n fix: {hint}") + print(f" (checked {sdkconfig_h})\n") + env.Exit(1) + else: + print(f"sdkconfig guard: OK ({len(REQUIRED)} invariant(s) satisfied)") + + +# Run after the firmware ELF is built, so the merged sdkconfig.h exists. +env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", assert_sdkconfig) diff --git a/sdkconfig.common.defaults b/sdkconfig.common.defaults index faebaed4f..c4fa2185e 100644 --- a/sdkconfig.common.defaults +++ b/sdkconfig.common.defaults @@ -2,14 +2,31 @@ # Applied as the first entry in SDKCONFIG_DEFAULTS; env-specific files follow # and override only what differs (MAX_CONNECTIONS, LWIP, nano-newlib). # -# Workflow: pioarduino caches a merged sdkconfig. at project root and -# reuses it on subsequent builds. Edits to any .defaults file don't propagate -# until that per-env file is regenerated. After editing, run: -# pio run -e -t fullclean +# Workflow: pioarduino caches a merged sdkconfig. at project root (it is +# gitignored). ESP-IDF applies these .defaults ONLY when generating a fresh +# sdkconfig.; if the cache already exists it is reused verbatim and your +# .defaults edits are silently ignored. `-t fullclean` does NOT regenerate it. +# After editing any .defaults file you MUST delete the cache, then rebuild: +# rm -f sdkconfig. && pio run -e # else the build ignores your change and you will chase ghosts. CONFIG_IDF_TARGET="esp32s3" +# --- CPU clock --- +# Run at full 240 MHz. board.json declares f_cpu=240 MHz, but that does NOT set +# the IDF Kconfig CPU freq in a dual-framework build — so the unpinned default +# (160 MHz) was leaking through, a ~33% clock cut behind the uniform HTTP +# slowdown (all endpoints ~-34% req/s vs the arduino build). 240 MHz is the +# ESP32-S3 rated max and what arduino Nightly 77 ran on this exact board. +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240 + +# --- Compiler optimization --- +# Optimize for size (-Os), matching arduino-esp32. IDF's default is -Og (debug), +# which is slower across the board. SIZE (not PERF/-O2) keeps flash/IRAM in +# budget while restoring baseline performance. +CONFIG_COMPILER_OPTIMIZATION_SIZE=y + # PSRAM config lives in sdkconfig.gaggimate.defaults — only the display-class # boards (LilyGo-T-RGB, seeed_xiao_esp32s3) have PSRAM. The Gaggimate-Controller # board has none; enabling SPIRAM here would abort boot on "PSRAM chip is not @@ -49,14 +66,56 @@ CONFIG_MBEDTLS_KEY_EXCHANGE_RSA_PSK=y # --- Task WDT --- CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n +# Arduino sets PANIC=y so WDT trips invoke the panic handler -> clean reboot +# + coredump. IDF default is n: WDT silently logs and the offending task can +# soft-wedge until manual power-cycle. Prefer auto-reboot for async_tcp WDT +# (Bug #3 hot path) and any other task that misses its deadline. +CONFIG_ESP_TASK_WDT_PANIC=y # --- FreeRTOS --- CONFIG_FREERTOS_HZ=1000 CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH=y CONFIG_RINGBUF_PLACE_FUNCTIONS_INTO_FLASH=y +# Hardware watchpoint on the bottom of each task stack — traps overflow at +# the moment it happens instead of letting it corrupt the next allocation. +# Arduino-prebuilt has this on; IDF default is n. +CONFIG_FREERTOS_WATCHPOINT_END_OF_STACK=y +# FreeRTOS software-timer task stack. Arduino: 3120. IDF default: 2048 (the +# Kconfig minimum). Restore Arduino's headroom — anything dispatched via +# xTimerCreate runs on this stack. +CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=3120 # --- Stacks --- CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +# esp_timer high-resolution timer task dispatches every esp_timer_create +# callback (Wi-Fi, BT, our brew/grind state machines). Arduino: 8192. IDF +# default: 3584 (right at the Kconfig minimum). Restore Arduino's value. +CONFIG_ESP_TIMER_TASK_STACK_SIZE=8192 + +# --- Stack-overflow + heap diagnostics --- +# GCC -fstack-protector. Arduino has it on; IDF default is none. Cheap +# overflow detection at function entry. +CONFIG_COMPILER_STACK_CHECK_MODE_NORM=y +CONFIG_COMPILER_STACK_CHECK=y +# Canary bytes around each heap allocation; small overflows / use-after-free +# trap at free() with a clean abort + trace instead of silently corrupting +# downstream allocations. Negligible cost. +CONFIG_HEAP_POISONING_LIGHT=y + +# --- lwIP TCP/IP task --- +# Arduino: pinned to CPU0 (0x0). IDF default: NO_AFFINITY (any core), which +# lets the task drift across cores and pay cross-core IPC on every callback, +# or contend with NimBLE for core 0 cycles. We pin AsyncTCP + NimBLE to core +# 0 via build_flags; make lwIP match. +CONFIG_LWIP_TCPIP_TASK_AFFINITY_CPU0=y +# Stack of the lwIP TCP/IP task. Arduino: 4096. IDF default: 3072. The IDF +# Kconfig itself notes that at LOG_DEFAULT_LEVEL >= INFO (we use INFO) the +# stack needs more headroom: "esp_netif API calls can end up a few calls +# deep and logging there can trigger a stack overflow." +CONFIG_LWIP_TCPIP_TASK_STACK_SIZE=4096 +# Hard cap on simultaneous sockets. Arduino: 16. IDF default: 10. AsyncWebServer +# (HTTP+WS) + HomeKit + MQTT + mDNS + OTA can hit 10 under load. +CONFIG_LWIP_MAX_SOCKETS=16 # --- Logging --- CONFIG_LOG_DEFAULT_LEVEL_INFO=y @@ -78,3 +137,18 @@ CONFIG_ARDUINO_SELECTIVE_OpenThread=n CONFIG_ARDUINO_SELECTIVE_SimpleBLE=n # Arduino's cores/esp32/main.cpp only defines app_main when =y; src/ has no app_main. CONFIG_AUTOSTART_ARDUINO=y + +# --- FATFS (SD card) long filenames --- +# IDF 5.5's Kconfig default is LFN_NONE (8.3 names only). Under that default, +# 4-char extensions like ".slog"/".json" cannot be opened or listed on the SD +# card, so /api/history/.slog GETs miss serveStatic and fall through to the +# SPA catch-all that returns index.html — the web UI then fails parsing with +# "Bad magic: ... got 0x6f64213c" (the bytes " 64%, +# starving contiguous internal allocs AsyncTCP needs to serve. Restore 4096. +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096 CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=16384 # Route NimBLE host + mbedTLS allocations to PSRAM (reclaims ~40 KB internal). @@ -54,3 +59,21 @@ CONFIG_LIBC_NEWLIB_NANO_FORMAT=n # with exceptions on, so the display class enables them here. Controller env # does not link the scales lib and stays -fno-exceptions. CONFIG_COMPILER_CXX_EXCEPTIONS=y + +# --- RGB LCD: restart GDMA at every VBlank --- +# Arduino's precompiled libs set this implicitly; the dual-framework build +# would otherwise inherit the IDF default (n) and lose the protection. +# Without it, a single PSRAM-bus contention event (WiFi/BT alloc, TLS +# handshake, big WS payload) can desync LCD_CAM's pixel pointer from the +# panel's HSYNC and PERMANENTLY shift the image until reboot — IDF docs +# call this out as "permanently shifted image". Resetting the GDMA channel +# at VSYNC_END (the start of VBlank) restarts the DMA stream from the +# framebuffer head every frame, so any desync clears on the next refresh. +# See components/esp_lcd/rgb/esp_lcd_panel_rgb.c — lcd_rgb_panel_try_restart_transmission(). +CONFIG_LCD_RGB_RESTART_IN_VSYNC=y + +# --- NimBLE bond persistence --- +# IDF Kconfig help: "Enable this flag to make bonding persistent across +# device reboots." Arduino-prebuilt has it on; IDF default is n. Without +# it, every paired BLE peer (scale, HomeKit phone) re-pairs on every boot. +CONFIG_BT_NIMBLE_NVS_PERSIST=y From 6b95001c93ee5758a301ae190e016f19595af613 Mon Sep 17 00:00:00 2001 From: Dave Emde <44786846+centercirclesolutions@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:15:46 -0700 Subject: [PATCH 5/5] fix(comms): re-send controller config after BLE reconnect (#786) The config burst (PID/pressure/pump coeffs) sent on a reconnect can be lost in the unstable BLE window right after the link comes up, and a spurious ACK then stops the reliable layer from retrying -- leaving the controller on firmware- default gains (Kff 0.000) so the boiler never heats after a controller reboot. Re-send the config burst for ~8s after each (re)connect at a 1s cadence; a copy lands once the link settles, and coalescing keeps it to one queued payload per type. HW-validated on this branch: controller-reset config delivery 30/30 pass (vs ~78% fail baseline), the re-send delivering config on the cycles that would otherwise fail. Claude-Session: https://claude.ai/code/session_01MUmX6ZL8t269p38f24GSqp Co-authored-by: Claude Opus 4.8 --- src/display/core/Controller.cpp | 13 +++++++++++++ src/display/core/Controller.h | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/src/display/core/Controller.cpp b/src/display/core/Controller.cpp index 8fea2ca7e..76232ece4 100644 --- a/src/display/core/Controller.cpp +++ b/src/display/core/Controller.cpp @@ -342,6 +342,8 @@ void Controller::onSystemInfo(const char *hardware, const char *version, uint32_ parseFloatCsv(settings.getPid(), pid, 4, 0.0f); comms.sendPidSettings(pid[0], pid[1], pid[2], pid[3]); setPumpModelCoeffs(); + configResendUntil = millis() + CONFIG_RESEND_WINDOW_MS; + lastConfigResend = millis(); } if (!loaded) { @@ -447,6 +449,17 @@ void Controller::loop() { unsigned long now = millis(); + // A config burst right after a reconnect can be lost in the unstable BLE window, + // and a spurious ACK then stops the reliable layer retrying. Re-send until it lands. + if (comms.isConnected() && now < configResendUntil && (now - lastConfigResend) >= CONFIG_RESEND_INTERVAL_MS) { + setPressureScale(); + float pid[4]; + parseFloatCsv(settings.getPid(), pid, 4, 0.0f); + comms.sendPidSettings(pid[0], pid[1], pid[2], pid[3]); + setPumpModelCoeffs(); + lastConfigResend = now; + } + // If BLE scanning has been running for a while without finding the controller, // notify the UI so it can update the startup label accordingly. if (!waitingForController && initialized && !comms.isConnected() && diff --git a/src/display/core/Controller.h b/src/display/core/Controller.h index 669169fd0..3746a8c0a 100644 --- a/src/display/core/Controller.h +++ b/src/display/core/Controller.h @@ -186,6 +186,11 @@ class Controller { bool screenReady = false; bool waitingForController = false; unsigned long connectStartTime = 0; + // Re-send the config burst for a few seconds after a (re)connect (see loop()). + unsigned long configResendUntil = 0; + unsigned long lastConfigResend = 0; + static const unsigned long CONFIG_RESEND_WINDOW_MS = 8000; + static const unsigned long CONFIG_RESEND_INTERVAL_MS = 1000; bool volumetricOverride = false; bool processCompleted = false; bool steamReady = false;