From 296fa2e5301828d80e8f2bd1df646f262cba1890 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sat, 25 Jul 2026 13:54:15 +0200 Subject: [PATCH 01/41] Rework Original into an HTTPS webhook tally counter Turns the Home Buttons Original (A1) into a two-channel counter that POSTs each press to an HTTPS webhook instead of publishing over MQTT. Behaviour: a press blinks the LED, increments a locally-held counter, redraws the display and posts the absolute count. The device is authoritative, so a dropped request repairs itself on the next press and no retry queue is needed; seq is carried purely so the receiver can dedupe a retry and avoid notifying twice. After the first press the Wi-Fi association and TLS session stay open for a short window so a burst of presses shares one handshake. Removed: MQTT client and Home Assistant discovery, the temperature and humidity sensor, the runtime MDI icon downloader, the factory self-test, and the mini/pro/industrial variants. The pro targets had not compiled for some time - App has no bsl_input_ member for that variant, and touch.cpp emits enum values that do not exist. Icons are now generated by tools/make_icons.py and flashed as a SPIFFS image rather than downloaded at runtime. TLS now uses the ESP-IDF certificate bundle. The pinned root was the DST-cross-signed ISRG Root X1, which expired 2024-09-30 while the comment claimed 2030, and a custom icon server was passed a null CA - HTTPS with no verification at all. The backend sits behind Cloudflare, which rotates edge issuers, so a bundle is the only durable option. SPIFFS shrinks from 0x160000 to 0xA0000 and the app slots grow to 0x1A0000. The baseline build was at 95.8% of its app partition with ~55 kB spare; this one sits at 69.3% including the 14.7 kB cert bundle. Also fixed, all present upstream at v2.6.1: - wake-pin decode used log(mask)/log(2) on floats, which returns a nonsense pin when two buttons are held and is undefined for a zero mask - the network connect timeout compared absolute millis() to the timeout - _nvs_2_efuse passed eFuse bit counts as buffer lengths into char[9] - the text-shrink loops could spin forever below one glyph width - _efuse_burned declared uint8_t buf[8] and read 8 bits - task names were passed as a printf format string - HardwareDefinition had no default member initialisers - the IP string buffer was read without being zero-initialised - load_persisted opened NVS read-write on every boot - the setup AP password was "password123" on every unit shipped CI builds the firmware for the first time; upstream only deployed docs, which is how the pro targets drifted into not compiling unnoticed. Co-Authored-By: Claude --- .github/workflows/ci.yml | 16 - .github/workflows/firmware.yml | 83 ++ .gitignore | 3 + Firmware/HomeButtonsArduino/.gitignore | 4 + Firmware/HomeButtonsArduino/partitions.csv | 13 +- Firmware/HomeButtonsArduino/platformio.ini | 82 +- Firmware/HomeButtonsArduino/pre_script.py | 5 +- .../HomeButtonsArduino/sdkconfig.defaults | 11 +- .../sdkconfig.industrial_debug.defaults | 11 - .../sdkconfig.industrial_release.defaults | 7 - .../sdkconfig.mini_debug.defaults | 11 - .../sdkconfig.mini_release.defaults | 7 - .../sdkconfig.pro_debug.defaults | 11 - .../sdkconfig.pro_release.defaults | 7 - .../HomeButtonsArduino/src/CMakeLists.txt | 43 +- Firmware/HomeButtonsArduino/src/app.cpp | 1233 ++++------------- Firmware/HomeButtonsArduino/src/app.h | 113 +- Firmware/HomeButtonsArduino/src/config.h | 142 +- .../HomeButtonsArduino/src/display/bitmaps.h | 842 ----------- .../src/display/display.cpp | 730 +--------- .../HomeButtonsArduino/src/display/display.h | 39 +- Firmware/HomeButtonsArduino/src/factory.cpp | 275 ---- Firmware/HomeButtonsArduino/src/factory.h | 48 - Firmware/HomeButtonsArduino/src/hardware.cpp | 432 +----- Firmware/HomeButtonsArduino/src/hardware.h | 153 +- Firmware/HomeButtonsArduino/src/hw_tests.h | 17 - .../HomeButtonsArduino/src/mdi/certificates.h | 47 - .../HomeButtonsArduino/src/mdi/download.cpp | 102 -- .../HomeButtonsArduino/src/mdi/download.h | 12 - .../HomeButtonsArduino/src/mdi/mdi_helper.cpp | 211 --- .../HomeButtonsArduino/src/mdi/mdi_helper.h | 40 - .../HomeButtonsArduino/src/mqtt_helper.cpp | 404 ------ Firmware/HomeButtonsArduino/src/mqtt_helper.h | 33 - Firmware/HomeButtonsArduino/src/network.cpp | 191 +-- Firmware/HomeButtonsArduino/src/network.h | 62 +- Firmware/HomeButtonsArduino/src/setup.cpp | 159 +-- Firmware/HomeButtonsArduino/src/state.cpp | 71 +- Firmware/HomeButtonsArduino/src/state.h | 107 +- Firmware/HomeButtonsArduino/src/topics.cpp | 219 --- Firmware/HomeButtonsArduino/src/topics.h | 61 - .../HomeButtonsArduino/src/touch/touch.cpp | 276 ---- Firmware/HomeButtonsArduino/src/touch/touch.h | 59 - Firmware/HomeButtonsArduino/src/types.h | 15 +- Firmware/HomeButtonsArduino/src/webhook.cpp | 101 ++ Firmware/HomeButtonsArduino/src/webhook.h | 58 + .../HomeButtonsArduino/tools/make_icons.py | 85 ++ README.md | 178 ++- 47 files changed, 1221 insertions(+), 5608 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/firmware.yml delete mode 100644 Firmware/HomeButtonsArduino/sdkconfig.industrial_debug.defaults delete mode 100644 Firmware/HomeButtonsArduino/sdkconfig.industrial_release.defaults delete mode 100644 Firmware/HomeButtonsArduino/sdkconfig.mini_debug.defaults delete mode 100644 Firmware/HomeButtonsArduino/sdkconfig.mini_release.defaults delete mode 100644 Firmware/HomeButtonsArduino/sdkconfig.pro_debug.defaults delete mode 100644 Firmware/HomeButtonsArduino/sdkconfig.pro_release.defaults delete mode 100644 Firmware/HomeButtonsArduino/src/factory.cpp delete mode 100644 Firmware/HomeButtonsArduino/src/factory.h delete mode 100644 Firmware/HomeButtonsArduino/src/hw_tests.h delete mode 100644 Firmware/HomeButtonsArduino/src/mdi/certificates.h delete mode 100644 Firmware/HomeButtonsArduino/src/mdi/download.cpp delete mode 100644 Firmware/HomeButtonsArduino/src/mdi/download.h delete mode 100644 Firmware/HomeButtonsArduino/src/mdi/mdi_helper.cpp delete mode 100644 Firmware/HomeButtonsArduino/src/mdi/mdi_helper.h delete mode 100644 Firmware/HomeButtonsArduino/src/mqtt_helper.cpp delete mode 100644 Firmware/HomeButtonsArduino/src/mqtt_helper.h delete mode 100644 Firmware/HomeButtonsArduino/src/topics.cpp delete mode 100644 Firmware/HomeButtonsArduino/src/topics.h delete mode 100644 Firmware/HomeButtonsArduino/src/touch/touch.cpp delete mode 100644 Firmware/HomeButtonsArduino/src/touch/touch.h create mode 100644 Firmware/HomeButtonsArduino/src/webhook.cpp create mode 100644 Firmware/HomeButtonsArduino/src/webhook.h create mode 100644 Firmware/HomeButtonsArduino/tools/make_icons.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 5d7fc8d..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: ci -on: - push: - branches: - - master - - main -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: 3.x - - run: pip install mkdocs-material - - run: mkdocs gh-deploy --force diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml new file mode 100644 index 0000000..40b2a1a --- /dev/null +++ b/.github/workflows/firmware.yml @@ -0,0 +1,83 @@ +name: firmware + +# Upstream had no firmware build in CI at all — only a docs deploy — which is +# how its `pro_*` targets drifted into a state that could not compile. This is +# the gate that stops that happening again. + +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + # ADR-0007 Form 2. This repo is public, so it cannot reach the org ARC + # runners (the Default runner group sets allows_public_repositories=false) + # and cannot read CI_RUNNER_MODE, which is a private-visibility org var. + # The expression therefore resolves to ubuntu-latest today — free and + # unmetered for public repos — but picks up ARC automatically if this + # repo is ever made private. Keep it byte-identical to the ADR form so + # the org drift gate matches it. + runs-on: ${{ vars.CI_RUNNER_MODE == 'arc' && (vars.SELF_HOSTED_LABEL || 'arc-se-org-shared') || 'ubuntu-latest' }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # The toolchain is ~1 GB and the ESP-IDF/Arduino build pulls it fresh + # on every cold runner, so this cache is most of the wall-clock time. + - name: Cache PlatformIO + uses: actions/cache@v4 + with: + path: | + ~/.platformio + ~/.cache/pip + key: pio-${{ runner.os }}-${{ hashFiles('Firmware/HomeButtonsArduino/platformio.ini') }} + restore-keys: pio-${{ runner.os }}- + + - name: Install PlatformIO + run: pip install --upgrade platformio + + - name: Build firmware + working-directory: Firmware/HomeButtonsArduino + run: pio run -e original_release + + - name: Build SPIFFS image + working-directory: Firmware/HomeButtonsArduino + run: | + python tools/make_icons.py + pio run -e original_release -t buildfs + + # The app partition was 95.8% full before this fork stripped MQTT and + # the icon downloader. Fail loudly rather than discovering at flash + # time that the image no longer fits. + - name: Check flash headroom + working-directory: Firmware/HomeButtonsArduino + run: | + set -euo pipefail + SIZE=$(stat -c%s .pio/build/original_release/firmware.bin) + LIMIT=$((0x1A0000)) + PCT=$((SIZE * 100 / LIMIT)) + echo "firmware.bin: ${SIZE} bytes of ${LIMIT} (${PCT}%)" + echo "firmware.bin: ${SIZE} / ${LIMIT} bytes (${PCT}%)" >> "$GITHUB_STEP_SUMMARY" + if [ "$PCT" -ge 90 ]; then + echo "::error::firmware is ${PCT}% of the app partition" + exit 1 + fi + + - uses: actions/upload-artifact@v4 + with: + name: firmware-original + path: | + Firmware/HomeButtonsArduino/.pio/build/original_release/firmware.bin + Firmware/HomeButtonsArduino/.pio/build/original_release/bootloader.bin + Firmware/HomeButtonsArduino/.pio/build/original_release/partitions.bin + Firmware/HomeButtonsArduino/.pio/build/original_release/spiffs.bin + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 3294505..b7b3d37 100755 --- a/.gitignore +++ b/.gitignore @@ -283,3 +283,6 @@ $RECYCLE.BIN/ # End of https://www.toptal.com/developers/gitignore/api/c++,macos,windows,visualstudiocode,python ### Project specific ### + +# Generated by Firmware/HomeButtonsArduino/tools/make_icons.py +Firmware/HomeButtonsArduino/data/ diff --git a/Firmware/HomeButtonsArduino/.gitignore b/Firmware/HomeButtonsArduino/.gitignore index dad85bc..8a36508 100644 --- a/Firmware/HomeButtonsArduino/.gitignore +++ b/Firmware/HomeButtonsArduino/.gitignore @@ -10,3 +10,7 @@ sdkconfig.mini_debug sdkconfig.mini_release sdkconfig.pro_release sdkconfig.pro_debug + +# Generated per-build and deleted by pre_script.py +sdkconfig.original_release +sdkconfig.original_debug diff --git a/Firmware/HomeButtonsArduino/partitions.csv b/Firmware/HomeButtonsArduino/partitions.csv index 6f68ce1..0d53d4d 100644 --- a/Firmware/HomeButtonsArduino/partitions.csv +++ b/Firmware/HomeButtonsArduino/partitions.csv @@ -1,7 +1,12 @@ # Name, Type, SubType, Offset, Size, Flags +# Upstream gave each app slot 0x140000 and SPIFFS 0x160000. The baseline +# build already used 95.8% of an app slot, and the runtime icon downloader +# is gone, so SPIFFS only has to hold a handful of pre-loaded BMPs. Space +# moved from SPIFFS to the app slots to make room for the mbedTLS +# certificate bundle. nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, -app0, app, ota_0, 0x10000, 0x140000, -app1, app, ota_1, 0x150000,0x140000, -spiffs, data, spiffs, 0x290000,0x160000, -coredump, data, coredump,0x3F0000,0x10000, \ No newline at end of file +app0, app, ota_0, 0x10000, 0x1A0000, +app1, app, ota_1, 0x1B0000,0x1A0000, +spiffs, data, spiffs, 0x350000,0xA0000, +coredump, data, coredump,0x3F0000,0x10000, diff --git a/Firmware/HomeButtonsArduino/platformio.ini b/Firmware/HomeButtonsArduino/platformio.ini index eb60309..807ac61 100644 --- a/Firmware/HomeButtonsArduino/platformio.ini +++ b/Firmware/HomeButtonsArduino/platformio.ini @@ -7,6 +7,9 @@ ; ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html +; +; This fork targets Home Buttons Original (model A1) only. The mini, pro and +; industrial environments from upstream have been removed. [env] build_unflags = -std=gnu++11 @@ -17,22 +20,19 @@ upload_protocol = esptool board_build.partitions = partitions.csv monitor_speed = 115200 monitor_filters = esp32_exception_decoder, colorize -extra_scripts = +extra_scripts = pre:pre_script.py post:post_script.py -lib_deps = - knolleary/PubSubClient@2.8 +lib_deps = bblanchon/ArduinoJson@6.21.5 https://github.com/tzapu/WiFiManager.git#v2.0.17 - adafruit/Adafruit SHTC3 Library@1.0.1 zinggjm/GxEPD2@1.5.8 ricmoo/QRCode@0.0.1 olikraus/U8g2_for_Adafruit_GFX@1.8.0 https://github.com/Neargye/semver.git#v0.3.0 - https://github.com/nplan/FT6X36.git [env:original_release] -build_flags = +build_flags = -std=gnu++17 -Wno-unknown-pragmas -DARDUINO_ESP32S2_DEV @@ -40,10 +40,10 @@ build_flags = -DLOGGER_DEFAULT_LOG_LEVEL=ESP_LOG_INFO -DHOME_BUTTONS_ORIGINAL board_build.cmake_extra_args = -DHOME_BUTTONS_ORIGINAL=ON - + [env:original_debug] debug_tool = cmsis-dap -build_flags = +build_flags = -std=gnu++17 -Wno-unknown-pragmas -DARDUINO_ESP32S2_DEV @@ -52,69 +52,3 @@ build_flags = -DHOME_BUTTONS_ORIGINAL -DHOME_BUTTONS_DEBUG board_build.cmake_extra_args = -DHOME_BUTTONS_ORIGINAL=ON - -[env:mini_release] -build_flags = - -std=gnu++17 - -Wno-unknown-pragmas - -DARDUINO_ESP32S2_DEV - -DCORE_DEBUG_LEVEL=1 - -DLOGGER_DEFAULT_LOG_LEVEL=ESP_LOG_INFO - -DHOME_BUTTONS_MINI -board_build.cmake_extra_args = -DHOME_BUTTONS_MINI=ON - -[env:mini_debug] -debug_tool = cmsis-dap -build_flags = - -std=gnu++17 - -Wno-unknown-pragmas - -DARDUINO_ESP32S2_DEV - -DCORE_DEBUG_LEVEL=5 - -DLOGGER_DEFAULT_LOG_LEVEL=ESP_LOG_DEBUG - -DHOME_BUTTONS_MINI - -DHOME_BUTTONS_DEBUG -board_build.cmake_extra_args = -DHOME_BUTTONS_MINI=ON - -[env:pro_release] -build_flags = - -std=gnu++17 - -Wno-unknown-pragmas - -DARDUINO_ESP32S2_DEV - -DCORE_DEBUG_LEVEL=1 - -DLOGGER_DEFAULT_LOG_LEVEL=ESP_LOG_INFO - -DHOME_BUTTONS_PRO -board_build.cmake_extra_args = -DHOME_BUTTONS_PRO=ON - -[env:pro_debug] -debug_tool = cmsis-dap -build_flags = - -std=gnu++17 - -Wno-unknown-pragmas - -DARDUINO_ESP32S2_DEV - -DCORE_DEBUG_LEVEL=5 - -DLOGGER_DEFAULT_LOG_LEVEL=ESP_LOG_DEBUG - -DHOME_BUTTONS_PRO - -DHOME_BUTTONS_DEBUG -board_build.cmake_extra_args = -DHOME_BUTTONS_PRO=ON - -[env:industrial_release] -build_flags = - -std=gnu++17 - -Wno-unknown-pragmas - -DARDUINO_ESP32S2_DEV - -DCORE_DEBUG_LEVEL=1 - -DLOGGER_DEFAULT_LOG_LEVEL=ESP_LOG_INFO - -DHOME_BUTTONS_INDUSTRIAL -board_build.cmake_extra_args = -DHOME_BUTTONS_INDUSTRIAL=ON - -[env:industrial_debug] -debug_tool = cmsis-dap -build_flags = - -std=gnu++17 - -Wno-unknown-pragmas - -DARDUINO_ESP32S2_DEV - -DCORE_DEBUG_LEVEL=5 - -DLOGGER_DEFAULT_LOG_LEVEL=ESP_LOG_DEBUG - -DHOME_BUTTONS_INDUSTRIAL - -DHOME_BUTTONS_DEBUG -board_build.cmake_extra_args = -DHOME_BUTTONS_INDUSTRIAL=ON \ No newline at end of file diff --git a/Firmware/HomeButtonsArduino/pre_script.py b/Firmware/HomeButtonsArduino/pre_script.py index 57015b5..833a3ca 100644 --- a/Firmware/HomeButtonsArduino/pre_script.py +++ b/Firmware/HomeButtonsArduino/pre_script.py @@ -2,10 +2,7 @@ import os -sdkconfig_files = ["sdkconfig.original_release", "sdkconfig.original_debug", - "sdkconfig.mini_release", "sdkconfig.mini_debug", - "sdkconfig.pro_release", "sdkconfig.pro_debug", - "sdkconfig.industrial_release", "sdkconfig.industrial_debug"] +sdkconfig_files = ["sdkconfig.original_release", "sdkconfig.original_debug"] def delete_sdkconfig_files(): print("Deleting sdkconfig files...") diff --git a/Firmware/HomeButtonsArduino/sdkconfig.defaults b/Firmware/HomeButtonsArduino/sdkconfig.defaults index c502294..b6c0e07 100644 --- a/Firmware/HomeButtonsArduino/sdkconfig.defaults +++ b/Firmware/HomeButtonsArduino/sdkconfig.defaults @@ -23,6 +23,15 @@ CONFIG_SPIFFS_OBJ_NAME_LEN=56 CONFIG_MBEDTLS_DYNAMIC_BUFFER=y CONFIG_MBEDTLS_DYNAMIC_FREE_PEER_CERT=y CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA=y -CONFIG_MBEDTLS_DYNAMIC_FREE_CA_CERT=y +# Deliberately NOT enabling MBEDTLS_DYNAMIC_FREE_CA_CERT: the webhook client +# keeps the TLS session open across a burst of presses, and freeing the CA +# after the first handshake would break the reconnect path. + +# Root CA bundle for the webhook endpoint. The backend sits behind +# Cloudflare, which rotates edge issuers, so a single pinned root is not +# viable. CMN carries the common roots (ISRG, Google Trust Services, +# DigiCert) at roughly a third of the flash cost of the full bundle. +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN=y CONFIG_EFUSE_CUSTOM_TABLE=y diff --git a/Firmware/HomeButtonsArduino/sdkconfig.industrial_debug.defaults b/Firmware/HomeButtonsArduino/sdkconfig.industrial_debug.defaults deleted file mode 100644 index a00bf59..0000000 --- a/Firmware/HomeButtonsArduino/sdkconfig.industrial_debug.defaults +++ /dev/null @@ -1,11 +0,0 @@ -CONFIG_COMPILER_OPTIMIZATION_PERF=y - -CONFIG_ESPTOOLPY_FLASHMODE_QIO=y - -CONFIG_BOOTLOADER_LOG_LEVEL_DEBUG=y -CONFIG_LOG_DEFAULT_LEVEL_INFO=y -CONFIG_ARDUHAL_LOG_DEFAULT_LEVEL_DEBUG=y - -CONFIG_FREERTOS_USE_TRACE_FACILITY=y -CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y -CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y diff --git a/Firmware/HomeButtonsArduino/sdkconfig.industrial_release.defaults b/Firmware/HomeButtonsArduino/sdkconfig.industrial_release.defaults deleted file mode 100644 index a21a819..0000000 --- a/Firmware/HomeButtonsArduino/sdkconfig.industrial_release.defaults +++ /dev/null @@ -1,7 +0,0 @@ -CONFIG_COMPILER_OPTIMIZATION_PERF=y - -CONFIG_ESPTOOLPY_FLASHMODE_QIO=y - -CONFIG_BOOTLOADER_LOG_LEVEL_ERROR=y -CONFIG_LOG_DEFAULT_LEVEL_ERROR=y -CONFIG_ARDUHAL_LOG_DEFAULT_LEVEL_ERROR=y diff --git a/Firmware/HomeButtonsArduino/sdkconfig.mini_debug.defaults b/Firmware/HomeButtonsArduino/sdkconfig.mini_debug.defaults deleted file mode 100644 index a00bf59..0000000 --- a/Firmware/HomeButtonsArduino/sdkconfig.mini_debug.defaults +++ /dev/null @@ -1,11 +0,0 @@ -CONFIG_COMPILER_OPTIMIZATION_PERF=y - -CONFIG_ESPTOOLPY_FLASHMODE_QIO=y - -CONFIG_BOOTLOADER_LOG_LEVEL_DEBUG=y -CONFIG_LOG_DEFAULT_LEVEL_INFO=y -CONFIG_ARDUHAL_LOG_DEFAULT_LEVEL_DEBUG=y - -CONFIG_FREERTOS_USE_TRACE_FACILITY=y -CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y -CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y diff --git a/Firmware/HomeButtonsArduino/sdkconfig.mini_release.defaults b/Firmware/HomeButtonsArduino/sdkconfig.mini_release.defaults deleted file mode 100644 index a21a819..0000000 --- a/Firmware/HomeButtonsArduino/sdkconfig.mini_release.defaults +++ /dev/null @@ -1,7 +0,0 @@ -CONFIG_COMPILER_OPTIMIZATION_PERF=y - -CONFIG_ESPTOOLPY_FLASHMODE_QIO=y - -CONFIG_BOOTLOADER_LOG_LEVEL_ERROR=y -CONFIG_LOG_DEFAULT_LEVEL_ERROR=y -CONFIG_ARDUHAL_LOG_DEFAULT_LEVEL_ERROR=y diff --git a/Firmware/HomeButtonsArduino/sdkconfig.pro_debug.defaults b/Firmware/HomeButtonsArduino/sdkconfig.pro_debug.defaults deleted file mode 100644 index a00bf59..0000000 --- a/Firmware/HomeButtonsArduino/sdkconfig.pro_debug.defaults +++ /dev/null @@ -1,11 +0,0 @@ -CONFIG_COMPILER_OPTIMIZATION_PERF=y - -CONFIG_ESPTOOLPY_FLASHMODE_QIO=y - -CONFIG_BOOTLOADER_LOG_LEVEL_DEBUG=y -CONFIG_LOG_DEFAULT_LEVEL_INFO=y -CONFIG_ARDUHAL_LOG_DEFAULT_LEVEL_DEBUG=y - -CONFIG_FREERTOS_USE_TRACE_FACILITY=y -CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y -CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y diff --git a/Firmware/HomeButtonsArduino/sdkconfig.pro_release.defaults b/Firmware/HomeButtonsArduino/sdkconfig.pro_release.defaults deleted file mode 100644 index a21a819..0000000 --- a/Firmware/HomeButtonsArduino/sdkconfig.pro_release.defaults +++ /dev/null @@ -1,7 +0,0 @@ -CONFIG_COMPILER_OPTIMIZATION_PERF=y - -CONFIG_ESPTOOLPY_FLASHMODE_QIO=y - -CONFIG_BOOTLOADER_LOG_LEVEL_ERROR=y -CONFIG_LOG_DEFAULT_LEVEL_ERROR=y -CONFIG_ARDUHAL_LOG_DEFAULT_LEVEL_ERROR=y diff --git a/Firmware/HomeButtonsArduino/src/CMakeLists.txt b/Firmware/HomeButtonsArduino/src/CMakeLists.txt index d51256a..d4c211a 100644 --- a/Firmware/HomeButtonsArduino/src/CMakeLists.txt +++ b/Firmware/HomeButtonsArduino/src/CMakeLists.txt @@ -1,45 +1,14 @@ -# This file was automatically generated for projects -# without default 'CMakeLists.txt' file. - -# FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/src/*.*) - -# idf_component_register(SRCS ${app_sources}) +# This fork targets Home Buttons Original (model A1) only, so every source +# file under src/ is compiled. Upstream removed per-variant directories here; +# the mini/pro/industrial variants and src/touch no longer exist. +# NB: this file is also evaluated during component_get_requirements, a phase +# where HOME_BUTTONS_ORIGINAL is not yet defined, so it must not assert on it. +# config.h #errors at compile time if the build flag is missing. message(STATUS "HOME_BUTTONS_ORIGINAL: ${HOME_BUTTONS_ORIGINAL}") -message(STATUS "HOME_BUTTONS_MINI: ${HOME_BUTTONS_MINI}") -message(STATUS "HOME_BUTTONS_PRO: ${HOME_BUTTONS_PRO}") -message(STATUS "HOME_BUTTONS_INDUSTRIAL: ${HOME_BUTTONS_INDUSTRIAL}") -# Get list of all .c and .cpp files in the src directory FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/src/*.c ${CMAKE_SOURCE_DIR}/src/*.cpp) -if(DEFINED HOME_BUTTONS_ORIGINAL OR DEFINED HOME_BUTTONS_MINI) - file(GLOB_RECURSE files_to_remove "${CMAKE_SOURCE_DIR}/src/touch/*") - - foreach(file ${files_to_remove}) - list(REMOVE_ITEM app_sources "${file}") - endforeach() -endif() - -if(DEFINED HOME_BUTTONS_PRO) - file(GLOB_RECURSE files_to_remove "${CMAKE_SOURCE_DIR}/src/button_ui/*") - - foreach(file ${files_to_remove}) - list(REMOVE_ITEM app_sources "${file}") - endforeach() -endif() - -if(DEFINED HOME_BUTTONS_INDUSTRIAL) - file(GLOB_RECURSE files_to_remove - "${CMAKE_SOURCE_DIR}/src/display/*" - "${CMAKE_SOURCE_DIR}/src/mdi/*" - "${CMAKE_SOURCE_DIR}/src/touch/*") - - foreach(file ${files_to_remove}) - list(REMOVE_ITEM app_sources "${file}") - endforeach() -endif() - foreach(src_file ${app_sources}) message(STATUS "Source file: ${src_file}") endforeach() diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index 1dac812..0265340 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -2,12 +2,9 @@ #include #include -#include #include "esp_ota_ops.h" -#include #include "config.h" -#include "factory.h" #include "hardware.h" extern "C" bool verifyRollbackLater() { return true; }; @@ -15,7 +12,6 @@ extern "C" bool verifyRollbackLater() { return true; }; App::App() : AppStateMachine("AppSM", *this), Logger("APP"), -#if defined(HOME_BUTTONS_ORIGINAL) b1_("B1", 1, false, true, hw_), b2_("B2", 2, false, true, hw_), b3_("B3", 3, false, true, hw_), @@ -25,34 +21,12 @@ App::App() bsl_input_("BSLInput", std::array, NUM_BUTTONS>{ b1_, b2_, b3_, b4_, b5_, b6_}), -#elif defined(HOME_BUTTONS_MINI) - b1_("B1", 1, false, true, hw_), - b2_("B2", 2, false, true, hw_), - b3_("B3", 3, false, true, hw_), - b4_("B4", 4, false, true, hw_), - bsl_input_("BSLInput", - std::array, NUM_BUTTONS>{ - b1_, b2_, b3_, b4_}), -#elif defined(HOME_BUTTONS_PRO) - touch_handler_(hw_), -#elif defined(HOME_BUTTONS_INDUSTRIAL) - b1_("B1", 1, false, true, hw_), - b2_("B2", 2, false, true, hw_), - b3_("B3", 3, false, true, hw_), - b4_("B4", 4, false, true, hw_), - sw_("SW", 5, true, false, hw_), - bsl_input_("BSLInput", - std::array, NUM_BUTTONS>{ - b1_, b2_, b3_, b4_, sw_}), -#endif -#if defined(HAS_DISPLAY) - mdi_(device_state_), - display_(device_state_, mdi_), -#endif - topics_(device_state_), - network_(device_state_, topics_), - mqtt_(device_state_, bsl_input_, network_, topics_), + display_(device_state_), + network_(device_state_), + webhook_(device_state_), setup_(*this) { + press_queue_ = xQueueCreate(PRESS_QUEUE_SIZE, sizeof(PressQueueElement)); + if (press_queue_ == nullptr) error("failed to create press queue"); } void App::setup() { @@ -71,18 +45,11 @@ void App::setup() { void App::_sleep_or_restart() { delay(3000); -#if defined(HAS_SLEEP_MODE) error("Going to sleep..."); _start_esp_sleep(); -#else - error("Restarting..."); - ESP.restart(); -#endif } -#if defined(HAS_SLEEP_MODE) void App::_start_esp_sleep() { -#if defined(HOME_BUTTONS_ORIGINAL) || defined(HOME_BUTTONS_MINI) esp_sleep_enable_ext1_wakeup(hw_.WAKE_BITMASK, ESP_EXT1_WAKEUP_ANY_HIGH); if (device_state_.persisted().wifi_done && device_state_.persisted().setup_done && @@ -90,57 +57,49 @@ void App::_start_esp_sleep() { !device_state_.persisted().check_connection) { if (device_state_.flags().schedule_wakeup_time > 0) { esp_sleep_enable_timer_wakeup(device_state_.flags().schedule_wakeup_time * - 1000000UL); + 1000000ULL); } else { - esp_sleep_enable_timer_wakeup(device_state_.sensor_interval() * - 60000000UL); + esp_sleep_enable_timer_wakeup( + static_cast(device_state_.heartbeat_interval()) * + 60000000ULL); } } -#elif defined(HOME_BUTTONS_PRO) - esp_sleep_enable_ext1_wakeup(hw_.WAKE_BITMASK, ESP_EXT1_WAKEUP_ANY_HIGH); -#endif info("deep sleep... z z z"); esp_deep_sleep_start(); } void App::_go_to_sleep() { device_state_.save_all(); -#if defined(HOME_BUTTONS_ORIGINAL) || defined(HOME_BUTTONS_MINI) hw_.set_all_leds(0); -#elif defined(HOME_BUTTONS_PRO) - hw_.set_frontlight(0); -#endif _start_esp_sleep(); } -#endif std::pair App::_determine_boot_cause() { BootCause boot_cause = BootCause::RESET; - int16_t wakeup_pin = 0; uint8_t wakeup_btn_id = 0; -#if defined(HOME_BUTTONS_ORIGINAL) || defined(HOME_BUTTONS_MINI) switch (esp_sleep_get_wakeup_cause()) { case ESP_SLEEP_WAKEUP_EXT1: { - uint64_t GPIO_reason = esp_sleep_get_ext1_wakeup_status(); - wakeup_pin = (log(GPIO_reason)) / log(2); - debug("wakeup cause: PIN %d", wakeup_pin); + uint64_t gpio_reason = esp_sleep_get_ext1_wakeup_status(); + if (gpio_reason == 0) { + debug("EXT1 wakeup with empty status"); + break; + } + // Lowest set bit is the pin. Upstream used log(x)/log(2) on floats, + // which yields a nonsense pin when two buttons are held at wake and + // is undefined for a zero mask. + int wakeup_pin = __builtin_ctzll(gpio_reason); + debug("wakeup cause: PIN %d (mask 0x%llx)", wakeup_pin, gpio_reason); wakeup_btn_id = bsl_input_.IdFromPin(wakeup_pin); if (wakeup_btn_id > 0) { boot_cause = BootCause::BUTTON; - } else { - boot_cause = BootCause::RESET; } } break; case ESP_SLEEP_WAKEUP_TIMER: boot_cause = BootCause::TIMER; break; default: - boot_cause = BootCause::RESET; break; } -#elif defined(HOME_BUTTONS_PRO) || defined(HOME_BUTTONS_INDUSTRIAL) - boot_cause = BootCause::RESET; -#endif return std::make_pair(boot_cause, wakeup_btn_id); } @@ -149,78 +108,35 @@ void App::_log_task_stats() { TaskStatus_t statusArray[maxTasks]; uint32_t totalRunTime; - // Fetch the status of tasks UBaseType_t numTasks = uxTaskGetSystemState(statusArray, maxTasks, &totalRunTime); - // Print task stats debug("#### Task Stats ####"); - debug("%-15s%10s%10s%10s%10s%10s", "Task Name", "State", "Prio", "Stack", - "Num", "Time %"); for (UBaseType_t i = 0; i < numTasks; i++) { TaskStatus_t* taskStatus = &statusArray[i]; - // Calculate task run time as a percentage float runTimePercentage = 0.0; if (totalRunTime > 0) { runTimePercentage = (taskStatus->ulRunTimeCounter / (float)totalRunTime) * 100; } - char buffer[128]; - sprintf(buffer, "%-15s%10d%10d%10d%10d%10.2f", taskStatus->pcTaskName, - taskStatus->eCurrentState, taskStatus->uxCurrentPriority, - taskStatus->usStackHighWaterMark, (int)taskStatus->xTaskNumber, - runTimePercentage); - debug(buffer); + // Task name is an argument, never the format string: upstream passed a + // formatted buffer straight to debug(), so a '%' in a task name would + // have been interpreted as a conversion. + debug("%-15s%10d%10d%10d%10d%10.2f", taskStatus->pcTaskName, + taskStatus->eCurrentState, taskStatus->uxCurrentPriority, + taskStatus->usStackHighWaterMark, (int)taskStatus->xTaskNumber, + runTimePercentage); } - uint32_t esp_free_heap = ESP.getFreeHeap(); - uint32_t esp_min_free_heap = ESP.getMinFreeHeap(); - uint32_t rtos_free_heap = xPortGetFreeHeapSize(); - debug("Free heap: ESP %d, ESP MIN %d, RTOS %d\n", esp_free_heap, - esp_min_free_heap, rtos_free_heap); -} - -void App::_publish_system_state() { - uint32_t esp_free_heap = ESP.getFreeHeap(); - uint32_t esp_min_free_heap = ESP.getMinFreeHeap(); - uint32_t uptime = millis() / 1000; - int32_t rssi = network_.get_rssi(); - IPAddress ip = network_.get_ip(); - - StaticJsonDocument<512> doc; - doc["esp_free_heap"] = esp_free_heap; - doc["esp_min_free_heap"] = esp_min_free_heap; - doc["uptime_seconds"] = uptime; - doc["wifi_rssi"] = rssi; - doc["ip_address"] = ip.toString(); - doc["sw_version"] = SW_VERSION; -#if defined(HAS_BATTERY) - doc["batt_voltage"] = hw_.read_battery_voltage(); -#endif - - char buffer[512]; - serializeJson(doc, buffer, sizeof(buffer)); - network_.publish(topics_.t_system_state(), buffer, true); + debug("Free heap: ESP %d, ESP MIN %d, RTOS %d", ESP.getFreeHeap(), + ESP.getMinFreeHeap(), xPortGetFreeHeapSize()); } void App::_ui_task(void* param) { App* app = static_cast(param); while (true) { -#if defined(HAS_BUTTON_UI) app->bsl_input_.Loop(); -#elif defined(HAS_TOUCH_UI) - app->touch_handler_.Loop(); -#endif - -#if defined(HAS_FRONTLIGHT) - if (millis() - app->device_state_.flags().last_user_input_time > - FRONTLIGHT_TIMEOUT) { - if (!app->device_state_.flags().keep_frontlight_on) { - app->hw_.set_frontlight(0); - } - } -#endif delay(5); } } @@ -228,18 +144,15 @@ void App::_ui_task(void* param) { void App::_start_ui_task() { if (ui_task_h_ != nullptr) return; debug("UI task started."); - xTaskCreate( - _ui_task, // Function that should be called - "UI", // Name of the task (for debugging) - 10000, // Stack size (bytes) - this, // Parameter to pass - 23, // Task priority, using same as wifi driver: - // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/performance/speed.html - &ui_task_h_ // Task handle + xTaskCreate(_ui_task, // Function that should be called + "UI", // Name of the task (for debugging) + 10000, // Stack size (bytes) + this, // Parameter to pass + 23, // Task priority, same as the wifi driver + &ui_task_h_ // Task handle ); } -#if defined(HAS_DISPLAY) void App::_display_task(void* param) { App* app = static_cast(param); while (true) { @@ -250,7 +163,7 @@ void App::_display_task(void* param) { void App::_start_display_task() { if (display_task_h_ != nullptr) return; - debug("m_display task started."); + debug("display task started."); xTaskCreate(_display_task, // Function that should be called "DISPLAY", // Name of the task (for debugging) 5000, // Stack size (bytes) @@ -259,7 +172,6 @@ void App::_start_display_task() { &display_task_h_ // Task handle ); } -#endif void App::_network_task(void* param) { App* app = static_cast(param); @@ -283,43 +195,124 @@ void App::_start_network_task() { } void App::_begin_hw() { -#if defined(HAS_DISPLAY) - // must be before ledAttachPin (reserves GPIO37 = SPIDQS) + // must be before ledAttachPin (reserves GPIO37 = SPIDQS). + // Display::begin() also mounts SPIFFS, which is where the pre-flashed + // icons live. display_.begin(hw_); -#endif hw_.begin(); -#if defined(HAS_BUTTON_UI) bsl_input_.Init(); bsl_input_.LEDSetDefaultBrightnessAll(LED_DFLT_BRIGHT); -#elif defined(HAS_TOUCH_UI) - touch_handler_.Init(hw_.TOUCH_CLICK_PIN, hw_.TOUCH_INT_PIN); -#endif - -#if defined(HOME_BUTTONS_INDUSTRIAL) - // set button config based - auto conf = device_state_.user_preferences().btn_conf_string; - debug("Button config: %s", conf.c_str()); - for (uint8_t i = 0; i < NUM_BUTTONS; i++) { - if (conf[i] == 'S') { - bsl_input_.SetSwitchMode(i + 1, true); - } - } - sw_.SetSwitchMode(true); -#endif } void App::_start_tasks() { _start_ui_task(); _start_network_task(); -#if defined(HAS_DISPLAY) _start_display_task(); -#endif - -#if defined(HAS_BUTTON_UI) bsl_input_.Start(); -#elif defined(HAS_TOUCH_UI) - touch_handler_.Start(); -#endif +} + +// --------------------------------------------------------------------------- +// Counter plumbing +// --------------------------------------------------------------------------- + +bool App::_btn_to_counter(uint8_t btn_id, uint8_t& idx, int32_t& delta) { + switch (btn_id) { + case BTN_COUNTER_A_INC: + idx = 0; + delta = 1; + return true; + case BTN_COUNTER_A_DEC: + idx = 0; + delta = -1; + return true; + case BTN_COUNTER_B_INC: + idx = 1; + delta = 1; + return true; + case BTN_COUNTER_B_DEC: + idx = 1; + delta = -1; + return true; + default: + return false; + } +} + +void App::_refresh_counter_labels() { + // Only the increment buttons carry the running total; the decrement and + // spare button labels stay whatever the user configured in the portal. + device_state_.set_btn_label( + BTN_COUNTER_A_INC, + ButtonLabel("A %ld", static_cast(device_state_.counter(0))) + .c_str()); + device_state_.set_btn_label( + BTN_COUNTER_B_INC, + ButtonLabel("B %ld", static_cast(device_state_.counter(1))) + .c_str()); +} + +// Runs on the UI task. RAM only: no NVS write, no HTTP. +void App::_handle_counter_press(uint8_t btn_id) { + uint8_t idx = 0; + int32_t delta = 0; + if (!_btn_to_counter(btn_id, idx, delta)) { + debug("button %u is not assigned to a counter", btn_id); + return; + } + + int32_t count = device_state_.adjust_counter(idx, delta); + info("counter %s %+ld -> %ld", COUNTER_NAMES[idx], static_cast(delta), + static_cast(count)); + + _refresh_counter_labels(); + device_state_.flags().display_redraw = true; + + PressQueueElement element{}; + element.event.counter_idx = idx; + element.event.button_id = btn_id; + element.event.delta = delta; + element.event.count = count; + element.event.seq = device_state_.next_seq(); + element.queued_at = millis(); + + if (press_queue_ == nullptr || + xQueueSend(press_queue_, &element, (TickType_t)0) != pdTRUE) { + // The counter and the display already moved; only the notification is + // lost. The next delivered press carries the corrected absolute count. + error("press queue full, event for counter %s not sent", + COUNTER_NAMES[idx]); + } +} + +// Runs on the main task. Owns the NVS write and the POST. +void App::_flush_pending() { + if (press_queue_ == nullptr) return; + if (uxQueueMessagesWaiting(press_queue_) == 0) return; + if (network_.get_state() != Network::State::W_CONNECTED) return; + + // One NVS write covers however many presses are waiting. + device_state_.save_all(); + + PressQueueElement element; + while (xQueueReceive(press_queue_, &element, 0) == pdTRUE) { + element.event.age_ms = millis() - element.queued_at; + if (!webhook_.send_press(element.event)) { + warning("failed to deliver press seq %u", element.event.seq); + } + esp_task_wdt_reset(); + } +} + +void App::_net_on_connect() { + webhook_.begin(); + + if (boot_cause_ == BootCause::TIMER) { + webhook_.send_heartbeat(); + } +} + +void App::_handle_ui_event_global(UserInput::Event event) { + device_state_.flags().last_user_input_time = millis(); } void App::_main_task() { @@ -352,71 +345,26 @@ void App::_main_task() { device_state_.load_all(hw_); - // ------ factory test ------ - FactoryTest factory_test(*this); - if (factory_test.is_test_required()) { - if (factory_test.run_test()) { - device_state_.load_all(hw_); -#if defined(HAS_DISPLAY) - display_.disp_welcome(); - display_.update(); -#else - bsl_input_.LEDBlinkAll(2, LED_DFLT_BRIGHT, 500, 400, false); -#endif - info("factory test complete."); - _sleep_or_restart(); - } else { - error("factory test failed!"); -#if defined(HAS_DISPLAY) - display_.disp_error("Factory\nTest\nFailed"); - display_.update(); -#else - bsl_input_.LEDBlinkAll(5, LED_DFLT_BRIGHT, 200, 160, false); -#endif - _sleep_or_restart(); - } - } - _begin_hw(); - // ------ test code ------ - - // place test code here - // info("!!!!! Serial print test"); - // Serial.println("Serial"); - // Serial1.println("Serial1"); - // Serial.begin(115200); - // Serial1.begin(115200); - // Serial.println("Serial after begin"); - // Serial1.println("Serial1 after begin"); - // debug("Debug"); - - // while (true) { - // delay(1000); - // } - // ------ after update handler ------ if (device_state_.persisted().last_sw_ver != SW_VERSION) { if (device_state_.persisted().last_sw_ver.length() > 0) { info("firmware updated from %s to %s", device_state_.persisted().last_sw_ver.c_str(), SW_VERSION); device_state_.persisted().last_sw_ver = SW_VERSION; - device_state_.persisted().send_discovery_config = true; device_state_.save_all(); -#if defined(HAS_DISPLAY) display_.disp_message( (UIState::MessageType("Firmware\nupdated to\n") + SW_VERSION) .c_str()); display_.update(); -#endif ESP.restart(); } else { // first boot after factory flash device_state_.persisted().last_sw_ver = SW_VERSION; } } -// ------ determine power mode ------ -#if defined(HOME_BUTTONS_ORIGINAL) + // ------ determine power mode ------ device_state_.sensors().battery_present = hw_.is_battery_present(); device_state_.sensors().dc_connected = hw_.is_dc_connected(); info("batt present: %d, DC connected: %d", @@ -470,7 +418,6 @@ void App::_main_task() { } else { // battery_present == false if (device_state_.sensors().dc_connected) { device_state_.persisted().low_batt_mode = false; - // choose power mode based on user setting device_state_.flags().awake_mode = device_state_.persisted().user_awake_mode; } else { @@ -481,53 +428,13 @@ void App::_main_task() { info("usr awake mode: %d, awake mode: %d", device_state_.persisted().user_awake_mode, device_state_.flags().awake_mode); -#elif defined(HOME_BUTTONS_MINI) - float batt_voltage = hw_.read_battery_voltage(); - info("batt volts: %f", batt_voltage); - if (device_state_.persisted().low_batt_mode) { - if (batt_voltage >= hw_.BATT_HYSTERESIS_VOLT) { - device_state_.persisted().low_batt_mode = false; - device_state_.save_all(); - info("low batt mode disabled"); - ESP.restart(); // to handle m_display update - } else { - info("in low batt mode..."); - _go_to_sleep(); - } - } else { // low_batt_mode == false - if (batt_voltage < hw_.MIN_BATT_VOLT) { - // check again - delay(1000); - batt_voltage = hw_.read_battery_voltage(); - if (batt_voltage < hw_.MIN_BATT_VOLT) { - device_state_.persisted().low_batt_mode = true; - warning("batt voltage too low, low bat mode enabled"); - display_.disp_message_large( - "Turned\nOFF\n\nPlease\nreplace\nbatteries!"); - display_.update(); - _go_to_sleep(); - } - } else if (batt_voltage <= hw_.WARN_BATT_VOLT) { - device_state_.sensors().battery_low = true; - } - } - // mini doesn't have awake mode - device_state_.flags().awake_mode = false; -#elif defined(HOME_BUTTONS_PRO) || defined(HOME_BUTTONS_INDUSTRIAL) - device_state_.flags().awake_mode = true; -#endif - -#if defined(HAS_TH_SENSOR) - // ------ read sensors ------ - hw_.read_temp_hmd(device_state_.sensors().temperature, - device_state_.sensors().humidity, - device_state_.get_use_fahrenheit()); -#endif -#if defined(HAS_BATTERY) device_state_.sensors().battery_pct = hw_.read_battery_percent(); device_state_.sensors().battery_voltage = hw_.read_battery_voltage(); -#endif + + // Labels carry the running totals, so make them match the counters + // restored from NVS before anything is drawn. + _refresh_counter_labels(); // ------ start tasks ------ _start_tasks(); @@ -541,31 +448,11 @@ void App::_main_task() { switch (boot_cause_) { case BootCause::RESET: { if (!device_state_.persisted().silent_restart) { -#if defined(HAS_BUTTON_UI) bsl_input_.LEDOnAll(); delay(1000); -#endif -#if defined(HAS_FRONTLIGHT) - hw_.set_frontlight(hw_.FL_LED_BRIGHT_DFLT); -#endif -#if defined(HAS_DISPLAY) display_.disp_message("RESTART...", 0); delay(3000); -#endif - } - -#if defined(HAS_DISPLAY) - // format SPIFFS if needed - if (!SPIFFS.begin()) { - info("Formatting icon storage..."); - display_.disp_message("Formatting\nIcon\nStorage...", 0); - delay(3000); - SPIFFS.format(); - } else { - SPIFFS.end(); - debug("SPIFFS test mount OK"); } -#endif // check if restart to setup or Wi-Fi setup is needed if (device_state_.persisted().restart_to_wifi_setup) { @@ -582,44 +469,26 @@ void App::_main_task() { if (!device_state_.persisted().wifi_done || !device_state_.persisted().setup_done) { -#if defined(HAS_DISPLAY) display_.disp_welcome(); delay(3000); display_.end(); delay(3000); -#endif -#if defined(HAS_SLEEP_MODE) _go_to_sleep(); -#endif } else { -#if defined(HAS_DISPLAY) display_.disp_main(); delay(3000); -#endif } - device_state_.persisted().download_mdi_icons = true; - device_state_.persisted().send_discovery_config = true; device_state_.save_all(); if (device_state_.flags().awake_mode) { -// proceed with awake mode -#if defined(HAS_BUTTON_UI) bsl_input_.LEDOffAll(); -#elif defined(HAS_FRONTLIGHT) - hw_.set_frontlight(0); -#endif } else { -#if defined(HAS_DISPLAY) display_.end(); delay(3000); -#endif -#if defined(HAS_SLEEP_MODE) _go_to_sleep(); -#endif } break; } case BootCause::BUTTON: { -#if defined(HOME_BUTTONS_ORIGINAL) || defined(HOME_BUTTONS_MINI) if (!device_state_.flags().awake_mode) { if (device_state_.persisted().charge_complete_showing) { device_state_.persisted().charge_complete_showing = false; @@ -645,47 +514,28 @@ void App::_main_task() { } else { // proceed } - } else { - // proceed with awake mode } break; -#endif } - -#if defined(HOME_BUTTONS_ORIGINAL) case BootCause::TIMER: { - if (device_state_.flags().awake_mode) { - // proceed with awake mode - } else { - if (hw_.is_charger_in_standby()) { // hw <= 2.1 doesn't have awake - // mode when charging + if (!device_state_.flags().awake_mode) { + if (hw_.is_charger_in_standby()) { if (!device_state_.persisted().charge_complete_showing) { device_state_.persisted().charge_complete_showing = true; display_.disp_message_large("Fully\ncharged!"); } } - // proceed with sensor publish + // proceed with the heartbeat post } break; } -#endif default: break; } -#if defined(HAS_DISPLAY) display_.init_ui_state(UIState{.page = DisplayPage::MAIN}); -#endif - network_.set_mqtt_callback(std::bind(&App::_mqtt_callback, this, - std::placeholders::_1, - std::placeholders::_2)); network_.set_on_connect(std::bind(&App::_net_on_connect, this)); -#if defined(HAS_TOUCH_UI) - touch_handler_.SetEventCallbackSecondary( - std::bind(&App::_handle_ui_event_global, this, std::placeholders::_1)); -#endif - debug("Starting main state machine loop"); while (true) { loop(); @@ -694,315 +544,14 @@ void App::_main_task() { } } -void App::_handle_ui_event_global(UserInput::Event event) { - device_state_.flags().last_user_input_time = millis(); -} - -void App::_publish_ui_event(UserInput::Event event) { - TopicType topic = topics_.get_button_topic(event); - if (event.type == UserInput::EventType::kClickSingle || - event.type == UserInput::EventType::kClickDouble || - event.type == UserInput::EventType::kClickTriple || - event.type == UserInput::EventType::kClickQuad) { - network_.publish(topic, BTN_PRESS_PAYLOAD); - } else if (event.type == UserInput::EventType::kSwitchOn) { - network_.publish(topic, "ON"); - } else if (event.type == UserInput::EventType::kSwitchOff) { - network_.publish(topic, "OFF"); - } -} - -#if defined(HAS_TH_SENSOR) -void App::_publish_sensors() { - network_.publish(topics_.t_temperature(), - PayloadType("%.2f", device_state_.sensors().temperature)); - network_.publish(topics_.t_humidity(), - PayloadType("%.2f", device_state_.sensors().humidity)); - network_.publish(topics_.t_battery(), - PayloadType("%u", device_state_.sensors().battery_pct)); -} -#endif - -#if defined(HAS_BATTERY) -void App::_publish_battery() { - network_.publish(topics_.t_battery(), - PayloadType("%u", device_state_.sensors().battery_pct)); -} -#endif - -#if defined(HAS_AWAKE_MODE) -void App::_publish_awake_mode_avlb() { - if (hw_.is_dc_connected()) { - network_.publish(topics_.t_awake_mode_avlb(), "online", true); - } else { - network_.publish(topics_.t_awake_mode_avlb(), "offline", true); - } -} -#endif - -void App::_mqtt_callback(const char* topic, const char* payload) { -#if defined(HAS_TH_SENSOR) - if (strcmp(topic, topics_.t_sensor_interval_cmd().c_str()) == 0) { - uint16_t mins = atoi(payload); - if (mins >= SEN_INTERVAL_MIN && mins <= SEN_INTERVAL_MAX) { - device_state_.set_sensor_interval(mins); - device_state_.save_all(); - network_.publish(topics_.t_sensor_interval_state(), - PayloadType("%u", device_state_.sensor_interval()), - true); - info("Updating discovery config..."); - mqtt_.update_discovery_config(); - debug("sensor interval set to %d minutes", mins); - _publish_sensors(); - } - network_.publish(topics_.t_sensor_interval_cmd(), "", true); - return; - } -#endif - -#if defined(HAS_DISPLAY) - for (uint8_t i = 0; i < NUM_BUTTONS; i++) { - if (strcmp(topic, topics_.t_btn_label_cmd(i + 1).c_str()) == 0) { - ButtonLabel new_label(payload); - new_label = new_label.trim(); - debug("button %d label changed to: %s", i + 1, new_label.c_str()); - device_state_.set_btn_label(i + 1, new_label.c_str()); - - network_.publish(topics_.t_btn_label_state(i + 1), - device_state_.get_btn_label(i + 1), true); - network_.publish(topics_.t_btn_label_cmd(i + 1), "", true); - device_state_.flags().display_redraw = true; - device_state_.save_all(); - - ButtonLabel label(device_state_.get_btn_label(i + 1).c_str()); - - if (label.substring(0, 4) == "mdi:") { - device_state_.persisted().download_mdi_icons = true; - } - return; - } - } -#endif - -#if defined(HAS_AWAKE_MODE) - if (strcmp(topic, topics_.t_awake_mode_cmd().c_str()) == 0) { - if (strcmp(payload, "ON") == 0) { - device_state_.persisted().user_awake_mode = true; - device_state_.flags().awake_mode = true; - device_state_.save_all(); - network_.publish(topics_.t_awake_mode_state(), "ON", true); - debug("user awake mode set to: ON"); - debug("resetting to awake mode..."); - } else if (strcmp(payload, "OFF") == 0) { - device_state_.persisted().user_awake_mode = false; - device_state_.save_all(); - network_.publish(topics_.t_awake_mode_state(), "OFF", true); - debug("user awake mode set to: OFF"); - } - network_.publish(topics_.t_awake_mode_cmd(), "", true); - return; - } -#endif - -#if defined(HAS_DISPLAY) - // user message - if (strcmp(topic, topics_.t_disp_msg_cmd().c_str()) == 0) { - if (display_.get_ui_state().page == DisplayPage::MAIN) { - UserMessage msg(payload); - device_state_.persisted().user_msg_showing = true; - device_state_.save_all(); - display_.disp_message_large(msg.c_str()); - } - network_.publish(topics_.t_disp_msg_cmd(), "", true); - network_.publish(topics_.t_disp_msg_state(), "-", false); - } -#endif - -#if defined(HAS_SLEEP_MODE) - // schedule wakeup cmd - if (strcmp(topic, topics_.t_schedule_wakeup_cmd().c_str()) == 0) { - uint32_t secs = atoi(payload); - if (secs >= SCHEDULE_WAKEUP_MIN && secs <= SCHEDULE_WAKEUP_MAX) { - device_state_.flags().schedule_wakeup_time = secs; - network_.publish(topics_.t_schedule_wakeup_cmd(), "", true); - network_.publish(topics_.t_schedule_wakeup_state(), "None", true); - debug("schedule wakeup set to %d seconds", secs); - } - } -#endif - -#if defined(HOME_BUTTONS_INDUSTRIAL) - // led amb_bright cmd - if (strcmp(topic, topics_.t_led_amb_bright_cmd().c_str()) == 0) { - uint16_t amb_bright = atoi(payload); - if (amb_bright <= LED_MAX_AMB_BRIGHT) { - device_state_.set_led_brightness(amb_bright); - device_state_.save_all(); - bsl_input_.LEDSetAmbientBrightnessAll(amb_bright); - bsl_input_.LEDSetDefaultBrightnessAll( - amb_bright * (LED_DFLT_BRIGHT / LED_MAX_AMB_BRIGHT)); - network_.publish(topics_.t_led_amb_bright_state(), - PayloadType("%u", amb_bright), true); - debug("LED amb_bright set to %d", amb_bright); - } else { - warning("Invalid amb_bright value: %d", amb_bright); - } - network_.publish(topics_.t_led_amb_bright_cmd(), "", true); - return; - } - - // switch cmd - for (auto bsl_w : bsl_input_.GetBtnSwLEDs()) { - if (bsl_w.get().switch_mode() && !bsl_w.get().is_kill_switch()) { - if (strcmp(topic, topics_.t_switch_cmd(bsl_w.get().id()).c_str()) == 0) { - if (strcmp(payload, "ON") == 0) { - bsl_w.get().SetSwitchOn(); - network_.publish(topics_.t_switch_state(bsl_w.get().id()), "ON", - false); - } else if (strcmp(payload, "OFF") == 0) { - network_.publish(topics_.t_switch_state(bsl_w.get().id()), "OFF", - false); - bsl_w.get().SetSwitchOff(); - } - network_.publish(topics_.t_switch_cmd(bsl_w.get().id()), "", true); - return; - } - } - } -#endif -} - -void App::_net_on_connect() { - if (device_state_.persisted().send_discovery_config) { - device_state_.persisted().send_discovery_config = false; - info("Sending discovery config..."); - mqtt_.send_discovery_config(); - } - - network_.subscribe(topics_.t_cmd() + "#"); -#if defined(HAS_AWAKE_MODE) - _publish_awake_mode_avlb(); - network_.publish(topics_.t_awake_mode_state(), - (device_state_.persisted().user_awake_mode) ? "ON" : "OFF", - true); -#endif -#if defined(HAS_TH_SENSOR) - network_.publish(topics_.t_sensor_interval_state(), - PayloadType("%u", device_state_.sensor_interval()), true); -#endif -#if defined(HAS_DISPLAY) - for (uint8_t i = 0; i < NUM_BUTTONS; i++) { - auto t = topics_.t_btn_label_state(i + 1); - network_.publish(t, device_state_.get_btn_label(i + 1), true); - } - network_.publish(topics_.t_disp_msg_state(), "-", false); -#endif - -#if defined(HOME_BUTTONS_INDUSTRIAL) - network_.publish( - topics_.t_led_amb_bright_state(), - PayloadType("%u", device_state_.user_preferences().led_amb_bright, true)); - network_.publish(topics_.t_avlb(), "online", true); - for (auto bsl_w : bsl_input_.GetBtnSwLEDs()) { - // publish switch state is switch mode - if (bsl_w.get().switch_mode()) { - network_.publish(topics_.t_switch_state(bsl_w.get().id()), - bsl_w.get().switch_state() ? "ON" : "OFF", false); - } - } -#endif - -#if defined(HAS_DISPLAY) - if (device_state_.persisted().download_mdi_icons) { - device_state_.persisted().download_mdi_icons = false; - _download_mdi_icons(); - } -#endif -} - -#if defined(HAS_DISPLAY) -void App::_download_mdi_icons() { - bool download_required = false; - mdi_.begin(); - for (uint8_t i = 0; i < NUM_BUTTONS; i++) { - ButtonLabel label(device_state_.get_btn_label(i + 1).c_str()); - if (label.substring(0, 4) == "mdi:") { - MDIName icon = label.substring( - 4, label.index_of(' ') > 0 ? label.index_of(' ') : label.length()); - if (!mdi_.exists_all_sizes(icon.c_str())) { - download_required = true; - break; - } - } - } - if (!download_required) { - info("no icons to download"); - mdi_.end(); - return; - } - - display_.disp_message("Downloading\nicons..."); - - // check if server is reachable - if (mdi_.check_connection()) { - info("icon server reachable"); - } else { - warning("icon server NOT reachable"); - mdi_.end(); - display_.disp_error("Icon\nserver\nNOT\nreachable"); - device_state_.flags().display_redraw = true; - return; - } - - // free up space if needed - size_t free = mdi_.get_free_space(); - info("SPIFFS free space: %d", free); - if (free < MDI_FREE_SPACE_THRESHOLD) { - info("making space..."); - if (!mdi_.make_space(2 * MDI_FREE_SPACE_THRESHOLD)) { - error("failed to make space"); - mdi_.end(); - return; - } - } - - info("Downloading icons..."); - for (uint8_t i = 0; i < NUM_BUTTONS; i++) { - ButtonLabel label(device_state_.get_btn_label(i + 1).c_str()); - if (label.substring(0, 4) == "mdi:") { - MDIName icon = label.substring( - 4, label.index_of(' ') > 0 ? label.index_of(' ') : label.length()); - if (!mdi_.exists_all_sizes(icon.c_str())) { - mdi_.download(icon.c_str()); - } - } - } - mdi_.end(); - device_state_.flags().display_redraw = true; -} -#endif +// --------------------------------------------------------------------------- +// States +// --------------------------------------------------------------------------- void AppSMStates::InitState::entry() { sm().network_.connect(); sm().bsl_input_.InitPress(sm().wakeup_btn_id_); -#if defined(HOME_BUTTONS_ORIGINAL) - sm().mdi_.add_size(64); - sm().mdi_.add_size(48); -#elif defined(HOME_BUTTONS_MINI) - sm().mdi_.add_size(100); -#elif defined(HOME_BUTTONS_PRO) - sm().mdi_.add_size(92); - sm().mdi_.add_size(64); -#endif - -#if defined(HOME_BUTTONS_INDUSTRIAL) - uint8_t amb_bright = sm().device_state_.user_preferences().led_amb_bright; - sm().bsl_input_.LEDSetAmbientBrightnessAll(amb_bright); - uint8_t dflt_bright = amb_bright * (LED_DFLT_BRIGHT / LED_MAX_AMB_BRIGHT); - sm().bsl_input_.LEDSetDefaultBrightnessAll(dflt_bright); -#endif - // open settings menu if setup not done if (sm().device_state_.flags().awake_mode) { if (!sm().device_state_.persisted().wifi_done) { @@ -1028,24 +577,15 @@ void AppSMStates::InitState::entry() { sm().device_state_.persisted().charge_complete_showing = false; esp_task_wdt_init(WDT_TIMEOUT_AWAKE, true); esp_task_wdt_add(NULL); -#if defined(HAS_DISPLAY) sm().display_.disp_main(); -#endif return transition_to(); } } void AppSMStates::AwakeModeIdleState::entry() { -#if defined(HAS_DISPLAY) sm().display_.disp_main(); -#endif -#if defined(HAS_BUTTON_UI) sm().bsl_input_.SetEventCallback(std::bind( &AwakeModeIdleState::handle_ui_event, this, std::placeholders::_1)); -#elif defined(HAS_TOUCH_UI) - sm().touch_handler_.SetEventCallback(std::bind( - &AwakeModeIdleState::handle_ui_event, this, std::placeholders::_1)); -#endif } void AppSMStates::AwakeModeIdleState::exit() { @@ -1053,51 +593,17 @@ void AppSMStates::AwakeModeIdleState::exit() { } void AppSMStates::AwakeModeIdleState::loop() { - if (millis() - sm().last_sensor_publish_ >= AWAKE_SENSOR_INTERVAL) { -#if defined(HAS_TH_SENSOR) - sm().hw_.read_temp_hmd(sm().device_state_.sensors().temperature, - sm().device_state_.sensors().humidity, - sm().device_state_.get_use_fahrenheit()); - sm()._publish_sensors(); -#endif - -#if defined(HAS_BATTERY) - sm().device_state_.sensors().battery_pct = sm().hw_.read_battery_percent(); - sm().device_state_.sensors().battery_voltage = - sm().hw_.read_battery_voltage(); - sm()._publish_battery(); -#endif - sm().last_sensor_publish_ = millis(); - sm()._publish_system_state(); -#ifdef HOME_BUTTONS_DEBUG - sm()._log_task_stats(); -#endif - } + sm()._flush_pending(); -#if defined(HAS_DISPLAY) if (millis() - sm().last_m_display_redraw_ >= AWAKE_REDRAW_INTERVAL) { if (sm().device_state_.flags().display_redraw) { sm().device_state_.flags().display_redraw = false; - if (sm().device_state_.persisted().download_mdi_icons) { - sm()._download_mdi_icons(); - sm().device_state_.persisted().download_mdi_icons = false; - } sm().display_.disp_main(); } sm().last_m_display_redraw_ = millis(); } -#endif -#if defined(HAS_FRONTLIGHT) - else if (millis() - sm().device_state_.flags().last_user_input_time > - FRONTLIGHT_TIMEOUT) { - sm().hw_.set_frontlight(0); - } -#endif - -#if defined(HAS_CHARGER) - else if (!sm().hw_.is_dc_connected()) { - sm()._publish_awake_mode_avlb(); + if (!sm().hw_.is_dc_connected()) { sm().device_state_.sensors().charging = false; return transition_to(); } @@ -1114,43 +620,26 @@ void AppSMStates::AwakeModeIdleState::loop() { return transition_to(); } } -#endif } void AppSMStates::AwakeModeIdleState::handle_ui_event(UserInput::Event event) { sm().device_state_.flags().last_user_input_time = millis(); -#if defined(HAS_FRONTLIGHT) - sm().hw_.set_frontlight(sm().hw_.FL_LED_BRIGHT_DFLT); -#endif if (event.final) { switch (event.type) { case UserInput::EventType::kClickSingle: - case UserInput::EventType::kClickDouble: - case UserInput::EventType::kClickTriple: - case UserInput::EventType::kClickQuad: - sm()._publish_ui_event(event); - sm().bsl_input_.LEDBlink(event.btn_id, - UserInput::EventType2NumClicks(event.type), 0, - 0, 0, false); - break; - case UserInput::EventType::kSwipeDown: - return transition_to(); - case UserInput::EventType::kSwitchOff: - case UserInput::EventType::kSwitchOn: - sm()._publish_ui_event(event); + sm()._handle_counter_press(event.btn_id); + sm().bsl_input_.LEDBlink(event.btn_id, 1, 0, 0, 0, false); break; default: break; } } else { switch (event.type) { -#if defined(HAS_DISPLAY) case UserInput::EventType::kHoldLong2s: if (sm().hw_.num_buttons_pressed() == 1) { return transition_to(); } break; -#endif case UserInput::EventType::kHoldLong5s: if (sm().hw_.num_buttons_pressed() == 2) { return transition_to(); @@ -1164,13 +653,8 @@ void AppSMStates::AwakeModeIdleState::handle_ui_event(UserInput::Event event) { void AppSMStates::SleepModeHandleInput::entry() { sm().input_start_time_ = millis(); -#if defined(HAS_BUTTON_UI) sm().bsl_input_.SetEventCallback(std::bind( &SleepModeHandleInput::handle_ui_event, this, std::placeholders::_1)); -#elif defined(HAS_TOUCH_UI) - sm().touch_handler_.SetEventCallback(std::bind( - &SleepModeHandleInput::handle_ui_event, this, std::placeholders::_1)); -#endif } void AppSMStates::SleepModeHandleInput::exit() { @@ -1188,10 +672,7 @@ void AppSMStates::SleepModeHandleInput::handle_ui_event( sm().device_state_.flags().last_user_input_time = millis(); if (event.final) { switch (event.type) { - case UserInput::EventType::kClickSingle: - case UserInput::EventType::kClickDouble: - case UserInput::EventType::kClickTriple: - case UserInput::EventType::kClickQuad: + case UserInput::EventType::kClickSingle: { if (!sm().device_state_.persisted().wifi_done) { sm().device_state_.persisted().restart_to_wifi_setup = true; sm().device_state_.persisted().silent_restart = true; @@ -1205,22 +686,19 @@ void AppSMStates::SleepModeHandleInput::handle_ui_event( sm().info("restarting to setup..."); ESP.restart(); } - sm().bsl_input_.LEDBlink(event.btn_id, - UserInput::EventType2NumClicks(event.type), 0, - 0, 0, true); -#if defined(HAS_BATTERY) + sm()._handle_counter_press(event.btn_id); + sm().bsl_input_.LEDBlink(event.btn_id, 1, 0, 0, 0, true); if (sm().device_state_.sensors().battery_low) { sm().display_.disp_message_large(BATT_EMPTY_MSG, 3000); } -#endif sm().user_event_ = event; return transition_to(); + } default: break; } } else { // non final event switch (event.type) { -#if defined(HAS_DISPLAY) case UserInput::EventType::kHoldLong2s: if (sm().hw_.num_buttons_pressed() == 1) { return transition_to(); @@ -1231,7 +709,6 @@ void AppSMStates::SleepModeHandleInput::handle_ui_event( return transition_to(); } break; -#endif default: break; } @@ -1239,13 +716,9 @@ void AppSMStates::SleepModeHandleInput::handle_ui_event( } void AppSMStates::NetConnectingState::entry() { -#if defined(HAS_BUTTON_UI) + start_time_ = millis(); sm().bsl_input_.SetEventCallback(std::bind( &NetConnectingState::handle_ui_event, this, std::placeholders::_1)); -#elif defined(HAS_TOUCH_UI) - sm().touch_handler_.SetEventCallback(std::bind( - &NetConnectingState::handle_ui_event, this, std::placeholders::_1)); -#endif } void AppSMStates::NetConnectingState::exit() { @@ -1253,27 +726,16 @@ void AppSMStates::NetConnectingState::exit() { } void AppSMStates::NetConnectingState::loop() { -#if defined(HAS_BUTTON_UI) - if (sm().network_.get_state() == Network::State::M_CONNECTED) { - if (sm().user_event_.type != UserInput::EventType::kNone) { - sm()._publish_ui_event(sm().user_event_); - } -#if defined(HAS_TH_SENSOR) - sm().hw_.read_temp_hmd(sm().device_state_.sensors().temperature, - sm().device_state_.sensors().humidity, - sm().device_state_.get_use_fahrenheit()); - sm()._publish_sensors(); - sm()._publish_system_state(); -#endif -#if defined(HAS_BATTERY) - sm().device_state_.sensors().battery_pct = sm().hw_.read_battery_percent(); - sm()._publish_battery(); -#endif + if (sm().network_.get_state() == Network::State::W_CONNECTED) { sm().device_state_.persisted().failed_connections = 0; + sm()._flush_pending(); + if (sm().boot_cause_ == BootCause::BUTTON) { + return transition_to(); + } return transition_to(); - - } else if (millis() >= NET_CONNECT_TIMEOUT) { -#if defined(HAS_DISPLAY) + // Upstream compared millis() against the timeout directly, which only + // happened to work because the device had just booted. + } else if (millis() - start_time_ >= NET_CONNECT_TIMEOUT) { sm().warning("network connect timeout."); if (sm().boot_cause_ == BootCause::BUTTON) { sm().display_.disp_error("Network\nconnection\nnot\nsuccessful", 3000); @@ -1287,18 +749,64 @@ void AppSMStates::NetConnectingState::loop() { } } return transition_to(); -#endif } -#endif } void AppSMStates::NetConnectingState::handle_ui_event(UserInput::Event event) { sm().device_state_.flags().last_user_input_time = millis(); + if (event.final && event.type == UserInput::EventType::kClickSingle) { + // Queued now, delivered as soon as the link comes up. + sm()._handle_counter_press(event.btn_id); + sm().bsl_input_.LEDBlink(event.btn_id, 1, 0, 0, 0, true); + } +} + +void AppSMStates::SessionState::entry() { + sm().session_last_input_time_ = millis(); + sm().info("session open, sleeping after %u ms idle", SESSION_IDLE_TIMEOUT); + sm().display_.disp_main(); + sm().bsl_input_.SetEventCallback( + std::bind(&SessionState::handle_ui_event, this, std::placeholders::_1)); +} + +void AppSMStates::SessionState::exit() { sm().bsl_input_.ClearEventCallback(); } + +void AppSMStates::SessionState::loop() { + sm()._flush_pending(); + + if (millis() - sm().last_m_display_redraw_ >= AWAKE_REDRAW_INTERVAL) { + if (sm().device_state_.flags().display_redraw) { + sm().device_state_.flags().display_redraw = false; + sm().display_.disp_main(); + } + sm().last_m_display_redraw_ = millis(); + } + + if (millis() - sm().session_last_input_time_ > SESSION_IDLE_TIMEOUT) { + sm().info("session idle, shutting down"); + return transition_to(); + } +} + +void AppSMStates::SessionState::handle_ui_event(UserInput::Event event) { + sm().device_state_.flags().last_user_input_time = millis(); + sm().session_last_input_time_ = millis(); if (event.final) { + if (event.type == UserInput::EventType::kClickSingle) { + sm()._handle_counter_press(event.btn_id); + sm().bsl_input_.LEDBlink(event.btn_id, 1, 0, 0, 0, false); + } + } else { switch (event.type) { - case UserInput::EventType::kClickSingle: - sm().info("button press - user cancelled, aborting..."); - return transition_to(); + case UserInput::EventType::kHoldLong2s: + if (sm().hw_.num_buttons_pressed() == 1) { + return transition_to(); + } + break; + case UserInput::EventType::kHoldLong5s: + if (sm().hw_.num_buttons_pressed() == 2) { + return transition_to(); + } break; default: break; @@ -1307,35 +815,21 @@ void AppSMStates::NetConnectingState::handle_ui_event(UserInput::Event event) { } void AppSMStates::InfoScreenState::entry() { -#if defined(HAS_DISPLAY) sm().info_screen_start_time_ = millis(); sm().display_.disp_info(); -#if defined(HAS_BUTTON_UI) sm().bsl_input_.SetEventCallback(std::bind(&InfoScreenState::handle_ui_event, this, std::placeholders::_1)); -#elif defined(HAS_TOUCH_UI) - sm().device_state_.flags().keep_frontlight_on = true; - sm().hw_.set_frontlight(sm().hw_.FL_LED_BRIGHT_DFLT); - sm().touch_handler_.SetEventCallback(std::bind( - &InfoScreenState::handle_ui_event, this, std::placeholders::_1)); -#endif -#endif } void AppSMStates::InfoScreenState::exit() { sm().bsl_input_.ClearEventCallback(); - sm().device_state_.flags().keep_frontlight_on = false; } void AppSMStates::InfoScreenState::loop() { - if (millis() - sm().info_screen_start_time_ >= INFO_SCREEN_DISP_TIME) { - sm().debug("info screen timeout"); + if (millis() - sm().info_screen_start_time_ > INFO_SCREEN_DISP_TIME) { if (sm().device_state_.flags().awake_mode) { return transition_to(); } else { -#if defined(HAS_DISPLAY) - sm().display_.disp_main(); -#endif return transition_to(); } } @@ -1343,52 +837,28 @@ void AppSMStates::InfoScreenState::loop() { void AppSMStates::InfoScreenState::handle_ui_event(UserInput::Event event) { sm().device_state_.flags().last_user_input_time = millis(); - if (event.final) { - switch (event.type) { - case UserInput::EventType::kSwipeUp: - case UserInput::EventType::kSwipeDown: - case UserInput::EventType::kClickSingle: - if (sm().device_state_.flags().awake_mode) { - return transition_to(); - } else { - return transition_to(); - } - default: - break; + if (event.final && event.type == UserInput::EventType::kClickSingle) { + if (sm().device_state_.flags().awake_mode) { + return transition_to(); + } else { + return transition_to(); + } + } else if (!event.final && event.type == UserInput::EventType::kHoldLong5s) { + if (sm().hw_.num_buttons_pressed() == 2) { + return transition_to(); } } } void AppSMStates::SettingsMenuState::entry() { sm().settings_menu_start_time_ = millis(); -#if defined(HOME_BUTTONS_INDUSTRIAL) - sm().bsl_input_.PauseSwitchModeAll(); -#endif -#if defined(HAS_DISPLAY) sm().display_.disp_settings(); -#else - sm().bsl_input_.LEDPulseAll(0, 2000); -#endif -#if defined(HAS_BUTTON_UI) sm().bsl_input_.SetEventCallback(std::bind( &SettingsMenuState::handle_ui_event, this, std::placeholders::_1)); -#elif defined(HAS_TOUCH_UI) - sm().device_state_.flags().keep_frontlight_on = true; - sm().hw_.set_frontlight(sm().hw_.FL_LED_BRIGHT_DFLT); - sm().touch_handler_.SetEventCallback(std::bind( - &SettingsMenuState::handle_ui_event, this, std::placeholders::_1)); -#endif } void AppSMStates::SettingsMenuState::exit() { sm().bsl_input_.ClearEventCallback(); - sm().device_state_.flags().keep_frontlight_on = false; -#if !defined(HAS_DISPLAY) - sm().bsl_input_.LEDOffAll(); -#endif -#if defined(HOME_BUTTONS_INDUSTRIAL) - sm().bsl_input_.ResumeSwitchModeAll(); -#endif } void AppSMStates::SettingsMenuState::loop() { @@ -1404,129 +874,75 @@ void AppSMStates::SettingsMenuState::loop() { void AppSMStates::SettingsMenuState::handle_ui_event(UserInput::Event event) { sm().device_state_.flags().last_user_input_time = millis(); -#if defined(HAS_BUTTON_UI) - if (event.final) { - switch (event.type) { - case UserInput::EventType::kClickSingle: - switch (event.btn_id) { - case 1: - // setup - sm().device_state_.persisted().restart_to_setup = true; - sm().device_state_.persisted().silent_restart = true; - sm().device_state_.save_all(); - sm().info("restarting to setup..."); - ESP.restart(); - break; - case 2: - // Wi-Fi setup - sm().device_state_.persisted().restart_to_wifi_setup = true; - sm().device_state_.persisted().silent_restart = true; - sm().device_state_.save_all(); - sm().info("restarting to Wi-Fi setup..."); - ESP.restart(); - break; - case 3: - // restart - sm().info("restarting..."); - ESP.restart(); - break; - case 4: - // cancel - if (sm().device_state_.flags().awake_mode) { - return transition_to(); - } else { - return transition_to(); - } - break; - default: - break; - } - break; - default: - break; - } - } else { // non final - switch (event.type) { - case UserInput::EventType::kHoldLong10s: - if (event.btn_id == 3) { - // factory reset - return transition_to(); - } - break; -#if defined(HAS_DISPLAY) - case UserInput::EventType::kHoldLong2s: - if (event.btn_id == 1) { - // device info screen - return transition_to(); - } - break; -#endif - default: - break; - } - } - -#elif defined(HOME_BUTTONS_PRO) if (event.final) { - switch (event.type) { - case UserInput::EventType::kClickSingle: - if (event.point.y < 74) { + if (event.type == UserInput::EventType::kClickSingle) { + switch (event.btn_id) { + case 1: // setup sm().device_state_.persisted().restart_to_setup = true; sm().device_state_.persisted().silent_restart = true; sm().device_state_.save_all(); + sm().info("restarting to setup..."); ESP.restart(); - } else if (event.point.y < 149) { + break; + case 2: // Wi-Fi setup sm().device_state_.persisted().restart_to_wifi_setup = true; sm().device_state_.persisted().silent_restart = true; sm().device_state_.save_all(); + sm().info("restarting to Wi-Fi setup..."); ESP.restart(); - } else if (event.point.y < 224) { + break; + case 3: // restart + sm().info("restarting..."); ESP.restart(); - } else { - // exit - return transition_to(); - } - break; + break; + case 4: + // cancel + if (sm().device_state_.flags().awake_mode) { + return transition_to(); + } else { + return transition_to(); + } + break; + default: + break; + } + } + } else { // non final + switch (event.type) { case UserInput::EventType::kHoldLong10s: - if (event.point.y > 149 && event.point.y < 224) { + if (event.btn_id == 3) { // factory reset return transition_to(); } break; + case UserInput::EventType::kHoldLong2s: + if (event.btn_id == 1) { + // device info screen + return transition_to(); + } + break; default: break; } } -#endif } void AppSMStates::DeviceInfoState::entry() { sm().device_info_start_time_ = millis(); -#if defined(HAS_DISPLAY) sm().display_.disp_device_info(); -#endif -#if defined(HAS_BUTTON_UI) sm().bsl_input_.SetEventCallback(std::bind(&DeviceInfoState::handle_ui_event, this, std::placeholders::_1)); -#elif defined(HAS_TOUCH_UI) - sm().device_state_.flags().keep_frontlight_on = true; - sm().hw_.set_frontlight(sm().hw_.FL_LED_BRIGHT_DFLT); - sm().touch_handler_.SetEventCallback(std::bind( - &DeviceInfoState::handle_ui_event, this, std::placeholders::_1)); -#endif } void AppSMStates::DeviceInfoState::exit() { sm().bsl_input_.ClearEventCallback(); - sm().device_state_.flags().keep_frontlight_on = false; } void AppSMStates::DeviceInfoState::loop() { if (millis() - sm().device_info_start_time_ > DEVICE_INFO_TIMEOUT) { - sm().debug("device info timeout"); if (sm().device_state_.flags().awake_mode) { return transition_to(); } else { @@ -1537,130 +953,61 @@ void AppSMStates::DeviceInfoState::loop() { void AppSMStates::DeviceInfoState::handle_ui_event(UserInput::Event event) { sm().device_state_.flags().last_user_input_time = millis(); -#if defined(HOME_BUTTONS_ORIGINAL) || defined(HOME_BUTTONS_MINI) - if (event.final) { - switch (event.type) { - case UserInput::EventType::kClickSingle: - // cancel - if (sm().device_state_.flags().awake_mode) { - return transition_to(); - } else { - return transition_to(); - } - break; - default: - break; + if (event.final && event.type == UserInput::EventType::kClickSingle) { + if (sm().device_state_.flags().awake_mode) { + return transition_to(); + } else { + return transition_to(); } } -#elif defined(HOME_BUTTONS_PRO) -#TODO -#endif } void AppSMStates::CmdShutdownState::entry() { -#if defined(HAS_DISPLAY) - if (sm().display_.get_ui_state().page != DisplayPage::MAIN) { - sm().display_.disp_main(); - } - if (sm().device_state_.persisted().download_mdi_icons) { - sm()._download_mdi_icons(); - sm().device_state_.persisted().download_mdi_icons = false; - } - if (sm().boot_cause_ == BootCause::RESET) { - sm().display_.disp_main(); - } -#endif sm().shutdown_cmd_time_ = millis(); + // Anything still queued has to go out before the link drops. + sm()._flush_pending(); + sm().device_state_.save_all(); + sm().network_.disconnect(); + sm().bsl_input_.Stop(); } void AppSMStates::CmdShutdownState::loop() { - // wait for timeout if (millis() - sm().shutdown_cmd_time_ > SHUTDOWN_DELAY) { -#if defined(HAS_BUTTON_UI) - sm().bsl_input_.Stop(); -#elif defined(HAS_TOUCH_UI) - sm().touch_handler_.Stop(); -#endif - sm().network_.disconnect(); return transition_to(); } } void AppSMStates::NetDisconnectingState::loop() { - bool conditions = true; - conditions = - conditions && sm().network_.get_state() == Network::State::DISCONNECTED; -#if defined(HAS_DISPLAY) - conditions = conditions && !sm().display_.busy(); -#endif - if (conditions) { -#if defined(HAS_DISPLAY) - if (sm().device_state_.flags().display_redraw) { - sm().device_state_.flags().display_redraw = false; - sm().display_.disp_main(); - } - sm().display_.end(); -#endif + if (sm().network_.get_state() == Network::State::DISCONNECTED) { return transition_to(); } } void AppSMStates::ShuttingDownState::loop() { - bool ended = true; -#if defined(HAS_DISPLAY) - ended = ended && sm().display_.get_state() == Display::State::IDLE; -#endif -#if defined(HAS_BUTTON_UI) - ended = ended && - sm().bsl_input_.cstate() == ComponentBase::ComponentState::kStopped && - !sm().hw_.any_button_pressed(); -#endif -#if defined(HAS_TOUCH_UI) - ended = ended && sm().touch_handler_.cstate() == - ComponentBase::ComponentState::kStopped; -#endif - if (ended) { -#ifdef HOME_BUTTONS_DEBUG - sm()._log_task_stats(); -#endif - if (sm().device_state_.flags().awake_mode) { - sm().device_state_.persisted().silent_restart = true; - sm().device_state_.save_all(); - ESP.restart(); - } else { -#if defined(HAS_SLEEP_MODE) - sm()._go_to_sleep(); -#endif - } + if (sm().device_state_.flags().display_redraw) { + sm().device_state_.flags().display_redraw = false; + sm().display_.disp_main(); + sm().display_.update(); } + sm().display_.end(); + delay(100); + sm()._go_to_sleep(); } void AppSMStates::FactoryResetState::entry() { sm().info("factory reset..."); - sm().network_.disconnect(true); // erase login data -#if defined(HAS_DISPLAY) - sm().display_.disp_message("Factory\nRESET..."); - sm().display_.end(); -#else - sm().bsl_input_.LEDBlink(3, 10, 0, 200, 160, false); -#endif -#if defined(HOME_BUTTONS_ORIGINAL) || defined(HOME_BUTTONS_MINI) - sm().bsl_input_.Stop(); -#elif defined(HOME_BUTTONS_PRO) -#endif + sm().display_.disp_message("Factory\nRESET", 0); + sm().device_state_.clear_all(); + sm().network_.disconnect(true); } void AppSMStates::FactoryResetState::loop() { - bool conditions = true; - conditions = - conditions && sm().network_.get_state() == Network::State::DISCONNECTED; -#if defined(HAS_DISPLAY) - conditions = conditions && sm().display_.get_state() == Display::State::IDLE; -#endif - if (conditions) { - sm().device_state_.clear_all(); - sm().info("factory reset complete."); - delay(5000); + if (sm().network_.get_state() == Network::State::DISCONNECTED) { + sm().display_.disp_message("Factory\nRESET\ncomplete", 3000); + sm().display_.update(); + delay(3000); + sm().display_.end(); + delay(1000); ESP.restart(); } } diff --git a/Firmware/HomeButtonsArduino/src/app.h b/Firmware/HomeButtonsArduino/src/app.h index fc92c0f..72cf485 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -2,26 +2,16 @@ #define HOMEBUTTONS_APP_H #include +#include "freertos/FreeRTOS.h" // must precede queue.h +#include "freertos/queue.h" #include "state.h" #include "network.h" -#include "mqtt_helper.h" -#include "topics.h" +#include "webhook.h" #include "logger.h" #include "hardware.h" #include "setup.h" - -#if defined(HAS_DISPLAY) #include "display/display.h" -#include "mdi/mdi_helper.h" -#endif - -#if defined(HAS_BUTTON_UI) #include "button_ui/btn_sw_led.h" -#endif - -#if defined(HAS_TOUCH_UI) -#include "touch/touch.h" -#endif class App; @@ -72,6 +62,25 @@ class NetConnectingState : public State { void handle_ui_event(UserInput::Event event); const char* get_name() override { return "NetConnectingState"; } + + private: + uint32_t start_time_ = 0; +}; + +// Battery-mode burst window. Entered once the first press has been +// delivered; keeps Wi-Fi and the TLS session open so a run of presses +// shares one association and one handshake. Exits to shutdown after +// SESSION_IDLE_TIMEOUT with no user input. +class SessionState : public State { + public: + using State::State; + + void entry() override; + void exit() override; + void loop() override; + void handle_ui_event(UserInput::Event event); + + const char* get_name() override { return "SessionState"; } }; class InfoScreenState : public State { @@ -153,10 +162,10 @@ class FactoryResetState : public State { using AppStateMachine = StateMachine< App, AppSMStates::InitState, AppSMStates::AwakeModeIdleState, AppSMStates::SleepModeHandleInput, AppSMStates::NetConnectingState, - AppSMStates::InfoScreenState, AppSMStates::SettingsMenuState, - AppSMStates::DeviceInfoState, AppSMStates::CmdShutdownState, - AppSMStates::NetDisconnectingState, AppSMStates::ShuttingDownState, - AppSMStates::FactoryResetState>; + AppSMStates::SessionState, AppSMStates::InfoScreenState, + AppSMStates::SettingsMenuState, AppSMStates::DeviceInfoState, + AppSMStates::CmdShutdownState, AppSMStates::NetDisconnectingState, + AppSMStates::ShuttingDownState, AppSMStates::FactoryResetState>; class App : public AppStateMachine, public Logger { public: @@ -165,22 +174,17 @@ class App : public AppStateMachine, public Logger { void setup(); private: -#if defined(HAS_SLEEP_MODE) void _start_esp_sleep(); void _go_to_sleep(); -#endif void _sleep_or_restart(); std::pair _determine_boot_cause(); void _log_task_stats(); - void _publish_system_state(); static void _ui_task(void* app); void _start_ui_task(); -#if defined(HAS_DISPLAY) static void _display_task(void* app); void _start_display_task(); -#endif static void _network_task(void* app); void _start_network_task(); @@ -194,22 +198,17 @@ class App : public AppStateMachine, public Logger { void _start_tasks(); void _handle_ui_event_global(UserInput::Event event); - void _publish_ui_event(UserInput::Event event); - void _mqtt_callback(const char* topic, const char* payload); void _net_on_connect(); -#if defined(HAS_TH_SENSOR) - void _publish_sensors(); -#endif -#if defined(HAS_BATTERY) - void _publish_battery(); -#endif -#if defined(HAS_DISPLAY) - void _download_mdi_icons(); -#endif - -#if defined(HAS_AWAKE_MODE) - void _publish_awake_mode_avlb(); -#endif + + // Counter plumbing --------------------------------------------------- + // Returns true and fills idx/delta when btn_id is one of the four + // counter buttons; false for the two unassigned buttons. + static bool _btn_to_counter(uint8_t btn_id, uint8_t& idx, int32_t& delta); + // Applies the press locally (counter, label, redraw) and either sends it + // straight away or queues it until the network is up. + void _handle_counter_press(uint8_t btn_id); + void _refresh_counter_labels(); + void _flush_pending(); DeviceState device_state_; TaskHandle_t ui_task_h_ = nullptr; @@ -217,7 +216,6 @@ class App : public AppStateMachine, public Logger { TaskHandle_t network_task_h_ = nullptr; TaskHandle_t main_task_h_ = nullptr; -#if defined(HOME_BUTTONS_ORIGINAL) BtnSwLED b1_; BtnSwLED b2_; BtnSwLED b3_; @@ -225,55 +223,44 @@ class App : public AppStateMachine, public Logger { BtnSwLED b5_; BtnSwLED b6_; BtnSwLEDInput bsl_input_; -#elif defined(HOME_BUTTONS_MINI) - BtnSwLED b1_; - BtnSwLED b2_; - BtnSwLED b3_; - BtnSwLED b4_; - BtnSwLEDInput bsl_input_; -#elif defined(HOME_BUTTONS_INDUSTRIAL) - BtnSwLED b1_; - BtnSwLED b2_; - BtnSwLED b3_; - BtnSwLED b4_; - BtnSwLED sw_; - BtnSwLEDInput bsl_input_; -#endif - -#if defined(HAS_TOUCH_UI) - TouchInput touch_handler_; -#endif UserInput::Event user_event_ = {}; -#if defined(HAS_DISPLAY) - MDIHelper mdi_; Display display_; -#endif - TopicHelper topics_; Network network_; - MQTTHelper mqtt_; + Webhook webhook_; HardwareDefinition hw_; HBSetup setup_; + // Button callbacks run on the UI task, so a press may not block on HTTP or + // NVS there. handle_ui_event() only touches RAM and pushes onto this queue; + // the main task drains it in _flush_pending(), which owns the NVS write and + // the POST. + static constexpr uint8_t PRESS_QUEUE_SIZE = 8; + struct PressQueueElement { + Webhook::Event event; + uint32_t queued_at; + }; + QueueHandle_t press_queue_ = nullptr; + BootCause boot_cause_; uint8_t wakeup_btn_id_ = 0; - uint32_t last_sensor_publish_ = 0; uint32_t last_m_display_redraw_ = 0; uint32_t input_start_time_ = 0; + uint32_t session_last_input_time_ = 0; uint32_t info_screen_start_time_ = 0; uint32_t settings_menu_start_time_ = 0; uint32_t device_info_start_time_ = 0; uint32_t shutdown_cmd_time_ = 0; - friend class FactoryTest; friend class HBSetup; friend class AppSMStates::InitState; friend class AppSMStates::AwakeModeIdleState; friend class AppSMStates::SleepModeHandleInput; friend class AppSMStates::NetConnectingState; + friend class AppSMStates::SessionState; friend class AppSMStates::InfoScreenState; friend class AppSMStates::SettingsMenuState; friend class AppSMStates::DeviceInfoState; diff --git a/Firmware/HomeButtonsArduino/src/config.h b/Firmware/HomeButtonsArduino/src/config.h index b8b6151..f589d42 100644 --- a/Firmware/HomeButtonsArduino/src/config.h +++ b/Firmware/HomeButtonsArduino/src/config.h @@ -1,154 +1,108 @@ #ifndef HOMEBUTTONS_CONFIG_H #define HOMEBUTTONS_CONFIG_H -// #define LED_DEFAULT_FADE_TIME 100 - -#if !defined(HOME_BUTTONS_ORIGINAL) && !defined(HOME_BUTTONS_MINI) && \ - !defined(HOME_BUTTONS_PRO) && !defined(HOME_BUTTONS_INDUSTRIAL) -#error "No device defined!" +// This fork targets Home Buttons Original (model A1) only, running as a +// two-channel tally counter that reports presses to an HTTPS webhook. +// The mini, pro and industrial variants have been removed, as have the +// MQTT client, Home Assistant discovery, the temperature/humidity sensor +// and the runtime MDI icon downloader. + +#if !defined(HOME_BUTTONS_ORIGINAL) +#error "This fork builds the Original (A1) variant only." #endif -#if defined(HOME_BUTTONS_ORIGINAL) #define HAS_BUTTON_UI #define HAS_DISPLAY #define HAS_BATTERY #define HAS_CHARGER #define HAS_AWAKE_MODE #define HAS_SLEEP_MODE -#define HAS_TH_SENSOR -#endif - -#if defined(HOME_BUTTONS_MINI) -#define HAS_BUTTON_UI -#define HAS_DISPLAY -#define HAS_BATTERY -#define HAS_SLEEP_MODE -#define HAS_TH_SENSOR -#endif - -#if defined(HOME_BUTTONS_PRO) -#define HAS_TOUCH_UI -#define HAS_DISPLAY -#define HAS_FRONTLIGHT -#define HAS_TH_SENSOR -#define HAS_FRONTLIGHT -#endif - -#if defined(HOME_BUTTONS_INDUSTRIAL) -#define HAS_BUTTON_UI -#endif #include #include // ------ device ------ static constexpr char MANUFACTURER[] = "PLab"; -static constexpr char SW_VERSION[] = "v2.6.1"; - -#if defined(HOME_BUTTONS_ORIGINAL) -static constexpr char SW_MODEL_ID[] = "A1"; -#elif defined(HOME_BUTTONS_MINI) -static constexpr char SW_MODEL_ID[] = "B1"; -#elif defined(HOME_BUTTONS_PRO) -static constexpr char SW_MODEL_ID[] = "C1"; -#elif defined(HOME_BUTTONS_INDUSTRIAL) -static constexpr char SW_MODEL_ID[] = "D1"; -#endif +static constexpr char SW_VERSION[] = "v3.0.0-counter.1"; +static constexpr char SW_MODEL_ID[] = "A1"; // must match the burnt eFuse // ------ URLs ------ -#if defined(HOME_BUTTONS_ORIGINAL) -static constexpr char DOCS_LINK[] = - "https://docs.home-buttons.com/original/setup/"; -#elif defined(HOME_BUTTONS_MINI) -static constexpr char DOCS_LINK[] = "https://docs.home-buttons.com/mini/setup/"; -#elif defined(HOME_BUTTONS_PRO) -static constexpr char DOCS_LINK[] = "https://docs.home-buttons.com/pro/setup/"; -#elif defined(HOME_BUTTONS_INDUSTRIAL) static constexpr char DOCS_LINK[] = - "https://docs.home-buttons.com/industrial/setup/"; -#endif -static constexpr char ICON_URL_DFLT[] = "https://icons.home-buttons.com/mdi/"; + "https://github.com/sengine-cloud/HomeButtons"; // ------ wifi AP ------ -static constexpr char SETUP_AP_PASSWORD[] = "password123"; +// The setup AP password is derived per-device from the eFuse random ID, so +// there is no shared default. See DeviceState::get_ap_password(). // ------ buttons ------ -#if defined(HOME_BUTTONS_ORIGINAL) static constexpr uint8_t NUM_BUTTONS = 6; -#elif defined(HOME_BUTTONS_MINI) -static constexpr uint8_t NUM_BUTTONS = 4; -#elif defined(HOME_BUTTONS_PRO) -static constexpr uint8_t NUM_BUTTONS = 9; -#elif defined(HOME_BUTTONS_INDUSTRIAL) -static constexpr uint8_t NUM_BUTTONS = 5; -#endif -static constexpr char BTN_PRESS_PAYLOAD[] = "PRESS"; static constexpr uint8_t BTN_LABEL_MAXLEN = 56; static constexpr uint8_t USER_MSG_MAXLEN = 64; +// ------ counters ------ +// Two independent counters, each with an increment and a decrement button. +// Buttons 5 and 6 are unassigned. +static constexpr uint8_t NUM_COUNTERS = 2; +static constexpr uint8_t BTN_COUNTER_A_INC = 1; +static constexpr uint8_t BTN_COUNTER_A_DEC = 2; +static constexpr uint8_t BTN_COUNTER_B_INC = 3; +static constexpr uint8_t BTN_COUNTER_B_DEC = 4; +static constexpr int32_t COUNTER_MIN = 0; +static constexpr int32_t COUNTER_MAX = 999999; +static constexpr char COUNTER_NAMES[NUM_COUNTERS][2] = {"a", "b"}; + +// ------ webhook ------ +static constexpr size_t ENDPOINT_URL_MAXLEN = 128; +static constexpr size_t AUTH_TOKEN_MAXLEN = 128; +static constexpr uint32_t HTTP_TIMEOUT = 10000L; // ms +static constexpr uint8_t HTTP_MAX_ATTEMPTS = 3; // per press, within session +static constexpr uint16_t HTTP_PAYLOAD_SIZE = 512; // bytes + // ------ defaults ------ static constexpr char DEVICE_NAME_DFLT[] = "Home Buttons"; -static constexpr uint16_t MQTT_PORT_DFLT = 1883; -static constexpr char BASE_TOPIC_DFLT[] = "homebuttons"; -static constexpr char DISCOVERY_PREFIX_DFLT[] = "homeassistant"; static constexpr char BNT_LABEL_DFLT_PREFIX[] = "B"; -static constexpr char BTN_CONF_DFLT[] = "BBBBBBBBBBBBBBBB"; - -// ------ sensors ------ -#if defined(HOME_BUTTONS_ORIGINAL) || defined(HOME_BUTTONS_PRO) || \ - defined(HOME_BUTTONS_INDUSTRIAL) -static constexpr uint16_t SEN_INTERVAL_DFLT = 10; // min -static constexpr uint16_t SEN_INTERVAL_MIN = 1; // min -static constexpr uint16_t SEN_INTERVAL_MAX = 30; // min -#elif defined(HOME_BUTTONS_MINI) -static constexpr uint16_t SEN_INTERVAL_DFLT = 30; // min -static constexpr uint16_t SEN_INTERVAL_MIN = 5; // min -static constexpr uint16_t SEN_INTERVAL_MAX = 60; // min -#endif + +// ------ heartbeat ------ +// Timer wake used purely to report battery level when nobody presses a +// button. Stored under the legacy "sen_itv" NVS key to avoid a migration. +static constexpr uint16_t HEARTBEAT_INTERVAL_DFLT = 720; // min (12 h) +static constexpr uint16_t HEARTBEAT_INTERVAL_MIN = 15; // min +static constexpr uint16_t HEARTBEAT_INTERVAL_MAX = 1440; // min (24 h) // ----- timing ------ static constexpr uint32_t SETUP_TIMEOUT = 600; // s static constexpr uint32_t INFO_SCREEN_DISP_TIME = 15000L; // ms -static constexpr uint32_t AWAKE_SENSOR_INTERVAL = 15000L; // ms static constexpr uint32_t WDT_TIMEOUT_AWAKE = 60; // s static constexpr uint32_t WDT_TIMEOUT_SLEEP = 60; // s static constexpr uint32_t AWAKE_REDRAW_INTERVAL = 1000L; // ms static constexpr uint32_t SETTINGS_MENU_TIMEOUT = 30000L; // ms static constexpr uint32_t DEVICE_INFO_TIMEOUT = 30000L; // ms static constexpr uint32_t SHUTDOWN_DELAY = 500L; // ms -static constexpr uint32_t FRONTLIGHT_TIMEOUT = 5000L; // ms static constexpr uint32_t SLEEP_MODE_INPUT_TIMEOUT = 10000L; // ms +// How long the device stays awake with the connection open after a press, +// so a burst of presses shares one Wi-Fi association and TLS handshake. +// Reset on every button event. +static constexpr uint32_t SESSION_IDLE_TIMEOUT = 30000L; // ms + // ------ network ------ static constexpr uint32_t QUICK_WIFI_TIMEOUT = 5000L; static constexpr uint32_t WIFI_TIMEOUT = 20000L; -static constexpr uint32_t MAX_WIFI_RETRIES_DURING_MQTT_SETUP = 2; -static constexpr uint32_t MQTT_TIMEOUT = 5000L; static constexpr uint32_t NET_CONN_CHECK_INTERVAL = 1000L; static constexpr uint32_t NET_CONNECT_TIMEOUT = 30000L; static constexpr uint8_t MAX_FAILED_CONNECTIONS = 5; static const IPAddress DEFAULT_DNS2 = IPAddress(1, 1, 1, 1); -// ------ MQTT ------ -static constexpr uint16_t MQTT_PYLD_SIZE = 512; -static constexpr uint16_t MQTT_BUFFER_SIZE = 777; -static constexpr size_t MAX_TOPIC_LENGTH = 256; - // ------ other ------ static constexpr uint32_t MIN_FREE_HEAP = 10000UL; -static constexpr uint32_t SCHEDULE_WAKEUP_MIN = 5; // s -static constexpr uint32_t SCHEDULE_WAKEUP_MAX = SEN_INTERVAL_MAX * 60; // s -static constexpr uint32_t MDI_FREE_SPACE_THRESHOLD = 100000UL; -static constexpr uint16_t LED_DEFAULT_FADE_TIME = 50; // ms +static constexpr uint32_t SCHEDULE_WAKEUP_MIN = 5; // s +static constexpr uint32_t SCHEDULE_WAKEUP_MAX = + static_cast(HEARTBEAT_INTERVAL_MAX) * 60; // s +static constexpr uint16_t LED_DEFAULT_FADE_TIME = 50; // ms // ------ UI ------ -#if defined(HOME_BUTTONS_ORIGINAL) static constexpr char BATT_EMPTY_MSG[] = "Battery\nLOW\n\nPlease\nrecharge\nsoon!"; -#elif defined(HOME_BUTTONS_MINI) -static constexpr char BATT_EMPTY_MSG[] = - "Batteries\nLOW\n\nPlease\nreplace\nsoon!"; -#endif // ------ LEDs ------ static constexpr uint8_t LED_DFLT_BRIGHT = 100; // pct diff --git a/Firmware/HomeButtonsArduino/src/display/bitmaps.h b/Firmware/HomeButtonsArduino/src/display/bitmaps.h index 7a33d8f..bd242fa 100644 --- a/Firmware/HomeButtonsArduino/src/display/bitmaps.h +++ b/Firmware/HomeButtonsArduino/src/display/bitmaps.h @@ -37,51 +37,6 @@ const unsigned char hb_logo_48x48[] PROGMEM = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; -const unsigned char hb_logo_64x64[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x80, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, - 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x0f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x3f, 0xfc, 0x01, 0x00, 0x00, - 0x00, 0x00, 0xc0, 0x1f, 0xf8, 0x03, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x07, - 0xe0, 0x0f, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x03, 0xc0, 0x1f, 0x00, 0x00, - 0x00, 0x00, 0xfc, 0x01, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x7f, 0x00, - 0x00, 0xfe, 0x00, 0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0xfc, 0x01, 0x00, - 0x00, 0xe0, 0x0f, 0x00, 0x00, 0xf0, 0x07, 0x00, 0x00, 0xf0, 0x07, 0x00, - 0x00, 0xe0, 0x0f, 0x00, 0x00, 0xf8, 0x01, 0x00, 0x00, 0x80, 0x1f, 0x00, - 0x00, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x3f, 0x00, 0x00, 0x7c, 0x00, 0x00, - 0x00, 0x00, 0x3e, 0x00, 0x00, 0x3c, 0x00, 0x00, 0x00, 0x00, 0x3c, 0x00, - 0x00, 0x3c, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x1c, 0x00, 0x00, - 0x00, 0x00, 0x3f, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0xc0, 0x3f, 0x00, - 0x00, 0x1c, 0x00, 0x00, 0x00, 0xe0, 0x3f, 0x00, 0x00, 0x1c, 0x00, 0x00, - 0x00, 0xf8, 0x3f, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0xfc, 0x3f, 0x00, - 0x00, 0x1c, 0x00, 0x00, 0x00, 0xfe, 0x3f, 0x00, 0x00, 0x1c, 0x00, 0x00, - 0x80, 0xff, 0x3f, 0x00, 0x00, 0x1c, 0x00, 0x00, 0xc0, 0xff, 0x3f, 0x00, - 0x00, 0x1c, 0x00, 0x00, 0xf0, 0xff, 0x3f, 0x00, 0x00, 0x1c, 0x00, 0x00, - 0xf8, 0xff, 0x3f, 0x00, 0x00, 0x1c, 0x00, 0x00, 0xfe, 0xff, 0x3f, 0x00, - 0x00, 0x1c, 0x00, 0x00, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x1c, 0x00, 0xc0, - 0xff, 0xff, 0x3f, 0x00, 0x00, 0x1c, 0x00, 0xe0, 0xff, 0xff, 0x3f, 0x00, - 0x00, 0x1c, 0x00, 0xf8, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x1c, 0x00, 0xfc, - 0xff, 0xff, 0x3f, 0x00, 0x00, 0x1c, 0x00, 0xfe, 0xff, 0xff, 0x3f, 0x00, - 0x00, 0x1c, 0x80, 0xff, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x1c, 0xc0, 0xff, - 0xff, 0xff, 0x3f, 0x00, 0x00, 0x1c, 0xf0, 0xff, 0xff, 0xff, 0x3f, 0x00, - 0x00, 0x1c, 0xf8, 0xff, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x1c, 0xfe, 0xff, - 0xff, 0xff, 0x3f, 0x00, 0x00, 0x3c, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, - 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0x00, 0xfc, 0xff, 0xff, - 0xff, 0xff, 0x3f, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, - 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0x00, 0xfc, 0xff, 0xff, - 0xff, 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - const unsigned char account_cog_64x64[] PROGMEM = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -127,117 +82,6 @@ const unsigned char account_cog_64x64[] PROGMEM = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; -const unsigned char account_cog_100x100[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x7f, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, - 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, - 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xc0, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xe0, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xf0, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xf0, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0x01, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0x03, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, - 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, - 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, - 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xfe, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xfe, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0x01, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, 0xff, 0x01, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, 0xff, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, 0xff, - 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, - 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, - 0xff, 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xc0, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x80, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, - 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xc0, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xe0, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xe0, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xe0, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x18, 0xe0, 0xff, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x78, 0xf8, 0xff, 0x87, 0x07, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xe0, 0x3f, 0x00, 0xfc, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, - 0x00, 0x00, 0xe0, 0xff, 0xff, 0x00, 0xfe, 0xff, 0xff, 0xff, 0x0f, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, 0x00, 0xfe, 0xff, 0xff, 0xff, 0x1f, - 0x00, 0x00, 0x00, 0xc0, 0xff, 0xff, 0x7f, 0x00, 0xff, 0xff, 0xff, 0xff, - 0x3f, 0x00, 0x00, 0x00, 0xf0, 0xff, 0xff, 0x7f, 0x00, 0xff, 0xff, 0xff, - 0xff, 0x3f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x7f, 0x80, 0xff, 0xff, - 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x7f, 0x80, 0xff, - 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0xc0, 0xff, 0xff, 0xff, 0x3f, 0x80, - 0xff, 0xff, 0xc0, 0xff, 0x7f, 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, 0x3f, - 0x00, 0xfe, 0x3f, 0x80, 0xff, 0x1f, 0x00, 0x00, 0xf0, 0xff, 0xff, 0xff, - 0x3f, 0x00, 0xfc, 0x3f, 0x00, 0xff, 0x0f, 0x00, 0x00, 0xf8, 0xff, 0xff, - 0xff, 0x3f, 0x00, 0xf8, 0x1f, 0x00, 0xfe, 0x07, 0x00, 0x00, 0xfc, 0xff, - 0xff, 0xff, 0x3f, 0x00, 0xf8, 0x1f, 0x00, 0xfe, 0x07, 0x00, 0x00, 0xfc, - 0xff, 0xff, 0xff, 0x3f, 0x00, 0xf8, 0x1f, 0x00, 0xfe, 0x07, 0x00, 0x00, - 0xfe, 0xff, 0xff, 0xff, 0x3f, 0x00, 0xf8, 0x1f, 0x00, 0xfe, 0x07, 0x00, - 0x00, 0xfe, 0xff, 0xff, 0xff, 0x3f, 0x00, 0xf8, 0x1f, 0x00, 0xfe, 0x03, - 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0xf8, 0x1f, 0x00, 0xfe, - 0x07, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0xfe, 0x3f, 0x00, - 0xff, 0x0f, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0xff, 0x7f, - 0x80, 0xff, 0x3f, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x80, 0xff, - 0xff, 0xc0, 0xff, 0x7f, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x80, - 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x7f, - 0x80, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, - 0x7f, 0x00, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0x00, 0xff, 0xff, 0xff, - 0xff, 0x7f, 0x00, 0xff, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0xff, 0xff, - 0xff, 0xff, 0xff, 0x00, 0xfe, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0xff, - 0xff, 0xff, 0xff, 0xff, 0x00, 0xfc, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xfd, 0xff, 0xef, 0x0f, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0xf8, 0xff, 0x87, 0x07, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0x01, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, - 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xe0, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xc0, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xc0, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xc0, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00}; - const unsigned char wifi_cog_64x64[] PROGMEM = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -283,117 +127,6 @@ const unsigned char wifi_cog_64x64[] PROGMEM = { 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0x00}; -const unsigned char wifi_cog_100x100[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, 0x07, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, 0xff, 0x7f, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, - 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0xc0, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x00, 0xe0, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, - 0xf8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, - 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x07, - 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x03, 0x00, 0xfc, 0xff, 0xff, 0xff, - 0x0f, 0x00, 0xc0, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x80, 0xff, 0xff, - 0xff, 0x3f, 0x00, 0xe0, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, 0xf8, - 0xff, 0xff, 0x7f, 0x00, 0xc0, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, - 0x80, 0xff, 0xff, 0x3f, 0x00, 0xc0, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xfe, 0xff, 0x3f, 0x00, 0x80, 0xff, 0xff, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xf0, 0xff, 0x1f, 0x00, 0x00, 0xff, 0x3f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0x0f, 0x00, 0x00, 0xfe, 0x0f, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x07, 0x00, 0x00, 0xfe, 0x07, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x07, 0x00, 0x00, 0xfc, - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x03, 0x00, 0x00, - 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x01, 0x00, - 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x0f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, 0xff, 0xff, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, - 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0xff, 0xff, - 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, 0xff, - 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, - 0xff, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xe0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xf0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0xff, 0x0f, 0x00, 0xff, 0xff, 0x03, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0x00, 0x00, 0xf0, 0x3f, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x1f, 0x00, 0x00, 0x80, - 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x03, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x0f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x1f, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x1f, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, - 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x7f, 0x00, 0x00, - 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x3f, 0x00, - 0x00, 0xfe, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0x1f, - 0x00, 0x01, 0xfe, 0x3f, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, - 0x1f, 0x80, 0x0f, 0xff, 0xff, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, - 0xff, 0x0f, 0x80, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xf0, 0xff, 0x0f, 0xc0, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, - 0x00, 0xf0, 0xff, 0x0f, 0xc0, 0xff, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, - 0x00, 0x00, 0xe0, 0xff, 0x07, 0xe0, 0xff, 0xff, 0xff, 0xff, 0x03, 0x00, - 0x00, 0x00, 0x00, 0xc0, 0xff, 0x07, 0xf0, 0xff, 0xff, 0xff, 0xff, 0x07, - 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0x07, 0xf0, 0xff, 0xff, 0xff, 0xff, - 0x07, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0x07, 0xf0, 0xff, 0x1f, 0xfc, - 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x03, 0xf0, 0xff, 0x07, - 0xf8, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x03, 0xc0, 0xff, - 0x03, 0xf0, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x80, - 0xff, 0x03, 0xe0, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, - 0x00, 0xff, 0x01, 0xe0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, - 0x03, 0x00, 0xfe, 0x01, 0xe0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xf0, 0x03, 0x00, 0xfe, 0x01, 0xe0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xe0, 0x03, 0x00, 0xfe, 0x01, 0xe0, 0x7f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xe0, 0x03, 0x00, 0xfe, 0x01, 0xe0, 0x7f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xc0, 0x03, 0x80, 0xff, 0x03, 0xf0, 0xff, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x80, 0x03, 0xc0, 0xff, 0x07, 0xf0, 0xff, 0x01, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xe0, 0xff, 0x0f, 0xfc, 0xff, - 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xf0, 0xff, 0xff, 0xff, - 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0xf0, 0xff, 0xff, - 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, - 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, - 0xff, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xe0, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xc0, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x80, 0x8f, 0xff, 0x7f, 0x78, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xfe, 0x3f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x1f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x1f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x1f, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x1f, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, - 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xf8, 0x0f, 0x00, 0x00}; - const unsigned char restore_64x64[] PROGMEM = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -439,117 +172,6 @@ const unsigned char restore_64x64[] PROGMEM = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; -const unsigned char restore_100x100[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0x7f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x03, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, 0x1f, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, - 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, - 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, - 0xff, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, - 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, - 0xff, 0xff, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xc0, 0xff, 0xff, 0x00, 0xf0, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xe0, 0xff, 0x0f, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xf0, 0xff, 0x03, 0x00, 0x00, 0xfc, 0xff, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xf8, 0xff, 0x00, 0x00, 0x00, 0xf0, 0xff, 0x03, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f, 0x00, 0x00, 0x00, 0xc0, 0xff, 0x07, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x1f, 0x00, 0x00, 0x00, 0x80, 0xff, - 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, - 0xfe, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0x03, 0x00, 0x00, 0x00, - 0x00, 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0x01, 0x00, 0x00, - 0x00, 0x00, 0xf8, 0x3f, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xf0, 0x3f, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xe0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x7f, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0xf0, - 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0x00, 0x00, 0x00, 0x00, - 0xf0, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0x00, 0x00, 0x00, - 0x00, 0xf0, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0x01, 0x00, - 0x00, 0x00, 0xf8, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x01, - 0x00, 0x00, 0x00, 0xf8, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x03, 0x00, 0x00, 0x00, 0xfc, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xfe, 0x03, 0x00, 0x00, 0x00, 0xfc, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xfe, 0x03, 0x00, 0x00, 0x00, 0xfc, 0x07, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfc, 0x07, 0x00, 0x00, 0x00, 0xfe, 0x03, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xfc, 0x07, 0x00, 0x00, 0x00, 0xfe, 0x03, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0xfe, 0x03, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0xfe, - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, - 0xfe, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, - 0x00, 0xfe, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x0f, 0x00, - 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x0f, - 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, - 0x0f, 0x00, 0xe0, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xf8, 0x0f, 0x00, 0xc0, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xf8, 0x0f, 0x00, 0x80, 0xff, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xf8, 0x0f, 0x00, 0x00, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0xfe, 0xff, 0xff, 0x01, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0xf8, 0xff, 0x7f, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0xf0, 0xff, - 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x07, 0x00, 0x00, 0xe0, - 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x07, 0x00, 0x00, - 0xc0, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x03, 0x00, - 0x00, 0x80, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x03, - 0x00, 0x00, 0x00, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x03, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xfe, 0x03, 0x00, 0x00, 0x00, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xff, 0x01, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x80, 0xff, 0x01, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x80, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xc0, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xe0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x3f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x1f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x0f, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x80, 0x1f, 0x00, 0x00, 0x00, 0x80, 0xff, 0x07, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x3f, 0x00, 0x00, 0x00, 0xc0, 0xff, - 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0x00, 0x00, 0x00, 0xf0, - 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, 0x03, 0x00, 0x00, - 0xfc, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0x0f, 0x00, - 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0xff, - 0x00, 0xf0, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, - 0xff, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xf8, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x03, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0x7f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00}; - const unsigned char close_64x64[] PROGMEM = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -595,117 +217,6 @@ const unsigned char close_64x64[] PROGMEM = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; -const unsigned char close_100x100[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x0e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x80, 0x0f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x1f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xc0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x3f, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x7f, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0x01, 0x00, 0x00, 0x00, 0xf8, - 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0x03, 0x00, 0x00, 0x00, - 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x07, 0x00, 0x00, - 0x00, 0xfe, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x0f, 0x00, - 0x00, 0x00, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x1f, - 0x00, 0x00, 0x80, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, - 0x3f, 0x00, 0x00, 0xc0, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xf0, 0x7f, 0x00, 0x00, 0xe0, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xe0, 0xff, 0x00, 0x00, 0xf0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xc0, 0xff, 0x01, 0x00, 0xf8, 0x3f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x80, 0xff, 0x03, 0x00, 0xfc, 0x1f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x07, 0x00, 0xfe, 0x0f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x0f, 0x00, 0xff, 0x07, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x1f, 0x80, 0xff, 0x03, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0xc0, 0xff, - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x7f, 0xe0, - 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, - 0xf0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, - 0xff, 0xf9, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x80, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0x01, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0x03, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, - 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, - 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, - 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xc0, 0xff, 0xf9, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xe0, 0xff, 0xf0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xf0, 0x7f, 0xe0, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xf8, 0x3f, 0xc0, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xfc, 0x1f, 0x80, 0xff, 0x03, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x0f, 0x00, 0xff, 0x07, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x07, 0x00, 0xfe, 0x0f, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0x03, 0x00, 0xfc, 0x1f, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0x01, 0x00, 0xf8, - 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0x00, 0x00, - 0xf0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x7f, 0x00, - 0x00, 0xe0, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x3f, - 0x00, 0x00, 0xc0, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, - 0x1f, 0x00, 0x00, 0x80, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xfe, 0x0f, 0x00, 0x00, 0x00, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xff, 0x07, 0x00, 0x00, 0x00, 0xfe, 0x0f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x80, 0xff, 0x03, 0x00, 0x00, 0x00, 0xfc, 0x1f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xc0, 0xff, 0x01, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xe0, 0xff, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x7f, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xc0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x3f, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x00, 0xc0, - 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00, 0x00, - 0x80, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00}; - const unsigned char file_question_outline_64x64[] PROGMEM = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -777,301 +288,6 @@ const unsigned char file_question_outline_48x48[] PROGMEM = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; -const unsigned char file_question_outline_100x100[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, - 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, - 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xf8, 0xff, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0x03, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, - 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xc0, - 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, - 0xc0, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, - 0x00, 0xc0, 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, - 0x00, 0x00, 0xc0, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x01, 0x00, 0x00, 0xc0, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xfe, 0x01, 0x00, 0x00, 0xc0, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xfe, 0x01, 0x00, 0x00, 0xc0, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xc0, 0xff, 0xff, 0x07, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xc0, 0xff, 0xff, 0x0f, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xc0, 0xff, 0xff, 0x1f, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xc0, 0xff, 0xff, - 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xc0, 0xff, - 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xc0, - 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, - 0xc0, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, - 0x00, 0xc0, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, - 0x00, 0x00, 0xc0, 0xff, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x01, 0x00, 0x00, 0xc0, 0xff, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, - 0xfe, 0x01, 0x00, 0x00, 0xc0, 0xff, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, - 0x00, 0xfe, 0x01, 0x00, 0x00, 0xc0, 0xff, 0xff, 0xff, 0x07, 0x00, 0x00, - 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xc0, 0xff, 0xff, 0xff, 0x07, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, 0x07, - 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, - 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0xc0, - 0x3f, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, - 0xf0, 0xff, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, - 0x00, 0xfc, 0xff, 0x03, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x01, 0x00, 0xfe, 0xff, 0x07, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, - 0xfe, 0x01, 0x00, 0xff, 0xff, 0x0f, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, - 0x00, 0xfe, 0x01, 0x80, 0xff, 0xff, 0x1f, 0x00, 0xf8, 0x07, 0x00, 0x00, - 0x00, 0x00, 0xfe, 0x01, 0x80, 0x7f, 0xe0, 0x1f, 0x00, 0xf8, 0x07, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0x01, 0xc0, 0x3f, 0xc0, 0x3f, 0x00, 0xf8, 0x07, - 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0xc0, 0x1f, 0x80, 0x3f, 0x00, 0xf8, - 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0xc0, 0x0f, 0x00, 0x3f, 0x00, - 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0xc0, 0x0f, 0x00, 0x3f, - 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0xc0, 0x0f, 0x00, - 0x3f, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, - 0x00, 0x3f, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, - 0x00, 0x00, 0x3f, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, - 0x00, 0x00, 0x00, 0x3f, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x01, 0x00, 0x00, 0x80, 0x3f, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, - 0xfe, 0x01, 0x00, 0x00, 0xc0, 0x1f, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, - 0x00, 0xfe, 0x01, 0x00, 0x00, 0xe0, 0x1f, 0x00, 0xf8, 0x07, 0x00, 0x00, - 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xf0, 0x0f, 0x00, 0xf8, 0x07, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xfc, 0x07, 0x00, 0xf8, 0x07, - 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xfe, 0x03, 0x00, 0xf8, - 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xfe, 0x00, 0x00, - 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0xff, 0x00, - 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0x7f, - 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x80, - 0x3f, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, - 0x80, 0x3f, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, - 0x00, 0x80, 0x1f, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x01, 0x00, 0x80, 0x1f, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, - 0xfe, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, - 0x00, 0xfe, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, - 0x00, 0x00, 0xfe, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x80, 0x1f, 0x00, 0x00, 0xf8, 0x07, - 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x80, 0x1f, 0x00, 0x00, 0xf8, - 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x80, 0x1f, 0x00, 0x00, - 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x80, 0x1f, 0x00, - 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x80, 0x1f, - 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, 0x80, - 0x1f, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x01, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x00, - 0xfe, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x07, 0x00, 0x00, 0x00, - 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x07, 0x00, 0x00, - 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x07, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x07, - 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0x07, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00}; - -const unsigned char file_question_outline_92x92[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xf0, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x80, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0x7f, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0x01, 0x00, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0x03, 0x00, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0x07, 0x00, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0x0f, 0x00, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0x1f, 0x00, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0x3f, 0x00, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0x7f, 0x00, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x00, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x01, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x03, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x07, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x0f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x0f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xf0, 0x00, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xfe, 0x07, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x80, 0xff, 0x1f, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0xc0, 0xff, 0x3f, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0xe0, 0xff, 0x7f, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0xf0, 0xff, 0xff, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0xf0, 0x0f, 0xff, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0xf8, 0x03, 0xfc, 0x01, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0xf8, 0x01, 0xf8, 0x01, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0xf8, 0x01, 0xf8, 0x01, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0xf8, 0x00, 0xf0, 0x01, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0xf8, 0x00, 0xf0, 0x01, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xf0, 0x01, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xf8, 0x01, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xf8, 0x01, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfc, 0x01, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0xfe, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0x7f, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x80, 0x3f, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xc0, 0x1f, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xe0, 0x0f, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xf0, 0x07, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xf0, 0x03, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xf8, 0x01, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xf8, 0x01, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xf8, 0x01, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xf8, 0x01, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xf8, 0x01, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xf8, 0x01, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xf8, 0x01, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xf8, 0x01, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0xf8, 0x01, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x1f, 0x00, 0x00, - 0x00, 0x80, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1f, 0x00, 0x00, - 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, - 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, - 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00, 0x00, - 0x00, 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x07, 0x00, 0x00, - 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, - 0x00, 0x00, 0xf0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - -const unsigned char thermometer_64x64[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x03, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xf0, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, - 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xfc, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, - 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, - 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, - 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, - 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, - 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, - 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, - 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, - 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0x01, 0x00, 0x00, - 0x00, 0x00, 0xc0, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, - 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, 0x07, 0x00, 0x00, - 0x00, 0x00, 0xf0, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, - 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, 0x1f, 0x00, 0x00, - 0x00, 0x00, 0xf8, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, - 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, 0x1f, 0x00, 0x00, - 0x00, 0x00, 0xf8, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, - 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, 0x1f, 0x00, 0x00, - 0x00, 0x00, 0xf8, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, - 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, 0xff, 0x0f, 0x00, 0x00, - 0x00, 0x00, 0xf0, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, - 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, 0x07, 0x00, 0x00, - 0x00, 0x00, 0xc0, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, - 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x7f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xf8, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, - 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - -const unsigned char water_percent_64x64[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x80, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, - 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x03, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xe0, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, - 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x1f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xf8, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, - 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x7f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, - 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0x01, 0x00, 0x00, - 0x00, 0x00, 0xc0, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, - 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, 0x07, 0x00, 0x00, - 0x00, 0x00, 0xe0, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, - 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, 0x1f, 0x00, 0x00, - 0x00, 0x00, 0xf8, 0xf0, 0xbf, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x7c, 0xe0, - 0x1f, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x7c, 0xe0, 0x0f, 0x3e, 0x00, 0x00, - 0x00, 0x00, 0x7c, 0xe0, 0x07, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x7e, 0xe0, - 0x83, 0x7f, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xf0, 0xc1, 0x7f, 0x00, 0x00, - 0x00, 0x00, 0xfe, 0xff, 0xe0, 0x7f, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, - 0xf0, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0x3f, 0xf8, 0xff, 0x00, 0x00, - 0x00, 0x00, 0xff, 0x1f, 0xfc, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0x0f, - 0xfe, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0x07, 0xff, 0xff, 0x00, 0x00, - 0x00, 0x00, 0xff, 0x83, 0x0f, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xc1, - 0x07, 0xfe, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xe0, 0x07, 0x7e, 0x00, 0x00, - 0x00, 0x00, 0x7e, 0xf0, 0x07, 0x7e, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xf8, - 0x07, 0x7e, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xfd, 0x0f, 0x3f, 0x00, 0x00, - 0x00, 0x00, 0xfc, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, - 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, 0xff, 0x0f, 0x00, 0x00, - 0x00, 0x00, 0xe0, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, - 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, - 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - const unsigned char battery_64x64[] PROGMEM = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -1117,62 +333,4 @@ const unsigned char battery_64x64[] PROGMEM = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; -const unsigned char cog_64x64[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x7f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x7f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0xff, - 0xff, 0x00, 0x08, 0x00, 0x00, 0xf8, 0xc0, 0xff, 0xff, 0x03, 0x1f, 0x00, - 0x00, 0xf8, 0xe3, 0xff, 0xff, 0xc7, 0x1f, 0x00, 0x00, 0xfc, 0xff, 0xff, - 0xff, 0xff, 0x3f, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, - 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, - 0x80, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0x80, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0x01, 0xc0, 0xff, 0xff, 0x0f, 0xf0, 0xff, 0xff, 0x03, - 0xc0, 0xff, 0xff, 0x03, 0xc0, 0xff, 0xff, 0x03, 0x80, 0xff, 0xff, 0x01, - 0x80, 0xff, 0xff, 0x01, 0x00, 0xfe, 0xff, 0x00, 0x00, 0xff, 0x7f, 0x00, - 0x00, 0xfc, 0xff, 0x00, 0x00, 0xff, 0x3f, 0x00, 0x00, 0xf8, 0x7f, 0x00, - 0x00, 0xfe, 0x1f, 0x00, 0x00, 0xf0, 0x7f, 0x00, 0x00, 0xfe, 0x0f, 0x00, - 0x00, 0xf0, 0x7f, 0x00, 0x00, 0xfe, 0x0f, 0x00, 0x00, 0xf0, 0x7f, 0x00, - 0x00, 0xfe, 0x0f, 0x00, 0x00, 0xf0, 0x7f, 0x00, 0x00, 0xfe, 0x0f, 0x00, - 0x00, 0xf0, 0x7f, 0x00, 0x00, 0xfe, 0x0f, 0x00, 0x00, 0xf0, 0x7f, 0x00, - 0x00, 0xfe, 0x0f, 0x00, 0x00, 0xf8, 0x7f, 0x00, 0x00, 0xfe, 0x1f, 0x00, - 0x00, 0xfc, 0xff, 0x00, 0x00, 0xff, 0x3f, 0x00, 0x00, 0xff, 0xff, 0x00, - 0x00, 0xff, 0xff, 0x00, 0x80, 0xff, 0xff, 0x01, 0x80, 0xff, 0xff, 0x01, - 0xc0, 0xff, 0xff, 0x03, 0xc0, 0xff, 0xff, 0x03, 0xc0, 0xff, 0xff, 0x0f, - 0xf0, 0xff, 0xff, 0x03, 0x80, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, - 0x80, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, - 0x00, 0xfe, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0xfc, 0xff, 0xff, - 0xff, 0xff, 0x3f, 0x00, 0x00, 0xfc, 0xff, 0xff, 0xff, 0xff, 0x3f, 0x00, - 0x00, 0xf8, 0xe3, 0xff, 0xff, 0xc7, 0x1f, 0x00, 0x00, 0xf8, 0xc0, 0xff, - 0xff, 0x03, 0x1f, 0x00, 0x00, 0x10, 0x00, 0xff, 0xff, 0x00, 0x08, 0x00, - 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x7f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, - 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x7f, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xfe, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, - 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - -const unsigned char chevron_up_32x32[] PROGMEM = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x01, 0x00, - 0x00, 0xc0, 0x03, 0x00, 0x00, 0xe0, 0x07, 0x00, 0x00, 0xf0, 0x0f, 0x00, - 0x00, 0x78, 0x1e, 0x00, 0x00, 0x3c, 0x3c, 0x00, 0x00, 0x1e, 0x78, 0x00, - 0x00, 0x0f, 0xf0, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - #endif // HOMEBUTTONS_BITMAPS_H \ No newline at end of file diff --git a/Firmware/HomeButtonsArduino/src/display/display.cpp b/Firmware/HomeButtonsArduino/src/display/display.cpp index 7b6deee..253b160 100644 --- a/Firmware/HomeButtonsArduino/src/display/display.cpp +++ b/Firmware/HomeButtonsArduino/src/display/display.cpp @@ -9,19 +9,13 @@ #include "config.h" #include "hardware.h" -#if defined(HOME_BUTTONS_ORIGINAL) static constexpr uint16_t ROTATION = 0; static constexpr uint16_t WIDTH = 128; static constexpr uint16_t HEIGHT = 296; -#elif defined(HOME_BUTTONS_MINI) -static constexpr uint16_t ROTATION = 0; -static constexpr int WIDTH = 200; -constexpr int HEIGHT = 200; -#elif defined(HOME_BUTTONS_PRO) -static constexpr uint16_t ROTATION = 0; -static constexpr uint16_t WIDTH = 400; -static constexpr uint16_t HEIGHT = 300; -#endif + +// Pre-loaded icons live at /mdi//.bmp in SPIFFS. Longest path is +// "/mdi/" + 3-digit size + "/" + a 48-char MDIName + ".bmp" = 61 chars. +using MDIPath = StaticString<80>; uint16_t read16(File &f) { // BMP data is stored little-endian, same as Arduino. @@ -74,17 +68,28 @@ ButtonLabel Display::get_text(ButtonLabel label) { } } +// Shrinks label one character at a time, keeping a trailing ".", until it is +// narrower than max_width. The length() > 1 bound is load bearing: without it +// a max_width smaller than a single glyph underflows length() - 2 to SIZE_MAX, +// substring() then returns the whole string unchanged and the loop spins +// forever. A label that still does not fit at one character is returned as is. ButtonLabel Display::trim_text(ButtonLabel label, uint16_t max_width) { - uint16_t w = u8g2.getUTF8Width(label.c_str()); - while (w > max_width) { + while (u8g2.getUTF8Width(label.c_str()) >= max_width && label.length() > 1) { label = label.substring(0, label.length() - 2) + "."; - w = u8g2.getUTF8Width(label.c_str()); } return label; } void Display::begin(HardwareDefinition &HW) { if (state != State::IDLE) return; + // Icons are flashed into SPIFFS, so the mount is permanent for the lifetime + // of the process - it must not be torn down between draws. + if (!spiffs_mounted_) { + spiffs_mounted_ = SPIFFS.begin(true); + if (!spiffs_mounted_) { + error("SPIFFS mount failed, icons will not be available"); + } + } disp = new GxEPD2_DISPLAY_CLASS( GxEPD2_DRIVER_CLASS(/*CS=*/HW.EINK_CS, /*DC=*/HW.EINK_DC, @@ -298,49 +303,6 @@ void Display::draw_message(const UIState::MessageType &message, bool error, disp->fillScreen(bg_color); -#if defined(HOME_BUTTONS_ORIGINAL) - if (!error) { - if (!large) { - u8g2.setFont(u8g2_font_courR12_tr); - u8g2.setCursor(0, 20); - } else { - u8g2.setFont(u8g2_font_helvB18_te); - u8g2.setCursor(0, 30); - } - u8g2.print(message.c_str()); - } else { - u8g2.setFont(u8g2_font_helvB12_tr); - const char *text = "ERROR"; - uint16_t w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 20); - u8g2.print(text); - u8g2.setFont(u8g2_font_courR12_tr); - u8g2.setCursor(0, 60); - u8g2.print(message.c_str()); - } - -#elif defined(HOME_BUTTONS_MINI) - if (!error) { - if (!large) { - u8g2.setFont(u8g2_font_courR18_tf); - u8g2.setCursor(0, 20); - } else { - u8g2.setFont(u8g2_font_helvB24_tr); - u8g2.setCursor(0, 30); - } - u8g2.print(message.c_str()); - } else { - u8g2.setFont(u8g2_font_helvB18_tr); - const char *text = "ERROR"; - uint16_t w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 30); - u8g2.print(text); - u8g2.setFont(u8g2_font_courR18_tf); - u8g2.setCursor(0, 70); - u8g2.print(message.c_str()); - } - -#elif defined(HOME_BUTTONS_PRO) if (!error) { if (!large) { u8g2.setFont(u8g2_font_courR12_tr); @@ -360,7 +322,6 @@ void Display::draw_message(const UIState::MessageType &message, bool error, u8g2.setCursor(0, 60); u8g2.print(message.c_str()); } -#endif disp->display(); } @@ -375,7 +336,6 @@ void Display::draw_main() { disp->fillScreen(bg_color); -#if defined(HOME_BUTTONS_ORIGINAL) const uint16_t min_btn_clearance = 14; const uint16_t h_padding = 5; @@ -384,7 +344,6 @@ void Display::draw_main() { disp->fillRect(12, HEIGHT - 3, WIDTH - 24, 3, text_color); } - mdi_.begin(); LabelType label_type[NUM_BUTTONS] = {}; for (uint16_t i = 0; i < NUM_BUTTONS; i++) { ButtonLabel label = device_state_.get_btn_label(i + 1); @@ -404,19 +363,19 @@ void Display::draw_main() { if (i % 2 == 0) { if (label_type[i + 1] == LabelType::Text || label_type[i + 1] == LabelType::Mixed) { - size = 48; + size = MDI_SIZE_SMALL; small = true; } else { - size = 64; + size = MDI_SIZE_LARGE; small = false; } } else { if (label_type[i - 1] == LabelType::Text || label_type[i - 1] == LabelType::Mixed) { - size = 48; + size = MDI_SIZE_SMALL; small = true; } else { - size = 64; + size = MDI_SIZE_LARGE; small = false; } } @@ -450,7 +409,7 @@ void Display::draw_main() { } else if (label_type[i] == LabelType::Mixed) { MDIName icon = get_mdi_name(label); ButtonLabel text = get_text(label); - uint16_t icon_size = 48; + uint16_t icon_size = MDI_SIZE_SMALL; uint16_t x = i % 2 == 0 ? 0 : WIDTH - icon_size; uint16_t y = static_cast(round(HEIGHT / 12. + i * HEIGHT / 6.)) - @@ -468,24 +427,13 @@ void Display::draw_main() { } uint16_t w, h; w = u8g2.getUTF8Width(text.c_str()); - h = u8g2.getFontAscent(); if (w >= max_text_width) { u8g2.setFont(u8g2_font_helvB18_te); + // trim_text() is bounded, so this always terminates + text = trim_text(text, max_text_width); w = u8g2.getUTF8Width(text.c_str()); - h = u8g2.getFontAscent(); - if (w >= max_text_width) { - text = text.substring(0, text.length() - 1) + "."; - while (1) { - w = u8g2.getUTF8Width(text.c_str()); - h = u8g2.getFontAscent(); - if (w >= max_text_width) { - text = text.substring(0, text.length() - 2) + "."; - } else { - break; - } - } - } } + h = u8g2.getFontAscent(); x = i % 2 == 0 ? icon_size + h_padding : WIDTH - icon_size - w - h_padding; y = static_cast(round(HEIGHT / 12. + i * HEIGHT / 6.)) + h / 2; @@ -502,24 +450,13 @@ void Display::draw_main() { } uint16_t w, h; w = u8g2.getUTF8Width(label.c_str()); - h = u8g2.getFontAscent(); if (w >= max_label_width) { u8g2.setFont(u8g2_font_helvB18_te); + // trim_text() is bounded, so this always terminates + label = trim_text(label, max_label_width); w = u8g2.getUTF8Width(label.c_str()); - h = u8g2.getFontAscent(); - if (w >= max_label_width) { - label = label.substring(0, label.length() - 1) + "."; - while (1) { - w = u8g2.getUTF8Width(label.c_str()); - h = u8g2.getFontAscent(); - if (w >= max_label_width) { - label = label.substring(0, label.length() - 2) + "."; - } else { - break; - } - } - } } + h = u8g2.getFontAscent(); int16_t x, y; if (i % 2 == 0) { x = h_padding; @@ -531,45 +468,6 @@ void Display::draw_main() { u8g2.print(label.c_str()); } } - mdi_.end(); - -#elif defined(HOME_BUTTONS_MINI) - mdi_.begin(); - // Loop through buttons - for (uint16_t i = 0; i < NUM_BUTTONS; i++) { - ButtonLabel label = device_state_.get_btn_label(i + 1); - uint16_t size = 100; - uint16_t x = i % 2 == 0 ? 0 : WIDTH - size; - uint16_t y = i < 2 ? 0 : HEIGHT - size; - MDIName icon = get_mdi_name(label); - draw_mdi(icon.c_str(), size, x, y); - } - mdi_.end(); - -#elif defined(HOME_BUTTONS_PRO) - uint16_t tile_width = 132; - uint16_t tile_height = 100; - for (uint16_t i = 0; i < NUM_BUTTONS; i++) { - ButtonLabel label = device_state_.get_btn_label(i + 1); - - ButtonTile tile = {}; - tile.label_type = get_label_type(label); - tile.text = get_text(label); - tile.mdi_name = get_mdi_name(label); - tile.width = tile_width; - tile.height = tile_height; - - int16_t x = 2 + i % 3 * tile.width; - int16_t y = i / 3 * tile.height; - tile.draw(*this, x, y, text_color); - } - // grid - disp->drawFastHLine(0, tile_height, WIDTH, text_color); - disp->drawFastHLine(0, 2 * tile_height, WIDTH, text_color); - disp->drawFastVLine(133, 0, HEIGHT, text_color); - disp->drawFastVLine(266, 0, HEIGHT, text_color); - -#endif disp->display(); } @@ -585,40 +483,16 @@ void Display::draw_info() { disp->fillScreen(bg_color); UIState::MessageType text; - -#if defined(HOME_BUTTONS_ORIGINAL) uint16_t w; - text = "- Temp -"; - u8g2.setFont(u8g2_font_courR12_tr); - w = u8g2.getUTF8Width(text.c_str()); - u8g2.setCursor(WIDTH / 2 - w / 2, 30); - u8g2.print(text.c_str()); - - text = UIState::MessageType("%.1f %s", device_state_.sensors().temperature, - device_state_.get_temp_unit().c_str()); - u8g2.setFont(u8g2_font_helvB24_te); - w = u8g2.getUTF8Width(text.c_str()); - u8g2.setCursor(WIDTH / 2 - w / 2 - 2, 70); - u8g2.print(text.c_str()); - - text = "- Humd -"; + // Battery is the only sensor left, so it gets the whole page. + text = "- Battery -"; u8g2.setFont(u8g2_font_courR12_tr); w = u8g2.getUTF8Width(text.c_str()); - u8g2.setCursor(WIDTH / 2 - w / 2, 129); - u8g2.print(text.c_str()); - - text = UIState::MessageType("%.0f %%", device_state_.sensors().humidity); - u8g2.setFont(u8g2_font_helvB24_te); - w = u8g2.getUTF8Width(text.c_str()); - u8g2.setCursor(WIDTH / 2 - w / 2 - 2, 169); + u8g2.setCursor(WIDTH / 2 - w / 2, 60); u8g2.print(text.c_str()); - text = "- Batt -"; - u8g2.setFont(u8g2_font_courR12_tr); - w = u8g2.getUTF8Width(text.c_str()); - u8g2.setCursor(WIDTH / 2 - w / 2, 228); - u8g2.print(text.c_str()); + disp->drawXBitmap(WIDTH / 2 - 64 / 2, 80, battery_64x64, 64, 64, text_color); if (device_state_.sensors().battery_present) { text = UIState::MessageType("%d %%", device_state_.sensors().battery_pct); @@ -627,81 +501,32 @@ void Display::draw_info() { } u8g2.setFont(u8g2_font_helvB24_te); w = u8g2.getUTF8Width(text.c_str()); - u8g2.setCursor(WIDTH / 2 - w / 2 - 2, 268); - u8g2.print(text.c_str()); - -#elif defined(HOME_BUTTONS_MINI) - u8g2.setFont(u8g2_font_helvB24_tr); - - disp->drawXBitmap(5, 4, thermometer_64x64, 64, 64, text_color); - text = UIState::MessageType("%.1f %s", device_state_.sensors().temperature, - device_state_.get_temp_unit().c_str()); - u8g2.setCursor(85, 50); - u8g2.print(text.c_str()); - - disp->drawXBitmap(5, 68, water_percent_64x64, 64, 64, text_color); - text = UIState::MessageType("%.0f %%", device_state_.sensors().humidity); - u8g2.setCursor(85, 116); - u8g2.print(text.c_str()); - - disp->drawXBitmap(5, 132, battery_64x64, 64, 64, text_color); - text = UIState::MessageType("%d %%", device_state_.sensors().battery_pct); - u8g2.setCursor(85, 180); + u8g2.setCursor(WIDTH / 2 - w / 2 - 2, 190); u8g2.print(text.c_str()); -#elif defined(HOME_BUTTONS_PRO) - u8g2.setFont(u8g2_font_helvB24_tr); - - disp->drawXBitmap(100, 30, thermometer_64x64, 64, 64, text_color); - text = UIState::MessageType("%.1f %s", device_state_.sensors().temperature, - device_state_.get_temp_unit().c_str()); - int8_t ascent = u8g2.getFontAscent(); - u8g2.setCursor(180, 30 + 64 / 2 + ascent / 2); - u8g2.print(text.c_str()); - - disp->drawXBitmap(100, 110, water_percent_64x64, 64, 64, text_color); - text = UIState::MessageType("%.0f %%", device_state_.sensors().humidity); - u8g2.setCursor(180, 110 + 64 / 2 + ascent / 2); - u8g2.print(text.c_str()); - - disp->drawLine(20, 200, 380, 200, text_color); - - // device info - disp->drawXBitmap(6, 210, hb_logo_64x64, 64, 64, text_color); - - u8g2.setFont(u8g2_font_profont12_tr); - - u8g2.setCursor(75, 220); - u8g2.print(device_state_.device_name().c_str()); - - UIState::MessageType sw_ver = UIState::MessageType("SW: ") + SW_VERSION; - u8g2.setCursor(75, 232); - u8g2.print(sw_ver.c_str()); - - UIState::MessageType model_info = UIState::MessageType("Model: ") + - device_state_.factory().model_id.c_str() + - " rev " + - device_state_.factory().hw_version.c_str(); - u8g2.setCursor(75, 244); - u8g2.print(model_info.c_str()); - - u8g2.setCursor(75, 256); - u8g2.print(device_state_.factory().unique_id.c_str()); - - UIState::MessageType ip_info = - UIState::MessageType("IP: %s", device_state_.ip()); - u8g2.setCursor(75, 268); - u8g2.print(ip_info.c_str()); - - // settings icon - disp->drawXBitmap(WIDTH - 70, HEIGHT - 90, cog_64x64, 64, 64, text_color); - u8g2.setCursor(357, 285); - u8g2.print("5s"); + if (device_state_.sensors().battery_present) { + text = UIState::MessageType("%.2f V", + device_state_.sensors().battery_voltage); + u8g2.setFont(u8g2_font_courR12_tr); + w = u8g2.getUTF8Width(text.c_str()); + u8g2.setCursor(WIDTH / 2 - w / 2, 220); + u8g2.print(text.c_str()); + } - // up chevron - disp->drawXBitmap(WIDTH / 2 - 32 / 2, HEIGHT - 28, chevron_up_32x32, 32, 32, - text_color); -#endif + const char *status = nullptr; + if (device_state_.sensors().charging) { + status = "Charging"; + } else if (device_state_.sensors().dc_connected) { + status = "DC power"; + } else if (device_state_.sensors().battery_low) { + status = "LOW - recharge"; + } + if (status != nullptr) { + u8g2.setFont(u8g2_font_courR12_tr); + w = u8g2.getUTF8Width(status); + u8g2.setCursor(WIDTH / 2 - w / 2, 250); + u8g2.print(status); + } disp->display(); } @@ -716,7 +541,6 @@ void Display::draw_device_info() { disp->fillScreen(bg_color); -#if defined(HOME_BUTTONS_ORIGINAL) disp->drawXBitmap(40, 0, hb_logo_48x48, 48, 48, text_color); u8g2.setFont(u8g2_font_profont12_tr); @@ -753,71 +577,6 @@ void Display::draw_device_info() { u8g2.setCursor(0, 152); u8g2.print(batt_volt.c_str()); -#elif defined(HOME_BUTTONS_MINI) - disp->drawXBitmap(76, 0, hb_logo_48x48, 48, 48, text_color); - - u8g2.setFont(u8g2_font_profont17_tr); - - u8g2.setCursor(0, 70); - u8g2.print(device_state_.device_name().c_str()); - - UIState::MessageType sw_ver = UIState::MessageType("SW: ") + SW_VERSION; - u8g2.setCursor(0, 90); - u8g2.print(sw_ver.c_str()); - - UIState::MessageType model_info = UIState::MessageType("Model: ") + - device_state_.factory().model_id.c_str() + - " rev " + - device_state_.factory().hw_version.c_str(); - u8g2.setCursor(0, 110); - u8g2.print(model_info.c_str()); - - u8g2.setCursor(0, 130); - u8g2.print(device_state_.factory().unique_id.c_str()); - - UIState::MessageType ip_info = - UIState::MessageType("IP: %s", device_state_.ip()); - u8g2.setCursor(0, 160); - u8g2.print(ip_info.c_str()); - - UIState::MessageType batt_volt = UIState::MessageType( - "Battery: %.2f V", device_state_.sensors().battery_voltage); - u8g2.setCursor(0, 180); - u8g2.print(batt_volt.c_str()); - -#elif defined(HOME_BUTTONS_PRO) - disp->drawXBitmap(76, 0, hb_logo_48x48, 48, 48, text_color); - - u8g2.setFont(u8g2_font_profont17_tr); - - u8g2.setCursor(0, 70); - u8g2.print(device_state_.device_name().c_str()); - - UIState::MessageType sw_ver = UIState::MessageType("SW: ") + SW_VERSION; - u8g2.setCursor(0, 90); - u8g2.print(sw_ver.c_str()); - - UIState::MessageType model_info = UIState::MessageType("Model: ") + - device_state_.factory().model_id.c_str() + - " rev " + - device_state_.factory().hw_version.c_str(); - u8g2.setCursor(0, 110); - u8g2.print(model_info.c_str()); - - u8g2.setCursor(0, 130); - u8g2.print(device_state_.factory().unique_id.c_str()); - - UIState::MessageType ip_info = - UIState::MessageType("IP: %s", device_state_.ip()); - u8g2.setCursor(0, 160); - u8g2.print(ip_info.c_str()); - - UIState::MessageType batt_volt = UIState::MessageType( - "Battery: %.2f V", device_state_.sensors().battery_voltage); - u8g2.setCursor(0, 180); - u8g2.print(batt_volt.c_str()); -#endif - disp->display(); } @@ -831,7 +590,6 @@ void Display::draw_welcome() { disp->fillScreen(bg_color); -#if defined(HOME_BUTTONS_ORIGINAL) uint16_t w; const char *text = "Home Buttons"; u8g2.setFont(u8g2_font_helvB12_tr); @@ -885,87 +643,6 @@ void Display::draw_welcome() { u8g2.setCursor(0, 294); u8g2.print(device_state_.factory().unique_id.c_str()); -#elif defined(HOME_BUTTONS_MINI) - uint8_t version = 8; // 49x49px - QRCode qrcode; - uint8_t qrcodeData[qrcode_getBufferSize(version)]; - qrcode_initText(&qrcode, qrcodeData, version, ECC_HIGH, DOCS_LINK); - uint16_t qr_x = 2; - uint16_t qr_y = 2; - for (uint8_t y2 = 0; y2 < qrcode.size; y2++) { - // Each horizontal module - for (uint8_t x2 = 0; x2 < qrcode.size; x2++) { - // Display each module - if (qrcode_getModule(&qrcode, x2, y2)) { - disp->fillRect(qr_x + x2 * 4, qr_y + y2 * 4, 4, 4, GxEPD_BLACK); - } - } - } - disp->fillRect(66, 66, 68, 68, GxEPD_WHITE); - disp->drawXBitmap(68, 68, hb_logo_64x64, 64, 64, GxEPD_BLACK); - - disp->fillRect(34, 186, 132, 14, GxEPD_WHITE); - u8g2.setFont(u8g2_font_profont17_tr); - const char *text = device_state_.factory().serial_number.c_str(); - uint16_t w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 198); - u8g2.print(text); - -#elif defined(HOME_BUTTONS_PRO) - uint16_t w; - const char *text = "Home Buttons"; - u8g2.setFont(u8g2_font_helvB12_tr); - w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 40); - u8g2.print(text); - - disp->drawXBitmap(52, 52, hb_logo_24x24, 24, 24, GxEPD_BLACK); - - text = "------------------------"; - u8g2.setFont(u8g2_font_helvB12_tr); - w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 102); - u8g2.print(text); - - uint8_t version = 6; // 41x41px - QRCode qrcode; - uint8_t qrcodeData[qrcode_getBufferSize(version)]; - qrcode_initText(&qrcode, qrcodeData, version, ECC_HIGH, DOCS_LINK); - - text = "Setup guide:"; - u8g2.setFont(u8g2_font_courR12_tr); - w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 145); - u8g2.print(text); - - uint16_t qr_x = 23; - uint16_t qr_y = 165; - for (uint8_t y2 = 0; y2 < qrcode.size; y2++) { - // Each horizontal module - for (uint8_t x2 = 0; x2 < qrcode.size; x2++) { - // Display each module - if (qrcode_getModule(&qrcode, x2, y2)) { - disp->drawRect(qr_x + x2 * 2, qr_y + y2 * 2, 2, 2, GxEPD_BLACK); - } - } - } - - u8g2.setFont(u8g2_font_profont12_tr); - UIState::MessageType sw_ver = UIState::MessageType("SW: ") + SW_VERSION; - u8g2.setCursor(0, 272); - u8g2.print(sw_ver.c_str()); - - UIState::MessageType model_info = UIState::MessageType("Model: ") + - device_state_.factory().model_id.c_str() + - " rev " + - device_state_.factory().hw_version.c_str(); - u8g2.setCursor(0, 283); - u8g2.print(model_info.c_str()); - - u8g2.setCursor(0, 294); - u8g2.print(device_state_.factory().unique_id.c_str()); -#endif - disp->display(); } @@ -979,7 +656,6 @@ void Display::draw_settings() { disp->fillScreen(bg_color); -#if defined(HOME_BUTTONS_ORIGINAL) disp->drawXBitmap(0, 17, account_cog_64x64, 64, 64, text_color); disp->drawXBitmap(WIDTH / 2, 17, wifi_cog_64x64, 64, 64, text_color); disp->drawXBitmap(0, 116, restore_64x64, 64, 64, text_color); @@ -1006,39 +682,6 @@ void Display::draw_settings() { u8g2.setCursor(0, 294); u8g2.print(device_state_.factory().unique_id.c_str()); -#elif defined(HOME_BUTTONS_MINI) - disp->drawXBitmap(0, 0, account_cog_100x100, 100, 100, text_color); - disp->drawXBitmap(100, 0, wifi_cog_100x100, 100, 100, text_color); - disp->drawXBitmap(0, 100, restore_100x100, 100, 100, text_color); - disp->drawXBitmap(100, 100, close_100x100, 100, 100, text_color); - -#elif defined(HOME_BUTTONS_PRO) - uint16_t x_icon = 310; - uint16_t x_text = 25; - u8g2.setFont(u8g2_font_helvB18_te); - int8_t ascent = u8g2.getFontAscent(); - - disp->drawXBitmap(x_icon, 5, account_cog_64x64, 64, 64, text_color); - u8g2.setCursor(x_text, 5 + 64 / 2 + ascent / 2); - u8g2.print("Setup"); - disp->drawFastHLine(0, 74, WIDTH, text_color); - - disp->drawXBitmap(x_icon, 79, wifi_cog_64x64, 64, 64, text_color); - u8g2.setCursor(x_text, 79 + 64 / 2 + ascent / 2); - u8g2.print("Wi-Fi Setup"); - disp->drawFastHLine(0, 149, WIDTH, text_color); - - disp->drawXBitmap(x_icon, 154, restore_64x64, 64, 64, text_color); - u8g2.setCursor(x_text, 154 + 64 / 2 + ascent / 2); - u8g2.print("Restart"); - disp->drawFastHLine(0, 224, WIDTH, text_color); - - disp->drawXBitmap(x_icon, 229, close_64x64, 64, 64, text_color); - u8g2.setCursor(x_text, 229 + 64 / 2 + ascent / 2); - u8g2.print("Exit"); - -#endif - disp->display(); } @@ -1057,83 +700,6 @@ void Display::draw_ap_config() { disp->fillScreen(bg_color); -#if defined(HOME_BUTTONS_ORIGINAL) - uint8_t version = 6; // 41x41px - QRCode qrcode; - uint8_t qrcodeData[qrcode_getBufferSize(version)]; - qrcode_initText(&qrcode, qrcodeData, version, ECC_HIGH, contents.c_str()); - - u8g2.setFont(u8g2_font_courR12_tr); - - uint16_t w; - const char *text = "Scan:"; - w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 20); - u8g2.print(text); - - uint16_t qr_x = 23; - uint16_t qr_y = 35; - for (uint8_t y2 = 0; y2 < qrcode.size; y2++) { - // Each horizontal module - for (uint8_t x2 = 0; x2 < qrcode.size; x2++) { - // Display each module - if (qrcode_getModule(&qrcode, x2, y2)) { - disp->drawRect(qr_x + x2 * 2, qr_y + y2 * 2, 2, 2, GxEPD_BLACK); - } - } - } - text = "--------- or ---------"; - u8g2.setFont(u8g2_font_helvB12_tr); - w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 153); - u8g2.print(text); - - text = "Connect to:"; - u8g2.setFont(u8g2_font_courR12_tr); - w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 190); - u8g2.print(text); - - u8g2.setCursor(0, 220); - u8g2.print("Wi-Fi:"); - u8g2.setFont(u8g2_font_helvB12_tr); - u8g2.setCursor(0, 235); - u8g2.print(device_state_.get_ap_ssid().c_str()); - - u8g2.setFont(u8g2_font_courR12_tr); - u8g2.setCursor(0, 260); - u8g2.print("Password:"); - u8g2.setFont(u8g2_font_helvB12_tr); - u8g2.setCursor(0, 275); - u8g2.print(device_state_.get_ap_password()); - -#elif defined(HOME_BUTTONS_MINI) - uint8_t version = 8; // 49x49px - QRCode qrcode; - uint8_t qrcodeData[qrcode_getBufferSize(version)]; - qrcode_initText(&qrcode, qrcodeData, version, ECC_HIGH, contents.c_str()); - uint16_t qr_x = 2; - uint16_t qr_y = 2; - for (uint8_t y2 = 0; y2 < qrcode.size; y2++) { - // Each horizontal module - for (uint8_t x2 = 0; x2 < qrcode.size; x2++) { - // Display each module - if (qrcode_getModule(&qrcode, x2, y2)) { - disp->fillRect(qr_x + x2 * 4, qr_y + y2 * 4, 4, 4, GxEPD_BLACK); - } - } - } - disp->fillRect(66, 66, 68, 68, GxEPD_WHITE); - disp->drawXBitmap(68, 68, wifi_cog_64x64, 64, 64, GxEPD_BLACK); - - disp->fillRect(34, 186, 132, 14, GxEPD_WHITE); - u8g2.setFont(u8g2_font_profont17_tr); - const char *text = device_state_.get_ap_ssid().c_str(); - uint16_t w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 198); - u8g2.print(text); - -#elif defined(HOME_BUTTONS_PRO) uint8_t version = 6; // 41x41px QRCode qrcode; uint8_t qrcodeData[qrcode_getBufferSize(version)]; @@ -1182,7 +748,6 @@ void Display::draw_ap_config() { u8g2.setFont(u8g2_font_helvB12_tr); u8g2.setCursor(0, 275); u8g2.print(device_state_.get_ap_password()); -#endif disp->display(); } @@ -1200,7 +765,6 @@ void Display::draw_web_config() { disp->fillScreen(bg_color); -#if defined(HOME_BUTTONS_ORIGINAL) uint8_t version = 6; // 41x41px QRCode qrcode; uint8_t qrcodeData[qrcode_getBufferSize(version)]; @@ -1244,77 +808,6 @@ void Display::draw_web_config() { u8g2.setCursor(0, 260); u8g2.print(device_state_.ip()); -#elif defined(HOME_BUTTONS_MINI) - uint8_t version = 8; // 49x49px - QRCode qrcode; - uint8_t qrcodeData[qrcode_getBufferSize(version)]; - qrcode_initText(&qrcode, qrcodeData, version, ECC_HIGH, contents.c_str()); - uint16_t qr_x = 2; - uint16_t qr_y = 2; - for (uint8_t y2 = 0; y2 < qrcode.size; y2++) { - // Each horizontal module - for (uint8_t x2 = 0; x2 < qrcode.size; x2++) { - // Display each module - if (qrcode_getModule(&qrcode, x2, y2)) { - disp->fillRect(qr_x + x2 * 4, qr_y + y2 * 4, 4, 4, GxEPD_BLACK); - } - } - } - disp->fillRect(66, 66, 68, 68, GxEPD_WHITE); - disp->drawXBitmap(68, 68, account_cog_64x64, 64, 64, GxEPD_BLACK); - - disp->fillRect(34, 186, 132, 14, GxEPD_WHITE); - u8g2.setFont(u8g2_font_profont17_tr); - const char *text = device_state_.ip(); - uint16_t w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 198); - u8g2.print(text); - -#elif defined(HOME_BUTTONS_PRO) - uint8_t version = 6; // 41x41px - QRCode qrcode; - uint8_t qrcodeData[qrcode_getBufferSize(version)]; - qrcode_initText(&qrcode, qrcodeData, version, ECC_HIGH, contents.c_str()); - - u8g2.setFont(u8g2_font_courR12_tr); - - uint16_t w; - const char *text = "Scan:"; - w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 20); - u8g2.print(text); - - uint16_t qr_x = 23; - uint16_t qr_y = 35; - - for (uint8_t y2 = 0; y2 < qrcode.size; y2++) { - // Each horizontal module - for (uint8_t x2 = 0; x2 < qrcode.size; x2++) { - // Display each module - if (qrcode_getModule(&qrcode, x2, y2)) { - disp->drawRect(qr_x + x2 * 2, qr_y + y2 * 2, 2, 2, GxEPD_BLACK); - } - } - } - text = "--------- or ---------"; - u8g2.setFont(u8g2_font_helvB12_tr); - w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 153); - u8g2.print(text); - - text = "Go to:"; - u8g2.setFont(u8g2_font_courR12_tr); - w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 200); - u8g2.print(text); - - u8g2.setFont(u8g2_font_helvB12_tr); - u8g2.setCursor(0, 240); - u8g2.print("http://"); - u8g2.setCursor(0, 260); - u8g2.print(device_state_.ip()); -#endif - disp->display(); } @@ -1333,30 +826,12 @@ void Display::draw_test(const char *text, const char *mdi_name, disp->fillScreen(bg); -#if defined(HOME_BUTTONS_ORIGINAL) - mdi_.begin(); draw_mdi(mdi_name, mdi_size, WIDTH / 2 - mdi_size / 2, 50); - mdi_.end(); u8g2.setFont(u8g2_font_helvB24_te); uint16_t w = u8g2.getUTF8Width(text); u8g2.setCursor(WIDTH / 2 - w / 2, 250); u8g2.print(text); -#elif defined(HOME_BUTTONS_MINI) - mdi_.begin(); - draw_mdi(mdi_name, mdi_size, WIDTH / 2 - mdi_size / 2, 20); - mdi_.end(); - - u8g2.setFont(u8g2_font_helvB24_te); - uint16_t w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 175); - u8g2.print(text); -#elif defined(HOME_BUTTONS_PRO) - u8g2.setFont(u8g2_font_helvB24_te); - uint16_t w = u8g2.getUTF8Width(text); - u8g2.setCursor(WIDTH / 2 - w / 2, 250); - u8g2.print(text); -#endif disp->display(); } @@ -1532,15 +1007,26 @@ bool Display::draw_bmp(File &file, int16_t x, int16_t y) { return valid; } +// Icons are pre-loaded into SPIFFS at flash time, so this is a plain read at +// the path layout the old downloader used. SPIFFS is mounted once in begin() +// and stays mounted - do not unmount here, the icons are read on every draw. +File Display::open_mdi_file(const char *name, uint16_t size) { + if (name == nullptr || name[0] == '\0') return File{}; + if (!spiffs_mounted_) { + error("SPIFFS not mounted, cannot open icon: %s", name); + return File{}; + } + MDIPath path("/mdi/%u/%s.bmp", static_cast(size), name); + return SPIFFS.open(path.c_str(), FILE_READ); +} + void Display::draw_mdi(const char *name, uint16_t size, int16_t x, int16_t y) { bool draw_placeholder = false; - File file; - if (mdi_.exists(name, size)) { - File file = mdi_.get_file(name, size); + File file = open_mdi_file(name, size); + if (file) { + // draw_bmp() closes the file if (!draw_bmp(file, x, y)) { error("Could not draw icon: %s", name); - // file might be corrupted - remove so it will be downloaded again - mdi_.remove(name, size); draw_placeholder = true; } } else { @@ -1548,78 +1034,10 @@ void Display::draw_mdi(const char *name, uint16_t size, int16_t x, int16_t y) { draw_placeholder = true; } if (draw_placeholder) { - if (size == 64) { + if (size == MDI_SIZE_LARGE) { disp->drawXBitmap(x, y, file_question_outline_64x64, 64, 64, text_color); - } else if (size == 48) { + } else if (size == MDI_SIZE_SMALL) { disp->drawXBitmap(x, y, file_question_outline_48x48, 48, 48, text_color); - } else if (size == 100) { - disp->drawXBitmap(x, y, file_question_outline_100x100, 100, 100, - text_color); - } else if (size == 92) { - disp->drawXBitmap(x, y, file_question_outline_92x92, 92, 92, text_color); } } - // disp->drawRect(x, y, size, size, text_color); } - -void ButtonTile::draw(Display &display, int16_t x, int16_t y, uint16_t color) { - // display.disp->drawRect(x, y, width, height, color); - switch (label_type) { - case LabelType::Icon: { - uint16_t mdi_size = 92; - if (width < mdi_size || height < mdi_size) { - display.error("ButtonTile::draw: Tile height too low"); - return; - } - int16_t icon_x = x + (width - mdi_size) / 2; - int16_t icon_y = y + (height - mdi_size) / 2; - display.mdi_.begin(); - display.draw_mdi(mdi_name.c_str(), mdi_size, icon_x, icon_y); - display.mdi_.end(); - break; - } - case LabelType::Mixed: { - uint16_t mdi_size = 64; - - display.u8g2.setFont(u8g2_font_helvB18_te); - uint16_t h_padding = 4; - uint16_t v_padding = 4; - int8_t ascent = display.u8g2.getFontAscent(); - int8_t descent = display.u8g2.getFontDescent(); - display.debug("Font ascent: %d, descent: %d", ascent, descent); - if (mdi_size + ascent - descent + v_padding > height) { - display.error("ButtonTile::draw: Tile height too low"); - return; - } - int16_t icon_x = x + (width - mdi_size) / 2; - int16_t icon_y = y + v_padding; - display.mdi_.begin(); - display.draw_mdi(mdi_name.c_str(), mdi_size, icon_x, icon_y); - display.mdi_.end(); - - text = display.trim_text(text, width - 2 * h_padding); - uint16_t text_width = display.u8g2.getUTF8Width(text.c_str()); - display.u8g2.setCursor(x + width / 2 - text_width / 2, - y + height - v_padding + descent); - display.u8g2.print(text.c_str()); - break; - } - case LabelType::Text: { - uint16_t h_padding = 4; - display.u8g2.setFont(u8g2_font_helvB24_te); - uint16_t text_width = display.u8g2.getUTF8Width(text.c_str()); - if (text_width >= width - 2 * h_padding) { - display.u8g2.setFont(u8g2_font_helvB18_te); - text = display.trim_text(text, width - 2 * h_padding); - } - text_width = display.u8g2.getUTF8Width(text.c_str()); - int8_t ascent = display.u8g2.getFontAscent(); - display.u8g2.setCursor(x + width / 2 - text_width / 2, - y + height / 2 + ascent / 2); - display.u8g2.print(text.c_str()); - break; - } - default: - break; - } -} \ No newline at end of file diff --git a/Firmware/HomeButtonsArduino/src/display/display.h b/Firmware/HomeButtonsArduino/src/display/display.h index 87e75d2..59b0054 100644 --- a/Firmware/HomeButtonsArduino/src/display/display.h +++ b/Firmware/HomeButtonsArduino/src/display/display.h @@ -1,6 +1,7 @@ #ifndef HOMEBUTTONS_DISPLAY_H #define HOMEBUTTONS_DISPLAY_H +#include #include #include #include @@ -8,27 +9,24 @@ #include "static_string.h" #include "state.h" #include "logger.h" -#include "mdi/mdi_helper.h" #include "types.h" // parameters for draw_bmp() static constexpr uint16_t input_buffer_pixels = 800; static constexpr uint16_t max_palette_pixels = 256; +// Icons are pre-loaded into SPIFFS at flash time under /mdi//.bmp. +// Only the two sizes the Original layout uses are shipped. +static constexpr uint16_t MDI_SIZE_LARGE = 64; +static constexpr uint16_t MDI_SIZE_SMALL = 48; + struct HardwareDefinition; class DeviceState; // display init stuff #define GxEPD2_DISPLAY_CLASS GxEPD2_BW - -#if defined(HOME_BUTTONS_ORIGINAL) -#define GxEPD2_DRIVER_CLASS GxEPD2_290_T94_V2 -#elif defined(HOME_BUTTONS_MINI) -#define GxEPD2_DRIVER_CLASS GxEPD2_154_D67 -#elif defined(HOME_BUTTONS_PRO) -#define GxEPD2_DRIVER_CLASS GxEPD2_420_GDEY042T91 -#endif +#define GxEPD2_DRIVER_CLASS GxEPD2_290_T94_V2 // 128x296 #define MAX_DISPLAY_BUFFER_SIZE 65536ul // e.g. #define MAX_HEIGHT(EPD) \ @@ -37,12 +35,10 @@ class DeviceState; : MAX_DISPLAY_BUFFER_SIZE / (EPD::WIDTH / 8)) class Display : public Logger { - friend class ButtonTile; - public: enum class State { IDLE, ACTIVE, CMD_END, ENDING }; - explicit Display(const DeviceState& device_state, MDIHelper& mdi_helper) - : Logger("Display"), device_state_(device_state), mdi_(mdi_helper) {} + explicit Display(DeviceState& device_state) + : Logger("Display"), device_state_(device_state) {} void begin(HardwareDefinition& HW); void end(); void update(); @@ -74,12 +70,12 @@ class Display : public Logger { bool new_ui_cmd = false; bool redraw_in_progress = false; + bool spiffs_mounted_ = false; uint16_t text_color = GxEPD_BLACK; uint16_t bg_color = GxEPD_WHITE; - const DeviceState& device_state_; - MDIHelper& mdi_; + DeviceState& device_state_; GxEPD2_DISPLAY_CLASS* disp; @@ -117,17 +113,10 @@ class Display : public Logger { void draw_white(); void draw_black(); bool draw_bmp(File& file, int16_t x, int16_t y); + // Opens the pre-loaded icon at /mdi//.bmp. The returned File is + // falsy when the icon was not flashed. SPIFFS must already be mounted. + File open_mdi_file(const char* name, uint16_t size); void draw_mdi(const char* name, uint16_t size, int16_t x, int16_t y); }; -struct ButtonTile { - LabelType label_type = LabelType::None; - ButtonLabel text{}; - MDIName mdi_name{}; - uint16_t width = 0; - uint16_t height = 0; - - void draw(Display& display, int16_t x, int16_t y, uint16_t color); -}; - #endif // HOMEBUTTONS_DISPLAY_H diff --git a/Firmware/HomeButtonsArduino/src/factory.cpp b/Firmware/HomeButtonsArduino/src/factory.cpp deleted file mode 100644 index 343128f..0000000 --- a/Firmware/HomeButtonsArduino/src/factory.cpp +++ /dev/null @@ -1,275 +0,0 @@ -#include "factory.h" - -#include "config.h" -#include "static_string.h" - -#include -#include - -#include -#include - -static constexpr char FAC_TEST_BASE_TOPIC[] = "homebuttons-factory/devices"; -static constexpr uint16_t TEST_ICON_SIZE = 100; -static constexpr uint32_t BUTTON_TEST_TIMEOUT = 60000L; -static constexpr std::array TEST_SPEC_KEYS = { - "temp_ref", "temp_tol", "humd_ref", "humd_tol", - "batt_mvolt_ref", "batt_mvolt_tol", "mdi_name", "disp_text"}; - -void FactoryTest::_mqtt_callback(const char* topic, uint8_t* payload, - uint32_t length) { - StaticJsonDocument<512> doc; - DeserializationError json_error = deserializeJson(doc, payload); - - if (json_error != DeserializationError::Ok) { - error("Failed to parse JSON: %s", json_error.c_str()); - return; - } - - if (!doc.containsKey("parameters")) { - error("Missing parameters"); - return; - } else { - for (auto k : TEST_SPEC_KEYS) { - if (!doc["parameters"].containsKey(k)) { - error("Missing parameter %s", k); - return; - } - } - } - - JsonObject parameters = doc["parameters"].as(); - test_spec_.temp_ref = parameters["temp_ref"].as(); - test_spec_.temp_tol = parameters["temp_tol"].as(); - test_spec_.humd_ref = parameters["humd_ref"].as(); - test_spec_.humd_tol = parameters["humd_tol"].as(); - test_spec_.batt_mvolt_ref = parameters["batt_mvolt_ref"].as(); - test_spec_.batt_mvolt_tol = parameters["batt_mvolt_tol"].as(); - test_spec_.mdi_name = parameters["mdi_name"].as(); - test_spec_.disp_text = parameters["disp_text"].as(); - - test_spec_.received = true; - info("Test spec received"); -} - -bool FactoryTest::is_test_required() { - Preferences prefs; - - prefs.begin("fac_test", true); - bool do_test = prefs.getBool("do_test", false); - prefs.end(); - return do_test; -} - -bool FactoryTest::run_test() { - Preferences prefs; - FacTestParams params = {}; - - bool passed = true; - - prefs.begin("fac_test", true); - params.do_test = prefs.getBool("do_test", false); - params.wifi_ssid = prefs.getString("wifi_ssid", ""); - params.wifi_password = prefs.getString("wifi_pass", ""); - params.mqtt_server.fromString(prefs.getString("mqtt_srv", "")); - params.mqtt_port = prefs.getUInt("mqtt_port", 0); - params.mqtt_user = prefs.getString("mqtt_user", ""); - params.mqtt_password = prefs.getString("mqtt_pass", ""); - prefs.end(); - - info("Starting factory test..."); - app_._begin_hw(); - -#if defined(HAS_DISPLAY) - // format SPIFFS if needed - if (!SPIFFS.begin()) { - info("Formatting icon storage..."); - app_.display_.disp_message("Formatting\nIcon\nStorage...", 0); - app_.display_.update(); - SPIFFS.format(); - } else { - SPIFFS.end(); - debug("SPIFFS test mount OK"); - } - app_.display_.disp_message_large("FACTORY"); - app_.display_.update(); -#endif - - WiFiClient wifi_client; - PubSubClient mqtt_client(wifi_client); - mqtt_client.setCallback( - std::bind(&FactoryTest::_mqtt_callback, this, std::placeholders::_1, - std::placeholders::_2, std::placeholders::_3)); - mqtt_client.setBufferSize(1024); - - WiFi.mode(WIFI_STA); - WiFi.persistent(false); - - info("Connecting to WiFi..."); - WiFi.begin(params.wifi_ssid.c_str(), params.wifi_password.c_str()); - while (true) { - delay(100); - if (WiFi.status() == WL_CONNECTED) { - break; - } - } - info("WiFi connected. IP: %s", WiFi.localIP().toString().c_str()); - info("Connecting to MQTT..."); - info("mqtt_server %s, port %d", params.mqtt_server.toString().c_str(), - params.mqtt_port); - mqtt_client.setServer(params.mqtt_server, params.mqtt_port); - while (!mqtt_client.connected()) { - mqtt_client.connect(app_.hw_.get_unique_id(), params.mqtt_user.c_str(), - params.mqtt_password.c_str()); - delay(100); - } - info("MQTT connected"); - - StaticString<256> test_topic("%s/%s/test_start", FAC_TEST_BASE_TOPIC, - app_.hw_.get_serial_number()); - mqtt_client.subscribe(test_topic.c_str()); - - // send device discovery message - StaticJsonDocument<256> device_doc; - device_doc["serial"] = app_.hw_.get_serial_number(); - device_doc["random_id"] = app_.hw_.get_random_id(); - device_doc["model_id"] = app_.hw_.get_model_id(); - device_doc["fw_version"] = SW_VERSION; - device_doc["hw_version"] = app_.hw_.get_hw_version(); - - char buffer[512]; - serializeJson(device_doc, buffer, sizeof(buffer)); - - StaticString<256> topic("%s/%s", FAC_TEST_BASE_TOPIC, - app_.hw_.get_serial_number()); - mqtt_client.publish(topic.c_str(), buffer); - - // wait for test start message - while (!test_spec_.received) { - mqtt_client.loop(); - delay(10); - } - -#if defined(HAS_DISPLAY) - // test display - info("Testing display"); - bool display_passed = true; - app_.mdi_.begin(); - - app_.mdi_.remove(test_spec_.mdi_name.c_str(), TEST_ICON_SIZE); - - if (app_.mdi_.check_connection()) { - if (!app_.mdi_.download(test_spec_.mdi_name.c_str(), TEST_ICON_SIZE)) { - error("MDI download failed"); - display_passed = false; - } - } else { - error("MDI server connection failed"); - display_passed = false; - } - app_.mdi_.end(); - - app_.display_.disp_test(test_spec_.disp_text.c_str(), - test_spec_.mdi_name.c_str(), 100); - app_.display_.update(); - passed = passed && display_passed; -#endif - -#if defined(HAS_BUTTON_UI) - // test LEDs & buttons - info("Testing LEDs & buttons"); - bool button_passed = false; - app_.hw_.set_all_leds_pct(LED_DFLT_BRIGHT); - uint32_t start_time = millis(); - uint8_t btn_idx = 0; - while (true) { - if (millis() - start_time > BUTTON_TEST_TIMEOUT) { - error("button test timeout"); - button_passed = false; - break; - } - if (app_.hw_.button_pressed(btn_idx + 1)) { - app_.hw_.set_led_pct_num(btn_idx + 1, 0); - btn_idx++; - } - if (btn_idx >= NUM_BUTTONS) { - button_passed = true; - break; - } - } - passed = passed && button_passed; -#endif - -#if defined(HAS_TH_SENSOR) - // test sensors - info("Testing sensors"); - bool sensor_passed = true; - float temp_val; - float hmd_val; - app_.hw_.read_temp_hmd(temp_val, hmd_val); - - if (temp_val <= test_spec_.temp_ref - test_spec_.temp_tol || - temp_val >= test_spec_.temp_ref + test_spec_.temp_tol) { - sensor_passed = false; - error("temp test fail. Measured: %f, expected: %f +/- %f", temp_val, - test_spec_.temp_ref, test_spec_.temp_tol); - } - if (hmd_val <= test_spec_.humd_ref - test_spec_.humd_tol || - hmd_val >= test_spec_.humd_ref + test_spec_.humd_tol) { - sensor_passed = false; - error("humidity test fail. Measured: %f, expected: %f +/- %f", hmd_val, - test_spec_.humd_ref, test_spec_.humd_tol); - } - passed = passed && sensor_passed; -#else - float temp_val = 0; - float hmd_val = 0; -#endif -#if defined(HAS_BATTERY) - info("Testing battery"); - bool batt_passed = true; - uint16_t batt_v = app_.hw_.read_battery_voltage() * 1000.; - if (batt_v <= test_spec_.batt_mvolt_ref - test_spec_.batt_mvolt_tol || - batt_v >= test_spec_.batt_mvolt_ref + test_spec_.batt_mvolt_tol) { - batt_passed = false; - error("battery test fail. Measured: %d mV, expected: %d +/- %d mV", batt_v, - test_spec_.batt_mvolt_ref, test_spec_.batt_mvolt_tol); - } - passed = passed && batt_passed; -#else - uint16_t batt_v = 0; -#endif - - // send test results - StaticJsonDocument<1024> result_doc; - - JsonObject device = result_doc.createNestedObject("device"); - device["serial"] = app_.hw_.get_serial_number(); - device["random_id"] = app_.hw_.get_random_id(); - device["model_id"] = app_.hw_.get_model_id(); - device["fw_version"] = SW_VERSION; - device["hw_version"] = app_.hw_.get_hw_version(); - result_doc["passed"] = passed; - - JsonObject parameters = result_doc.createNestedObject("parameters"); - parameters["measured_temp"] = temp_val; - parameters["measured_humidity"] = hmd_val; - parameters["measured_battery"] = batt_v; - - serializeJson(result_doc, buffer, sizeof(buffer)); - - StaticString<256> result_topic("%s/%s/test_result", FAC_TEST_BASE_TOPIC, - app_.hw_.get_serial_number()); - mqtt_client.publish(result_topic.c_str(), buffer); - - if (passed) { - info("factory test passed"); - prefs.begin("fac_test", false); - prefs.clear(); - prefs.end(); - return true; - } else { - error("factory test failed"); - return false; - } -} diff --git a/Firmware/HomeButtonsArduino/src/factory.h b/Firmware/HomeButtonsArduino/src/factory.h deleted file mode 100644 index 2406c08..0000000 --- a/Firmware/HomeButtonsArduino/src/factory.h +++ /dev/null @@ -1,48 +0,0 @@ -#ifndef HOMEBUTTONS_FACTORY_H -#define HOMEBUTTONS_FACTORY_H - -#include - -#include "static_string.h" -#include "logger.h" -#include "hardware.h" - -#include "app.h" - -struct FacTestParams { - bool do_test; - String wifi_ssid; - String wifi_password; - IPAddress mqtt_server; - uint32_t mqtt_port; - String mqtt_user; - String mqtt_password; -}; - -struct TestSpec { - bool received; - float temp_ref; - float temp_tol; - float humd_ref; - float humd_tol; - int16_t batt_mvolt_ref; - int16_t batt_mvolt_tol; - StaticString<56> mdi_name; - StaticString<32> disp_text; -}; - -class FactoryTest : public Logger { - public: - FactoryTest(App& app) : Logger("Fac.Test"), app_(app) {} - bool is_test_required(); - bool run_test(); - - private: - TestSpec test_spec_ = {}; - - App& app_; - - void _mqtt_callback(const char* topic, uint8_t* payload, uint32_t length); -}; - -#endif // HOMEBUTTONS_FACTORY_H diff --git a/Firmware/HomeButtonsArduino/src/hardware.cpp b/Firmware/HomeButtonsArduino/src/hardware.cpp index 48b0152..23b2fb8 100644 --- a/Firmware/HomeButtonsArduino/src/hardware.cpp +++ b/Firmware/HomeButtonsArduino/src/hardware.cpp @@ -3,20 +3,12 @@ #include "esp_efuse.h" #include "esp_efuse_custom_table.h" -#include #include -#include "Adafruit_SHTC3.h" - #include "driver/ledc.h" #include -#if defined(HAS_TH_SENSOR) -// Temperature & humidity sensor -Adafruit_SHTC3 shtc3 = Adafruit_SHTC3(); -#endif - // Convert perceived brightness (0-100) to a 12-bit PWM value (0-4095) uint16_t LED_PCT2PWM(uint8_t perceived_brightness, uint16_t max_pwm_value) { // Clamp the input value to the range 0-100 @@ -58,25 +50,14 @@ bool HardwareDefinition::init() { return false; } - if (strcmp(get_model_id(), "A1") == 0) { - strncpy(model_name_, "Home Buttons", sizeof(model_name_)); - } else if (strcmp(get_model_id(), "B1") == 0) { - strncpy(model_name_, "Home Buttons Mini", sizeof(model_name_)); - } else if (strcmp(get_model_id(), "C1") == 0) { - strncpy(model_name_, "Home Buttons Pro", sizeof(model_name_)); - } else if (strcmp(get_model_id(), "D1") == 0) { - strncpy(model_name_, "Home Buttons Industrial", sizeof(model_name_)); - } else { - error("unknown model id: %s", get_model_id()); - return false; - } + strncpy(model_name_, "Home Buttons", sizeof(model_name_) - 1); + model_name_[sizeof(model_name_) - 1] = '\0'; snprintf(unique_id_, sizeof(unique_id_), "HBTNS-%s-%s", get_serial_number(), get_random_id()); auto hw_ver = get_hw_version(); -#if defined(HOME_BUTTONS_ORIGINAL) if (strcmp(hw_ver, "1.0") == 0) { load_hw_rev_1_0(); info("configured for hw version: 1.0"); @@ -102,39 +83,12 @@ bool HardwareDefinition::init() { error("HW rev %s not supported", hw_ver); return false; } -#elif defined(HOME_BUTTONS_MINI) - if (strcmp(hw_ver, "0.1") == 0) { - load_mini_hw_rev_0_1(); - info("configured for hw version: 0.1"); - } else if (strcmp(hw_ver, "1.1") == 0) { - load_mini_hw_rev_1_1(); - info("configured for hw version: 1.1"); - } else { - error("HW rev %s not supported", hw_ver); - return false; - } -#elif defined(HOME_BUTTONS_PRO) - if (strcmp(hw_ver, "0.1") == 0) { - load_pro_hw_rev_0_1(); - } else { - error("HW rev %s not supported", hw_ver); - return false; - } -#elif defined(HOME_BUTTONS_INDUSTRIAL) - if (strcmp(hw_ver, "1.0") == 0) { - load_industrial_hw_rev_1_0(); - info("configured for hw version: 1.0"); - } else { - error("HW rev %s not supported", hw_ver); - return false; - } -#endif + return true; } void HardwareDefinition::begin() { debug("hw begin"); -#if defined(HOME_BUTTONS_ORIGINAL) pinMode(BTN1_PIN, INPUT); pinMode(BTN2_PIN, INPUT); pinMode(BTN3_PIN, INPUT); @@ -170,78 +124,6 @@ void HardwareDefinition::begin() { pinMode(VBAT_ADC, INPUT); analogSetPinAttenuation(VBAT_ADC, ADC_11db); - // temp sen i2c - Wire.begin( - (int)SDA, - (int)SCL); // must be cast to int otherwise wrong begin() is called - shtc3.begin(&Wire); - -#elif defined(HOME_BUTTONS_MINI) - pinMode(BTN1_PIN, INPUT); - pinMode(BTN2_PIN, INPUT); - pinMode(BTN3_PIN, INPUT); - pinMode(BTN4_PIN, INPUT); - - ledcSetup(LED1_CH, LED_FREQ, LED_RES); - ledcAttachPin(LED1_PIN, LED1_CH); - - ledcSetup(LED2_CH, LED_FREQ, LED_RES); - ledcAttachPin(LED2_PIN, LED2_CH); - - ledcSetup(LED3_CH, LED_FREQ, LED_RES); - ledcAttachPin(LED3_PIN, LED3_CH); - - ledcSetup(LED4_CH, LED_FREQ, LED_RES); - ledcAttachPin(LED4_PIN, LED4_CH); - - // battery voltage adc - pinMode(VBAT_ADC, INPUT); - analogSetPinAttenuation(VBAT_ADC, ADC_11db); - - // temp sen i2c - Wire.begin( - (int)SDA, - (int)SCL); // must be cast to int otherwise wrong begin() is called - shtc3.begin(&Wire); - -#elif defined(HOME_BUTTONS_PRO) - pinMode(TOUCH_CLICK_PIN, INPUT); - pinMode(TOUCH_INT_PIN, INPUT); - pinMode(FL_LED_EN_PIN, OUTPUT); - ledcSetup(FL_LED_CH, LED_FREQ, LED_RES); - ledcAttachPin(FL_LED_PIN, FL_LED_CH); - - // temp sen i2c - Wire.begin( - (int)SDA, - (int)SCL); // must be cast to int otherwise wrong begin() is called - shtc3.begin(&Wire); - - // touch screen - Wire1.begin( - (int)SDA_1, - (int)SCL_1); // must be cast to int otherwise wrong begin() is called - -#elif defined(HOME_BUTTONS_INDUSTRIAL) - pinMode(BTN1_PIN, INPUT); - pinMode(BTN2_PIN, INPUT); - pinMode(BTN3_PIN, INPUT); - pinMode(BTN4_PIN, INPUT); - pinMode(BTN5_PIN, INPUT); - - ledcSetup(LED1_CH, LED_FREQ, LED_RES); - ledcAttachPin(LED1_PIN, LED1_CH); - - ledcSetup(LED2_CH, LED_FREQ, LED_RES); - ledcAttachPin(LED2_PIN, LED2_CH); - - ledcSetup(LED3_CH, LED_FREQ, LED_RES); - ledcAttachPin(LED3_PIN, LED3_CH); - - ledcSetup(LED4_CH, LED_FREQ, LED_RES); - ledcAttachPin(LED4_PIN, LED4_CH); -#endif - // enable hardware ledc fading ledc_fade_func_install(0); } @@ -249,7 +131,6 @@ void HardwareDefinition::begin() { #if defined(HAS_BUTTON_UI) uint8_t HardwareDefinition::map_button_num_sw_to_hw(uint8_t sw_num) { -#if defined(HOME_BUTTONS_ORIGINAL) switch (sw_num) { case 1: return 1; @@ -266,19 +147,6 @@ uint8_t HardwareDefinition::map_button_num_sw_to_hw(uint8_t sw_num) { default: return 0; } -#elif defined(HOME_BUTTONS_MINI) - if (sw_num >= 1 && sw_num <= 4) { - return sw_num; - } else { - return 0; - } -#elif defined(HOME_BUTTONS_INDUSTRIAL) - if (sw_num >= 1 && sw_num <= 5) { - return sw_num; - } else { - return 0; - } -#endif } uint8_t HardwareDefinition::button_pin(uint8_t id) { @@ -322,8 +190,6 @@ bool HardwareDefinition::button_pressed(uint8_t id) { } uint8_t HardwareDefinition::num_buttons_pressed() { -#if defined(HOME_BUTTONS_ORIGINAL) || defined(HOME_BUTTONS_MINI) || \ - defined(HOME_BUTTONS_PRO) uint8_t num = 0; for (uint8_t i = 1; i <= NUM_BUTTONS; i++) { if (button_pressed(i)) { @@ -331,15 +197,10 @@ uint8_t HardwareDefinition::num_buttons_pressed() { } } return num; -#elif defined(HOME_BUTTONS_INDUSTRIAL) - uint8_t num = 0; - for (uint8_t i = 1; i <= 4; i++) { - if (button_pressed(i)) { - num++; - } - } - return num; -#endif +} + +bool HardwareDefinition::any_button_pressed() { + return num_buttons_pressed() > 0; } void HardwareDefinition::set_led(uint8_t ch, uint16_t brightness, @@ -358,7 +219,6 @@ void HardwareDefinition::set_led_num(uint8_t num, uint16_t brightness, num = map_button_num_sw_to_hw(num); uint8_t ch; -#if defined(HOME_BUTTONS_ORIGINAL) switch (num) { case 1: ch = LED1_CH; @@ -381,41 +241,16 @@ void HardwareDefinition::set_led_num(uint8_t num, uint16_t brightness, default: return; } -#elif defined(HOME_BUTTONS_MINI) || defined(HOME_BUTTONS_INDUSTRIAL) - switch (num) { - case 1: - ch = LED1_CH; - break; - case 2: - ch = LED2_CH; - break; - case 3: - ch = LED3_CH; - break; - case 4: - ch = LED4_CH; - break; - default: - return; - } -#endif set_led(ch, brightness, fade_time); } void HardwareDefinition::set_all_leds(uint16_t brightness, uint16_t fade_time) { -#if defined(HOME_BUTTONS_ORIGINAL) set_led(LED1_CH, brightness, fade_time); set_led(LED2_CH, brightness, fade_time); set_led(LED3_CH, brightness, fade_time); set_led(LED4_CH, brightness, fade_time); set_led(LED5_CH, brightness, fade_time); set_led(LED6_CH, brightness, fade_time); -#elif defined(HOME_BUTTONS_MINI) || defined(HOME_BUTTONS_INDUSTRIAL) - set_led(LED1_CH, brightness, fade_time); - set_led(LED2_CH, brightness, fade_time); - set_led(LED3_CH, brightness, fade_time); - set_led(LED4_CH, brightness, fade_time); -#endif } void HardwareDefinition::set_led_pct_num(uint8_t num, uint8_t brightness_pct, @@ -438,7 +273,6 @@ float HardwareDefinition::read_battery_voltage() { } uint8_t HardwareDefinition::read_battery_percent() { -#if defined(HOME_BUTTONS_ORIGINAL) if (!is_battery_present()) return 0; float pct = BATT_SOC_EST_K * read_battery_voltage() + BATT_SOC_EST_N; if (pct < 1.0) @@ -446,18 +280,6 @@ uint8_t HardwareDefinition::read_battery_percent() { else if (pct > 100.0) pct = 100; return (uint8_t)round(pct); -#elif defined(HOME_BUTTONS_MINI) - float batvolt = read_battery_voltage(); - float pct = (BAT_SOC_EST_ATAN_A * - atan(BAT_SOC_EST_ATAN_B * batvolt + BAT_SOC_EST_ATAN_C) + - BAT_SOC_EST_ATAN_D) * - 100; - if (pct < 1.0) - pct = 1; - else if (pct > 100.0) - pct = 100; - return (uint8_t)round(pct); -#endif } bool HardwareDefinition::is_battery_present() { @@ -496,46 +318,6 @@ void HardwareDefinition::enable_charger(bool enable) { } #endif -#if defined(HAS_TOUCH_UI) -bool HardwareDefinition::touch_click_pressed() { - return digitalRead(TOUCH_CLICK_PIN); -} -#endif - -#if defined(HAS_FRONTLIGHT) -void HardwareDefinition::set_frontlight(uint16_t brightness) { - // 0 - 255 - ledcWrite(FL_LED_CH, brightness); - if (brightness > 0) { - digitalWrite(FL_LED_EN_PIN, HIGH); - } else { - digitalWrite(FL_LED_EN_PIN, LOW); - } -} -#endif - -bool HardwareDefinition::any_button_pressed() { - return num_buttons_pressed() > 0; -} - -#if defined(HAS_TH_SENSOR) -void HardwareDefinition::read_temp_hmd(float &temp, float &hmd, - const bool fahrenheit) { - shtc3.reset(); - shtc3.sleep(false); - sensors_event_t humidity_event, temp_event; - shtc3.getEvent(&humidity_event, &temp_event); - shtc3.sleep(true); - if (fahrenheit) { - temp = temp_event.temperature * 1.8 + 32; - } else { - temp = temp_event.temperature; - } - hmd = humidity_event.relative_humidity; - debug("Sensor read: temp: %.2f C/F, hmd: %.2f %%", temp, hmd); -} -#endif - bool HardwareDefinition::factory_params_ok() { return factory_params_.serial_number[0] != 0 && factory_params_.random_id[0] != 0 && @@ -543,9 +325,10 @@ bool HardwareDefinition::factory_params_ok() { } bool HardwareDefinition::_efuse_burned() { - uint8_t buf[8]; + // only the first 8 bits (1 byte) of the serial number field are read + uint8_t buf[1] = {0}; ESP_ERROR_CHECK( - esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA_SERIAL_NUMBER, &buf, 8)); + esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA_SERIAL_NUMBER, buf, 8)); return buf[0] != 0; } @@ -583,14 +366,21 @@ void HardwareDefinition::_nvs_2_efuse() { Preferences preferences; preferences.begin("factory", true); bool success = true; - success = success && preferences.getString("serial_number", - factory_params_.serial_number, 64); - success = success && - preferences.getString("random_id", factory_params_.random_id, 48); - success = success && - preferences.getString("model_id", factory_params_.model_id, 16); - success = success && - preferences.getString("hw_version", factory_params_.hw_version, 24); + // maxLen must be the destination buffer size, not the eFuse field width in + // bits - Preferences only rejects values longer than maxLen. + success = + success && preferences.getString("serial_number", + factory_params_.serial_number, + sizeof(factory_params_.serial_number)); + success = success && preferences.getString("random_id", + factory_params_.random_id, + sizeof(factory_params_.random_id)); + success = success && preferences.getString("model_id", + factory_params_.model_id, + sizeof(factory_params_.model_id)); + success = + success && preferences.getString("hw_version", factory_params_.hw_version, + sizeof(factory_params_.hw_version)); if (success) { debug("read factory params from nvs: SN=%s, RID=%s, M=%s, HW=%s", factory_params_.serial_number, factory_params_.random_id, @@ -996,175 +786,3 @@ void HardwareDefinition::load_hw_rev_2_5() { // ------ PIN definitions ------ // ------ wakeup ------ WAKE_BITMASK = 0x204072; } - -void HardwareDefinition::load_pro_hw_rev_0_1() { // ------ PIN definitions - // ------ - version = semver::version{0, 1, 0}; - - TOUCH_CLICK_PIN = 5; - TOUCH_INT_PIN = 6; - TOUCH_RST_PIN = 4; - TOUCH_CLICK_ACTIVE_HIGH = true; - - FL_LED_EN_PIN = 1; - FL_LED_PIN = 2; - FL_LED_CH = 0; - FL_LED_BRIGHT_DFLT = 800; - - SDA = 10; - SCL = 11; - SDA_1 = 13; - SCL_1 = 14; - - EINK_CS = 34; - EINK_DC = 8; - EINK_RST = 9; - EINK_BUSY = 7; - - LIGHT_SEN_ADC = 3; - - // ------ LED analog parameters ------ - LED_RES = 12; - LED_FREQ = 1000; - - // ------ wakeup ------ - WAKE_BITMASK = 0x20; -} - -void HardwareDefinition::load_mini_hw_rev_0_1() { - version = semver::version{0, 1, 0}; - BTN1_PIN = 21; - BTN2_PIN = 1; - BTN3_PIN = 14; - BTN4_PIN = 4; - - BTN1_ACTIVE_HIGH = true; - BTN2_ACTIVE_HIGH = true; - BTN3_ACTIVE_HIGH = true; - BTN4_ACTIVE_HIGH = true; - - LED1_PIN = 17; - LED2_PIN = 2; - LED3_PIN = 15; - LED4_PIN = 5; - - SDA = 10; - SCL = 11; - VBAT_ADC = 3; - - EINK_CS = 34; - EINK_DC = 8; - EINK_RST = 9; - EINK_BUSY = 7; - - // ------ LED analog parameters ------ - LED1_CH = 0; - LED2_CH = 1; - LED3_CH = 2; - LED4_CH = 3; - - LED_RES = 12; - LED_FREQ = 1000; - LED_MAX_PWM = 750; - - // ------ battery reading ------“ - BATT_DIVIDER = 0.6666667; - BATT_ADC_REF_VOLT = 2.6; - MIN_BATT_VOLT = 2.15; - BATT_HYSTERESIS_VOLT = 2.25; - WARN_BATT_VOLT = 2.25; - BATT_FULL_VOLT = 3.25; - BATT_EMPTY_VOLT = 2.15; - BAT_SOC_EST_ATAN_A = 0.4294; - BAT_SOC_EST_ATAN_B = 4.8711; - BAT_SOC_EST_ATAN_C = -12.9462; - BAT_SOC_EST_ATAN_D = 0.4849; - // Measured on VARTA Industrial Pro AA Alkaline - - // ------ wakeup ------ - WAKE_BITMASK = 0x204012; -} - -void HardwareDefinition::load_mini_hw_rev_1_1() { - version = semver::version{0, 1, 0}; - BTN1_PIN = 21; - BTN2_PIN = 1; - BTN3_PIN = 14; - BTN4_PIN = 4; - - BTN1_ACTIVE_HIGH = true; - BTN2_ACTIVE_HIGH = true; - BTN3_ACTIVE_HIGH = true; - BTN4_ACTIVE_HIGH = true; - - LED1_PIN = 17; - LED2_PIN = 2; - LED3_PIN = 15; - LED4_PIN = 5; - - SDA = 10; - SCL = 11; - VBAT_ADC = 3; - - EINK_CS = 34; - EINK_DC = 8; - EINK_RST = 9; - EINK_BUSY = 7; - - // ------ LED analog parameters ------ - LED1_CH = 0; - LED2_CH = 1; - LED3_CH = 2; - LED4_CH = 3; - - LED_RES = 12; - LED_FREQ = 1000; - LED_MAX_PWM = 750; - - // ------ battery reading ------“ - BATT_DIVIDER = 0.6666667; - BATT_ADC_REF_VOLT = 2.6; - MIN_BATT_VOLT = 2.15; - BATT_HYSTERESIS_VOLT = 2.25; - WARN_BATT_VOLT = 2.25; - BATT_FULL_VOLT = 3.25; - BATT_EMPTY_VOLT = 2.15; - BAT_SOC_EST_ATAN_A = 0.4294; - BAT_SOC_EST_ATAN_B = 4.8711; - BAT_SOC_EST_ATAN_C = -12.9462; - BAT_SOC_EST_ATAN_D = 0.4849; - // Measured on VARTA Industrial Pro AA Alkaline - - // ------ wakeup ------ - WAKE_BITMASK = 0x204012; -} - -void HardwareDefinition::load_industrial_hw_rev_1_0() { - version = semver::version{0, 1, 0}; - BTN1_PIN = 6; - BTN2_PIN = 5; - BTN3_PIN = 1; - BTN4_PIN = 21; - BTN5_PIN = 14; - - BTN1_ACTIVE_HIGH = true; - BTN2_ACTIVE_HIGH = true; - BTN3_ACTIVE_HIGH = true; - BTN4_ACTIVE_HIGH = true; - BTN5_ACTIVE_HIGH = false; - - LED1_PIN = 17; - LED2_PIN = 16; - LED3_PIN = 37; - LED4_PIN = 2; - - // ------ LED analog parameters ------ - LED1_CH = 0; - LED2_CH = 1; - LED3_CH = 2; - LED4_CH = 3; - - LED_RES = 12; - LED_FREQ = 1000; - LED_MAX_PWM = 4095; -} \ No newline at end of file diff --git a/Firmware/HomeButtonsArduino/src/hardware.h b/Firmware/HomeButtonsArduino/src/hardware.h index cf4d583..f7cfe78 100644 --- a/Firmware/HomeButtonsArduino/src/hardware.h +++ b/Firmware/HomeButtonsArduino/src/hardware.h @@ -17,91 +17,73 @@ struct HardwareDefinition : public Logger { semver::version version; // ------ PIN definitions ------ - uint8_t BTN1_PIN; - uint8_t BTN2_PIN; - uint8_t BTN3_PIN; - uint8_t BTN4_PIN; - uint8_t BTN5_PIN; - uint8_t BTN6_PIN; - - bool BTN1_ACTIVE_HIGH; - bool BTN2_ACTIVE_HIGH; - bool BTN3_ACTIVE_HIGH; - bool BTN4_ACTIVE_HIGH; - bool BTN5_ACTIVE_HIGH; - bool BTN6_ACTIVE_HIGH; - - uint8_t TOUCH_CLICK_PIN; - bool TOUCH_CLICK_ACTIVE_HIGH; - uint8_t TOUCH_INT_PIN; - uint8_t TOUCH_RST_PIN; - - uint8_t LED1_PIN; - uint8_t LED2_PIN; - uint8_t LED3_PIN; - uint8_t LED4_PIN; - uint8_t LED5_PIN; - uint8_t LED6_PIN; - - uint8_t FL_LED_EN_PIN; - uint8_t FL_LED_PIN; - - uint8_t SDA; - uint8_t SCL; - uint8_t SDA_1; - uint8_t SCL_1; - uint8_t VBAT_ADC; - uint8_t CHARGER_STDBY; - uint8_t BOOST_EN; - uint8_t DC_IN_DETECT; - uint8_t CHG_ENABLE; - - uint8_t LIGHT_SEN_ADC; - - uint8_t EINK_CS; - uint8_t EINK_DC; - uint8_t EINK_RST; - uint8_t EINK_BUSY; + uint8_t BTN1_PIN = 0; + uint8_t BTN2_PIN = 0; + uint8_t BTN3_PIN = 0; + uint8_t BTN4_PIN = 0; + uint8_t BTN5_PIN = 0; + uint8_t BTN6_PIN = 0; + + bool BTN1_ACTIVE_HIGH = false; + bool BTN2_ACTIVE_HIGH = false; + bool BTN3_ACTIVE_HIGH = false; + bool BTN4_ACTIVE_HIGH = false; + bool BTN5_ACTIVE_HIGH = false; + bool BTN6_ACTIVE_HIGH = false; + + uint8_t LED1_PIN = 0; + uint8_t LED2_PIN = 0; + uint8_t LED3_PIN = 0; + uint8_t LED4_PIN = 0; + uint8_t LED5_PIN = 0; + uint8_t LED6_PIN = 0; + + // I2C pins. No I2C peripheral is populated on this build (the temperature + // & humidity sensor has been removed); kept as board documentation. + uint8_t SDA = 0; + uint8_t SCL = 0; + + uint8_t VBAT_ADC = 0; + uint8_t CHARGER_STDBY = 0; + uint8_t BOOST_EN = 0; + uint8_t DC_IN_DETECT = 0; + uint8_t CHG_ENABLE = 0; + + uint8_t EINK_CS = 0; + uint8_t EINK_DC = 0; + uint8_t EINK_RST = 0; + uint8_t EINK_BUSY = 0; // ------ LED analog parameters ------ - uint8_t LED1_CH; - uint8_t LED2_CH; - uint8_t LED3_CH; - uint8_t LED4_CH; - uint8_t LED5_CH; - uint8_t LED6_CH; + uint8_t LED1_CH = 0; + uint8_t LED2_CH = 0; + uint8_t LED3_CH = 0; + uint8_t LED4_CH = 0; + uint8_t LED5_CH = 0; + uint8_t LED6_CH = 0; - uint8_t FL_LED_CH; - uint8_t FL_LED_BRIGHT_DFLT; - - uint8_t LED_RES; - uint16_t LED_FREQ; - uint16_t LED_MAX_PWM; + uint8_t LED_RES = 0; + uint16_t LED_FREQ = 0; + uint16_t LED_MAX_PWM = 0; // ------ battery reading ------ - float BATT_DIVIDER; - float BATT_ADC_REF_VOLT; - float MIN_BATT_VOLT; - float BATT_HYSTERESIS_VOLT; - float WARN_BATT_VOLT; - float BATT_FULL_VOLT; - float BATT_EMPTY_VOLT; - float BATT_PRESENT_VOLT; - float DC_DETECT_VOLT; - float CHARGE_HYSTERESIS_VOLT; + float BATT_DIVIDER = 0; + float BATT_ADC_REF_VOLT = 0; + float MIN_BATT_VOLT = 0; + float BATT_HYSTERESIS_VOLT = 0; + float WARN_BATT_VOLT = 0; + float BATT_FULL_VOLT = 0; + float BATT_EMPTY_VOLT = 0; + float BATT_PRESENT_VOLT = 0; + float DC_DETECT_VOLT = 0; + float CHARGE_HYSTERESIS_VOLT = 0; // battery SoC linear approximation coefficients (used for lithium cells) - float BATT_SOC_EST_K; - float BATT_SOC_EST_N; - - // atan SoC approximation coefficients (used for alkaline cells) - float BAT_SOC_EST_ATAN_A; - float BAT_SOC_EST_ATAN_B; - float BAT_SOC_EST_ATAN_C; - float BAT_SOC_EST_ATAN_D; + float BATT_SOC_EST_K = 0; + float BATT_SOC_EST_N = 0; // ------ wakeup ------ - uint64_t WAKE_BITMASK; + uint64_t WAKE_BITMASK = 0; // ------ functions ------ bool init(); @@ -113,6 +95,7 @@ struct HardwareDefinition : public Logger { uint8_t button_pin(uint8_t num); bool button_pressed(uint8_t num); uint8_t num_buttons_pressed(); + bool any_button_pressed(); void set_led(uint8_t ch, uint16_t brightness, uint16_t fade_time = LED_DEFAULT_FADE_TIME); @@ -139,17 +122,6 @@ struct HardwareDefinition : public Logger { void enable_charger(bool enable); #endif -#if defined(HAS_TOUCH_UI) - bool touch_click_pressed(); -#endif - -#if defined(HAS_FRONTLIGHT) - void set_frontlight(uint8_t brightness); -#endif - - bool any_button_pressed(); - void read_temp_hmd(float &tempe, float &hmd, const bool fahrenheit = false); - const char *get_serial_number() { return factory_params_.serial_number; } const char *get_random_id() { return factory_params_.random_id; } const char *get_model_id() { return factory_params_.model_id; } @@ -181,13 +153,6 @@ struct HardwareDefinition : public Logger { void load_hw_rev_2_4(); void load_hw_rev_2_5(); - void load_pro_hw_rev_0_1(); - - void load_mini_hw_rev_0_1(); - void load_mini_hw_rev_1_1(); - - void load_industrial_hw_rev_1_0(); - private: struct { // members have length +1 for null terminator diff --git a/Firmware/HomeButtonsArduino/src/hw_tests.h b/Firmware/HomeButtonsArduino/src/hw_tests.h deleted file mode 100644 index 1049829..0000000 --- a/Firmware/HomeButtonsArduino/src/hw_tests.h +++ /dev/null @@ -1,17 +0,0 @@ -#ifndef HOMEBUTTONS_HW_TESTS_H -#define HOMEBUTTONS_HW_TESTS_H - -#include - -#include "hardware.h" -#include "types.h" - -class Logger; - -namespace hw_tests { -void blink_leds(const Logger& logger, const HWVersion& hw_version); -void led_on_button(const Logger& logger, const HWVersion& hw_version); -void wifi_stress(const Logger& logger, const HWVersion& hw_version); -} // namespace hw_tests - -#endif // HOMEBUTTONS_HW_TESTS_H diff --git a/Firmware/HomeButtonsArduino/src/mdi/certificates.h b/Firmware/HomeButtonsArduino/src/mdi/certificates.h deleted file mode 100644 index fb49337..0000000 --- a/Firmware/HomeButtonsArduino/src/mdi/certificates.h +++ /dev/null @@ -1,47 +0,0 @@ -#ifndef HOMEBUTTONS_CERTIFICATES_H -#define HOMEBUTTONS_CERTIFICATES_H - -#include - -namespace certificates { - -// ISRG Root X1 -// https://letsencrypt.org/certificates/ -// Valid until: 2030-06-04 -const char isrg_root_x1[] PROGMEM = R"CERT( ------BEGIN CERTIFICATE----- -MIIFYDCCBEigAwIBAgIQQAF3ITfU6UK47naqPGQKtzANBgkqhkiG9w0BAQsFADA/ -MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT -DkRTVCBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQwM1ow -TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh -cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwggIiMA0GCSqGSIb3DQEB -AQUAA4ICDwAwggIKAoICAQCt6CRz9BQ385ueK1coHIe+3LffOJCMbjzmV6B493XC -ov71am72AE8o295ohmxEk7axY/0UEmu/H9LqMZshftEzPLpI9d1537O4/xLxIZpL -wYqGcWlKZmZsj348cL+tKSIG8+TA5oCu4kuPt5l+lAOf00eXfJlII1PoOK5PCm+D -LtFJV4yAdLbaL9A4jXsDcCEbdfIwPPqPrt3aY6vrFk/CjhFLfs8L6P+1dy70sntK -4EwSJQxwjQMpoOFTJOwT2e4ZvxCzSow/iaNhUd6shweU9GNx7C7ib1uYgeGJXDR5 -bHbvO5BieebbpJovJsXQEOEO3tkQjhb7t/eo98flAgeYjzYIlefiN5YNNnWe+w5y -sR2bvAP5SQXYgd0FtCrWQemsAXaVCg/Y39W9Eh81LygXbNKYwagJZHduRze6zqxZ -Xmidf3LWicUGQSk+WT7dJvUkyRGnWqNMQB9GoZm1pzpRboY7nn1ypxIFeFntPlF4 -FQsDj43QLwWyPntKHEtzBRL8xurgUBN8Q5N0s8p0544fAQjQMNRbcTa0B7rBMDBc -SLeCO5imfWCKoqMpgsy6vYMEG6KDA0Gh1gXxG8K28Kh8hjtGqEgqiNx2mna/H2ql -PRmP6zjzZN7IKw0KKP/32+IVQtQi0Cdd4Xn+GOdwiK1O5tmLOsbdJ1Fu/7xk9TND -TwIDAQABo4IBRjCCAUIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw -SwYIKwYBBQUHAQEEPzA9MDsGCCsGAQUFBzAChi9odHRwOi8vYXBwcy5pZGVudHJ1 -c3QuY29tL3Jvb3RzL2RzdHJvb3RjYXgzLnA3YzAfBgNVHSMEGDAWgBTEp7Gkeyxx -+tvhS5B1/8QVYIWJEDBUBgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEB -ATAwMC4GCCsGAQUFBwIBFiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQu -b3JnMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRydXN0LmNvbS9E -U1RST09UQ0FYM0NSTC5jcmwwHQYDVR0OBBYEFHm0WeZ7tuXkAXOACIjIGlj26Ztu -MA0GCSqGSIb3DQEBCwUAA4IBAQAKcwBslm7/DlLQrt2M51oGrS+o44+/yQoDFVDC -5WxCu2+b9LRPwkSICHXM6webFGJueN7sJ7o5XPWioW5WlHAQU7G75K/QosMrAdSW -9MUgNTP52GE24HGNtLi1qoJFlcDyqSMo59ahy2cI2qBDLKobkx/J3vWraV0T9VuG -WCLKTVXkcGdtwlfFRjlBz4pYg1htmf5X6DYO8A4jqv2Il9DjXA6USbW1FzXSLr9O -he8Y4IWS6wY7bCkjCWDcRQJMEhg76fsO3txE+FiYruq9RUWhiF1myv4Q6W+CyBFC -Dfvp7OOGAN6dEOM4+qR9sdjoSYKEBpsr6GtPAQw4dy753ec5 ------END CERTIFICATE----- - )CERT"; - -} // namespace certificates - -#endif \ No newline at end of file diff --git a/Firmware/HomeButtonsArduino/src/mdi/download.cpp b/Firmware/HomeButtonsArduino/src/mdi/download.cpp deleted file mode 100644 index fc57b3a..0000000 --- a/Firmware/HomeButtonsArduino/src/mdi/download.cpp +++ /dev/null @@ -1,102 +0,0 @@ -#include "download.h" - -#include -#include - -#include - -#include "config.h" -#include "logger.h" -#include "static_string.h" - -static constexpr uint32_t DOWNLOAD_TIMEOUT = 10000; -static constexpr size_t DOWNLOAD_BUFFER_SIZE = 1024; - -bool download::download_file(const char* url, File& file, - const char* certificate) { - static Logger logger("Download"); - - // connect - HTTPClient https; - https.setConnectTimeout(DOWNLOAD_TIMEOUT); - https.setTimeout(DOWNLOAD_TIMEOUT); - if (certificate != nullptr) { - logger.debug("Using certificate"); - https.begin(url, certificate); - } else { - logger.debug("Not using certificate"); - https.begin(url); - } - - // Send a GET request for the BMP file - int http_code = https.GET(); - if (http_code != HTTP_CODE_OK) { - logger.error("GET request failed with code %d", http_code); - https.end(); - return false; - } - - // Write the BMP data to the file - WiFiClient* stream = https.getStreamPtr(); - int16_t content_size = https.getSize(); - if (content_size == -1) { - logger.error("No content length"); - https.end(); - return false; - } - size_t totalBytes = 0; - uint32_t start_time = millis(); - while (totalBytes < content_size) { - if (stream->available()) { - size_t num_2_read; - if (content_size - totalBytes < DOWNLOAD_BUFFER_SIZE) { - num_2_read = content_size - totalBytes; - } else { - num_2_read = DOWNLOAD_BUFFER_SIZE; - } - uint8_t buffer[DOWNLOAD_BUFFER_SIZE]; - size_t bytesRead = stream->readBytes(buffer, num_2_read); - file.write(buffer, bytesRead); - totalBytes += bytesRead; - } - if (millis() - start_time > DOWNLOAD_TIMEOUT) { - logger.error("Download timed out"); - https.end(); - file.close(); - return false; - } - yield(); - } - file.close(); - logger.debug("Wrote %d bytes", totalBytes); - - https.end(); - logger.debug("Disconnected from server"); - return true; -} - -bool download::check_connection(const char* url, const char* certificate) { - static Logger logger("Download"); - - // connect - HTTPClient https; - https.setConnectTimeout(DOWNLOAD_TIMEOUT); - https.setTimeout(DOWNLOAD_TIMEOUT); - if (certificate != nullptr) { - logger.debug("Using certificate"); - https.begin(url, certificate); - } else { - logger.debug("Not using certificate"); - https.begin(url); - } - - // Send a GET request for the BMP file - int http_code = https.GET(); - https.end(); - logger.info("GET request to %s returned %d", url, http_code); - if (http_code < 0) { - return false; - } else { - return true; - } -} \ No newline at end of file diff --git a/Firmware/HomeButtonsArduino/src/mdi/download.h b/Firmware/HomeButtonsArduino/src/mdi/download.h deleted file mode 100644 index 109d454..0000000 --- a/Firmware/HomeButtonsArduino/src/mdi/download.h +++ /dev/null @@ -1,12 +0,0 @@ -#ifndef HOME_BUTTONS_DOWNLOAD_H -#define HOME_BUTTONS_DOWNLOAD_H - -#include - -namespace download { -bool download_file(const char* url, File& file, - const char* certificate = nullptr); - -bool check_connection(const char* url, const char* certificate = nullptr); -} // namespace download -#endif diff --git a/Firmware/HomeButtonsArduino/src/mdi/mdi_helper.cpp b/Firmware/HomeButtonsArduino/src/mdi/mdi_helper.cpp deleted file mode 100644 index 7591743..0000000 --- a/Firmware/HomeButtonsArduino/src/mdi/mdi_helper.cpp +++ /dev/null @@ -1,211 +0,0 @@ -#include "mdi_helper.h" - -#include "download.h" -#include "certificates.h" -#include "config.h" - -static constexpr char FOLDER[] = "/mdi"; - -bool MDIHelper::begin() { - if (spiffs_mounted_) { - return true; - } - if (!SPIFFS.begin()) { - error("Failed to mount SPIFFS file system"); - return false; - } - - spiffs_mounted_ = true; - debug("Mounted SPIFFS file system"); - return true; -} - -void MDIHelper::add_size(uint16_t size) { - if (num_sizes_ >= MAX_NUM_SIZES) { - error("Size %d not added, max number of sizes reached", size); - return; - } - sizes_[num_sizes_++] = size; -} - -void MDIHelper::end() { - if (!spiffs_mounted_) { - return; - } - SPIFFS.end(); - spiffs_mounted_ = false; - debug("Unmounted SPIFFS file system"); -} - -StaticString MDIHelper::_get_path(const char* name, - uint16_t size) { - return StaticString("%s/%d/%s.bmp", FOLDER, size, name); -} - -bool MDIHelper::check_connection() { - if (_device_state.user_preferences().icon_server == ICON_URL_DFLT) { - debug("Using default icon server"); - return download::check_connection( - _device_state.user_preferences().icon_server.c_str(), - certificates::isrg_root_x1); - } else { - debug("Using user icon server: %s", - _device_state.user_preferences().icon_server.c_str()); - return download::check_connection( - _device_state.user_preferences().icon_server.c_str()); - } -} - -bool MDIHelper::download(const char* name, uint16_t size) { - if (!spiffs_mounted_) { - error("SPIFFS not mounted"); - return false; - } - - auto path = _get_path(name, size); - - if (SPIFFS.exists(path.c_str())) { - info("'%s' size %d already exists", name, size); - return true; - } - - info("Downloading '%s' size %d to '%s'", name, size, path.c_str()); - - File file = SPIFFS.open(path.c_str(), FILE_WRITE, true); - if (!file) { - error("Failed to open '%s' for writing", path.c_str()); - return false; - } - - StaticString<256> url("%s%dx%d/%s.bmp", - _device_state.user_preferences().icon_server.c_str(), - size, size, name); - info("Icon URL: %s", url.c_str()); - bool ret; - if (_device_state.user_preferences().icon_server == ICON_URL_DFLT) { - ret = - download::download_file(url.c_str(), file, certificates::isrg_root_x1); - } else { - ret = download::download_file(url.c_str(), file); - } - if (ret) { - info("Downloaded '%s' size: %d", name, size); - return true; - } else { - error("Failed to download '%s' size: %d", name, size); - SPIFFS.remove(path.c_str()); - return false; - } -} - -bool MDIHelper::download(const char* name) { - if (!spiffs_mounted_) { - error("SPIFFS not mounted"); - return false; - } - - for (uint8_t i = 0; i < num_sizes_; ++i) { - if (!download(name, sizes_[i])) { - return false; - } - } - return true; -} - -bool MDIHelper::exists(const char* name, uint16_t size) { - if (!spiffs_mounted_) { - error("SPIFFS not mounted"); - return false; - } - auto path = _get_path(name, size); - return SPIFFS.exists(path.c_str()); -} - -bool MDIHelper::exists_all_sizes(const char* name) { - if (!spiffs_mounted_) { - error("SPIFFS not mounted"); - return false; - } - for (uint8_t i = 0; i < num_sizes_; ++i) { - if (!exists(name, sizes_[i])) { - return false; - } - } - return true; -} - -File MDIHelper::get_file(const char* name, uint16_t size) { - if (!spiffs_mounted_) { - error("SPIFFS not mounted"); - return File(); - } - - if (!exists(name, size)) { - error("'%s' size %d does not exist", name, size); - return File(); - } - - auto path = _get_path(name, size); - debug("Opening '%s'", path.c_str()); - return SPIFFS.open(path.c_str(), FILE_READ); -} - -size_t MDIHelper::get_free_space() { - if (!spiffs_mounted_) { - error("SPIFFS not mounted"); - return 0; - } - size_t free = SPIFFS.totalBytes() - SPIFFS.usedBytes(); - debug("Free space: %d", free); - return free; -} - -bool MDIHelper::make_space(size_t size) { - if (!spiffs_mounted_) { - error("SPIFFS not mounted"); - return false; - } - if (get_free_space() > size) { - return true; - } - info("Freeing space..."); - File root = SPIFFS.open(FOLDER); - if (!root) { - error("Failed to open '%s'", FOLDER); - return false; - } - if (!root.isDirectory()) { - error("'%s' is not a directory", FOLDER); - return false; - } - uint16_t count = 0; - size_t size_before = SPIFFS.usedBytes(); - while (get_free_space() < size) { - File file = root.openNextFile(); - if (!file) { - error("Failed to open next file"); - return false; - } - size_t len = strlen(file.path()); - char path[len + 1]; - strncpy(path, file.path(), len + 1); - file.close(); - SPIFFS.remove(path); - count++; - debug("Removed '%s'", path); - delay(10); - } - info("Removed %d files, freed %d bytes", count, - size_before - SPIFFS.usedBytes()); - return true; -} - -bool MDIHelper::remove(const char* name, uint16_t size) { - if (!spiffs_mounted_) { - error("SPIFFS not mounted"); - return false; - } - auto path = _get_path(name, size); - debug("Removing '%s'", path.c_str()); - return SPIFFS.remove(path.c_str()); -} diff --git a/Firmware/HomeButtonsArduino/src/mdi/mdi_helper.h b/Firmware/HomeButtonsArduino/src/mdi/mdi_helper.h deleted file mode 100644 index e4c4057..0000000 --- a/Firmware/HomeButtonsArduino/src/mdi/mdi_helper.h +++ /dev/null @@ -1,40 +0,0 @@ -#ifndef HOMEBUTTONS_MDI_HELPER_H -#define HOMEBUTTONS_MDI_HELPER_H - -#include - -#include "logger.h" -#include "static_string.h" -#include "state.h" - -static constexpr uint8_t MAX_NUM_SIZES = 3; - -static constexpr size_t MAX_PATH_LEN = 56; - -class MDIHelper : public Logger { - public: - MDIHelper(DeviceState& device_state) - : Logger("MDI"), _device_state(device_state) {} - bool begin(); - void add_size(uint16_t size); - bool download(const char* name, uint16_t size); - bool download(const char* name); - bool check_connection(); - bool exists(const char* name, uint16_t size); - bool exists_all_sizes(const char* name); - File get_file(const char* name, uint16_t size); - size_t get_free_space(); - bool make_space(size_t size); - bool remove(const char* name, uint16_t size); - void end(); - - private: - bool spiffs_mounted_ = false; - uint16_t sizes_[MAX_NUM_SIZES] = {0}; - uint8_t num_sizes_ = 0; - StaticString _get_path(const char* name, uint16_t size); - - DeviceState& _device_state; -}; - -#endif diff --git a/Firmware/HomeButtonsArduino/src/mqtt_helper.cpp b/Firmware/HomeButtonsArduino/src/mqtt_helper.cpp deleted file mode 100644 index 2632451..0000000 --- a/Firmware/HomeButtonsArduino/src/mqtt_helper.cpp +++ /dev/null @@ -1,404 +0,0 @@ -#include "mqtt_helper.h" - -#include -#include - -#include "config.h" -#include "network.h" -#include "state.h" -#include "hardware.h" -#include "static_string.h" - -template -bool convertToJson(const StaticString& src, JsonVariant dst) { - return dst.set( - const_cast(src.c_str())); // Warning: use char*, not const char* - // to force a copy in ArduinoJson. -} - -using FormatterType = StaticString<64>; - -void MQTTHelper::send_discovery_config() { - // clear first - clear_discovery_config(); - - // device objects - StaticJsonDocument<256> device_full; - device_full["ids"][0] = _device_state.factory().unique_id; - device_full["mdl"] = _device_state.factory().model_name; - device_full["name"] = _device_state.device_name(); - device_full["sw"] = SW_VERSION; - device_full["hw"] = _device_state.factory().hw_version; - device_full["mf"] = MANUFACTURER; - device_full["cu"] = StaticString<32>("http://%s", _device_state.ip()); - - StaticJsonDocument<128> device_short; - device_short["ids"][0] = _device_state.factory().unique_id; - - uint16_t expire_after = _device_state.sensor_interval() * 60 + 60; // seconds - - char buffer[MQTT_PYLD_SIZE]; - - bool full_device_sent = false; - -#if defined(HAS_BUTTON_UI) - for (auto bsl_w : bsl_input_.GetBtnSwLEDs()) { - auto bsl = bsl_w.get(); - if (!bsl.switch_mode()) { - // button single press - { - StaticJsonDocument conf; - conf["atype"] = "trigger"; - conf["t"] = topics_.t_btn_press(bsl.id()); - conf["pl"] = BTN_PRESS_PAYLOAD; - conf["type"] = "button_short_press"; - conf["stype"] = FormatterType("button_%d", bsl.id()); - if (!full_device_sent) { - conf["dev"] = device_full; - full_device_sent = true; - } else { - conf["dev"] = device_short; - } - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_btn_config(bsl.id()), buffer, true); - } - - // button double press - { - StaticJsonDocument conf; - conf["atype"] = "trigger"; - conf["t"] = topics_.t_btn_press(bsl.id()) + "_double"; - conf["pl"] = BTN_PRESS_PAYLOAD; - conf["type"] = "button_double_press"; - conf["stype"] = FormatterType("button_%d", bsl.id()); - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_btn_double_config(bsl.id()), buffer, true); - } - - // button triple press - { - StaticJsonDocument conf; - conf["atype"] = "trigger"; - conf["t"] = topics_.t_btn_press(bsl.id()) + "_triple"; - conf["pl"] = BTN_PRESS_PAYLOAD; - conf["type"] = "button_triple_press"; - conf["stype"] = FormatterType("button_%d", bsl.id()); - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_btn_triple_config(bsl.id()), buffer, true); - } - - // button quad press - { - StaticJsonDocument conf; - conf["atype"] = "trigger"; - conf["t"] = topics_.t_btn_press(bsl.id()) + "_quad"; - conf["pl"] = BTN_PRESS_PAYLOAD; - conf["type"] = "button_quadruple_press"; - conf["stype"] = FormatterType("button_%d", bsl.id()); - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_btn_quad_config(bsl.id()), buffer, true); - } - } else { // switch - if (!bsl.is_kill_switch()) { - StaticJsonDocument conf; - conf["name"] = FormatterType("Switch %d", bsl.id()); - conf["uniq_id"] = FormatterType{} + _device_state.factory().unique_id + - "_switch_" + bsl.id(); - conf["stat_t"] = topics_.t_switch_state(bsl.id()); - conf["cmd_t"] = topics_.t_switch_cmd(bsl.id()); - conf["ic"] = "mdi:radiobox-marked"; - conf["avty_t"] = topics_.t_avlb(); - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_switch_config(bsl.id()), buffer, true); - } else { - StaticJsonDocument conf; - conf["name"] = "Kill Switch"; - conf["uniq_id"] = FormatterType{} + _device_state.factory().unique_id + - "_kill_switch"; - conf["stat_t"] = topics_.t_switch_state(5); - conf["ic"] = "mdi:mushroom"; - conf["avty_t"] = topics_.t_avlb(); - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_kill_switch_config(bsl.id()), buffer, true); - } - } - } -#endif - -#if defined(HAS_TH_SENSOR) - { - // temperature - - StaticJsonDocument conf; - conf["name"] = "Temperature"; - conf["uniq_id"] = - FormatterType{} + _device_state.factory().unique_id + "_temperature"; - conf["stat_t"] = topics_.t_temperature(); - conf["dev_cla"] = "temperature"; - conf["unit_of_meas"] = _device_state.get_use_fahrenheit() ? "°F" : "°C"; - conf["exp_aft"] = expire_after; - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_temperature_config(), buffer, true); - } - - { - // humidity - StaticJsonDocument conf; - conf["name"] = "Humidity"; - conf["uniq_id"] = - FormatterType{} + _device_state.factory().unique_id + "_humidity"; - conf["stat_t"] = topics_.t_humidity(); - conf["dev_cla"] = "humidity"; - conf["unit_of_meas"] = "%"; - conf["exp_aft"] = expire_after; - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_humidity_config(), buffer, true); - } -#endif - -#if defined(HAS_BATTERY) - { - // battery - StaticJsonDocument conf; - conf["name"] = "Battery"; - conf["uniq_id"] = - FormatterType{} + _device_state.factory().unique_id + "_battery"; - conf["stat_t"] = topics_.t_battery(); - conf["dev_cla"] = "battery"; - conf["unit_of_meas"] = "%"; - conf["exp_aft"] = expire_after; - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_battery_config(), buffer, true); - } -#endif - -#if defined(HAS_TH_SENSOR) - { - StaticJsonDocument conf; - conf["name"] = "Sensor interval"; - conf["uniq_id"] = FormatterType{} + _device_state.factory().unique_id + - "_sensor_interval"; - conf["cmd_t"] = topics_.t_sensor_interval_cmd(); - conf["stat_t"] = topics_.t_sensor_interval_state(); - conf["unit_of_meas"] = "min"; - conf["min"] = SEN_INTERVAL_MIN; - conf["max"] = SEN_INTERVAL_MAX; - conf["mode"] = "slider"; - conf["ic"] = "mdi:timer-sand"; - conf["ret"] = "true"; - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_sensor_interval_config(), buffer, true); - } -#endif - -#if defined(HAS_DISPLAY) - // button labels - for (uint8_t i = 0; i < NUM_BUTTONS; i++) { - StaticJsonDocument conf; - conf["name"] = FormatterType{} + "Button " + (i + 1) + " label"; - conf["uniq_id"] = FormatterType{} + _device_state.factory().unique_id + - "_button_" + (i + 1) + "_label"; - conf["cmd_t"] = topics_.t_btn_label_cmd(i + 1); - conf["stat_t"] = topics_.t_btn_label_state(i + 1); - conf["max"] = BTN_LABEL_MAXLEN; - conf["ic"] = FormatterType("mdi:numeric-%d-box", i + 1); - conf["ret"] = "true"; - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_btn_label_config(i + 1), buffer, true); - } - - { - // user message - StaticJsonDocument conf; - conf["name"] = "Show message"; - conf["uniq_id"] = - FormatterType{} + _device_state.factory().unique_id + "_user_message"; - conf["cmd_t"] = topics_.t_disp_msg_cmd(); - conf["stat_t"] = topics_.t_disp_msg_state(); - conf["max"] = USER_MSG_MAXLEN; - conf["ic"] = "mdi:message-text"; - conf["ret"] = "true"; - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_user_message_config(), buffer, true); - } -#endif - -#if defined(HAS_SLEEP_MODE) - { - // schedule wakeup - StaticJsonDocument conf; - conf["name"] = "Schedule wakeup"; - conf["uniq_id"] = FormatterType{} + _device_state.factory().unique_id + - "_schedule_wakeup"; - conf["cmd_t"] = topics_.t_schedule_wakeup_cmd(); - conf["stat_t"] = topics_.t_schedule_wakeup_state(); - conf["unit_of_meas"] = "s"; - conf["min"] = SCHEDULE_WAKEUP_MIN; - conf["max"] = SCHEDULE_WAKEUP_MAX; - conf["mode"] = "box"; - conf["ic"] = "mdi:alarm"; - conf["ret"] = "true"; - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_schedule_wakeup_config(), buffer, true); - } -#endif - -#if defined(HAS_AWAKE_MODE) - { - // awake mode toggle - StaticJsonDocument conf; - conf["name"] = "Awake mode"; - conf["uniq_id"] = - FormatterType{} + _device_state.factory().unique_id + "_awake_mode"; - conf["cmd_t"] = topics_.t_awake_mode_cmd(); - conf["stat_t"] = topics_.t_awake_mode_state(); - conf["ic"] = "mdi:coffee"; - conf["ret"] = "true"; - conf["avty_t"] = topics_.t_awake_mode_avlb(); - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_awake_mode_config(), buffer, true); - } -#endif - -#if defined(HOME_BUTTONS_INDUSTRIAL) - { - // led brightness slider - StaticJsonDocument conf; - conf["name"] = "LED brightness"; - conf["uniq_id"] = - FormatterType{} + _device_state.factory().unique_id + "_led_amb_bright"; - conf["cmd_t"] = topics_.t_led_amb_bright_cmd(); - conf["stat_t"] = topics_.t_led_amb_bright_state(); - conf["avty_t"] = topics_.t_avlb(); - conf["unit_of_meas"] = "%"; - conf["min"] = 0; - conf["max"] = LED_MAX_AMB_BRIGHT; - conf["mode"] = "slider"; - conf["ic"] = "mdi:led-on"; - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_led_amb_bright_config(), buffer, true); - } -#endif -} - -void MQTTHelper::update_discovery_config() { - StaticJsonDocument<128> device_short; - device_short["ids"][0] = _device_state.factory().unique_id; - - char buffer[MQTT_PYLD_SIZE]; - - uint16_t expire_after = _device_state.sensor_interval() * 60 + 60; // seconds - -#if defined(HAS_TH_SENSOR) - { - StaticJsonDocument conf; - conf["name"] = "Temperature"; - conf["uniq_id"] = - FormatterType{} + _device_state.factory().unique_id + "_temperature"; - conf["stat_t"] = topics_.t_temperature(); - conf["dev_cla"] = "temperature"; - conf["unit_of_meas"] = _device_state.get_use_fahrenheit() ? "°F" : "°C"; - conf["exp_aft"] = expire_after; - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_temperature_config(), buffer, true); - } - - { - StaticJsonDocument conf; - conf["name"] = "Humidity"; - conf["uniq_id"] = - FormatterType{} + _device_state.factory().unique_id + "_humidity"; - conf["stat_t"] = topics_.t_humidity(); - conf["dev_cla"] = "humidity"; - conf["unit_of_meas"] = "%"; - conf["exp_aft"] = expire_after; - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_humidity_config(), buffer, true); - } -#endif - -#if defined(HAS_BATTERY) - { - StaticJsonDocument conf; - conf["name"] = "Battery"; - conf["uniq_id"] = - FormatterType{} + _device_state.factory().unique_id + "_battery"; - conf["stat_t"] = topics_.t_battery(); - conf["dev_cla"] = "battery"; - conf["unit_of_meas"] = "%"; - conf["exp_aft"] = expire_after; - conf["dev"] = device_short; - serializeJson(conf, buffer, sizeof(buffer)); - _network.publish(topics_.t_battery_config(), buffer, true); - } -#endif -} - -void MQTTHelper::clear_discovery_config() { - // Construct topics - - // Buffer for empty payload - const char* empty_payload = ""; - -#if defined(HAS_BUTTON_UI) - for (auto bsl_w : bsl_input_.GetBtnSwLEDs()) { - auto bsl = bsl_w.get(); - _network.publish(topics_.t_btn_config(bsl.id()), empty_payload, true); - _network.publish(topics_.t_btn_double_config(bsl.id()), empty_payload, - true); - _network.publish(topics_.t_btn_triple_config(bsl.id()), empty_payload, - true); - _network.publish(topics_.t_btn_quad_config(bsl.id()), empty_payload, true); - _network.publish(topics_.t_switch_config(bsl.id()), empty_payload, true); - _network.publish(topics_.t_kill_switch_config(bsl.id()), empty_payload, - true); - } -#endif - -#if defined(HAS_TH_SENSOR) - _network.publish(topics_.t_temperature_config(), empty_payload, true); - _network.publish(topics_.t_humidity_config(), empty_payload, true); - _network.publish(topics_.t_sensor_interval_config(), empty_payload, true); -#endif - -#if defined(HAS_BATTERY) - _network.publish(topics_.t_battery_config(), empty_payload, true); -#endif - -#if defined(HAS_DISPLAY) - // button labels - for (uint8_t i = 0; i < NUM_BUTTONS; i++) { - _network.publish(topics_.t_btn_label_config(i + 1), empty_payload, true); - } - _network.publish(topics_.t_user_message_config(), empty_payload, true); -#endif - -#if defined(HAS_SLEEP_MODE) - - _network.publish(topics_.t_schedule_wakeup_config(), empty_payload, true); -#endif - -#if defined(HAS_AWAKE_MODE) - _network.publish(topics_.t_awake_mode_config(), empty_payload, true); -#endif - -#if defined(HOME_BUTTONS_INDUSTRIAL) - _network.publish(topics_.t_led_amb_bright_config(), empty_payload, true); -#endif -} diff --git a/Firmware/HomeButtonsArduino/src/mqtt_helper.h b/Firmware/HomeButtonsArduino/src/mqtt_helper.h deleted file mode 100644 index e9198b5..0000000 --- a/Firmware/HomeButtonsArduino/src/mqtt_helper.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef HOMEBUTTONS_MQTTHELPER_H -#define HOMEBUTTONS_MQTTHELPER_H - -#include "static_string.h" -#include "types.h" -#include "user_input.h" -#include "topics.h" -#include "config.h" -#include "button_ui/btn_sw_led.h" - -class DeviceState; -class Network; - -class MQTTHelper { - public: - MQTTHelper(DeviceState& state, BtnSwLEDInput& bsl_input, - Network& network, TopicHelper& topics) - : _device_state(state), - bsl_input_(bsl_input), - _network(network), - topics_(topics) {}; - void send_discovery_config(); - void update_discovery_config(); - void clear_discovery_config(); - - private: - DeviceState& _device_state; - BtnSwLEDInput& bsl_input_; - Network& _network; - TopicHelper& topics_; -}; - -#endif // HOMEBUTTONS_MQTTHELPER_H diff --git a/Firmware/HomeButtonsArduino/src/network.cpp b/Firmware/HomeButtonsArduino/src/network.cpp index 34c3eaa..43cafb3 100644 --- a/Firmware/HomeButtonsArduino/src/network.cpp +++ b/Firmware/HomeButtonsArduino/src/network.cpp @@ -4,15 +4,11 @@ #include "state.h" #include "utils.h" -static constexpr uint8_t MQTT_QUEUE_SIZE = 4; -static constexpr uint8_t MQTT_QUEUE_ITEMS_PER_LOOP = 5; - String mac2String(uint8_t ar[]) { String s; for (uint8_t i = 0; i < 6; ++i) { char buf[3]; - sprintf(buf, "%02X", ar[i]); // J-M-L: slight modification, added the 0 in - // the format for padding + snprintf(buf, sizeof(buf), "%02X", ar[i]); s += buf; if (i < 5) s += ':'; } @@ -108,47 +104,10 @@ void NetworkSMStates::NormalConnectState::loop() { } } -void NetworkSMStates::MQTTConnectState::entry() { - sm().mqtt_client_.setServer( - sm().device_state_.user_preferences().mqtt.server.c_str(), - sm().device_state_.user_preferences().mqtt.port); - sm().mqtt_client_.setBufferSize(MQTT_BUFFER_SIZE); - sm().mqtt_client_.setCallback( - std::bind(&Network::_mqtt_callback, &sm(), std::placeholders::_1, - std::placeholders::_2, std::placeholders::_3)); - // proceed with MQTT connection - start_time_ = millis(); - sm().info("connecting MQTT...."); - sm()._connect_mqtt(); -} - -void NetworkSMStates::MQTTConnectState::loop() { - if (sm().command_ == Network::Command::DISCONNECT) { - return transition_to(); - } else if (sm().mqtt_client_.connected()) { - sm().info("MQTT connected in %lu ms.", millis() - start_time_); - sm().info("Network connected in %lu ms.", - millis() - sm().cmd_connect_time_); - return transition_to(); - } else if (millis() - start_time_ > MQTT_TIMEOUT) { - if (WiFi.status() == WL_CONNECTED) { - sm().state_ = Network::State::W_CONNECTED; - sm().warning("MQTT connect failed. Retrying..."); - sm()._connect_mqtt(); - start_time_ = millis(); - } else { - sm().warning( - "MQTT connect failed. Wi-Fi not connected. Retrying " - "Wi-Fi..."); - return transition_to(); - } - } -} - -void NetworkSMStates::WifiConnectedState::loop() { +void NetworkSMStates::WifiConnectedState::entry() { sm().state_ = Network::State::W_CONNECTED; sm().device_state_.set_ip(WiFi.localIP()); - sm().info("Wi-Fi connected."); + sm().info("Wi-Fi connected in %lu ms.", millis() - sm().cmd_connect_time_); sm().info("IP: %s", ip_address_to_static_string(WiFi.localIP()).c_str()); String ssid = WiFi.SSID(); sm().device_state_.save_all(); @@ -156,13 +115,14 @@ void NetworkSMStates::WifiConnectedState::loop() { int32_t ch = WiFi.channel(); sm().info("SSID: %s, BSSID: %s, CH: %d", ssid.c_str(), mac2String(bssid).c_str(), ch); - return transition_to(); +} + +void NetworkSMStates::WifiConnectedState::loop() { + return transition_to(); } void NetworkSMStates::DisconnectState::entry() { sm().info("disconnecting..."); - sm().mqtt_client_.disconnect(); - sm().wifi_client_.flush(); WiFi.disconnect(true, sm().erase_); WiFi.mode(WIFI_OFF); sm().state_ = Network::State::DISCONNECTED; @@ -173,57 +133,29 @@ void NetworkSMStates::DisconnectState::loop() { return transition_to(); } -void NetworkSMStates::FullyConnectedState::entry() { +void NetworkSMStates::ConnectedState::entry() { last_conn_check_time_ = millis(); if (sm().on_connect_callback_) { sm().on_connect_callback_(); } - sm().state_ = Network::State::M_CONNECTED; } -void NetworkSMStates::FullyConnectedState::loop() { - if (sm().command_ == Network::Command::DISCONNECT && - uxQueueMessagesWaiting(sm().mqtt_publish_queue_) == 0) { +void NetworkSMStates::ConnectedState::loop() { + if (sm().command_ == Network::Command::DISCONNECT) { return transition_to(); } else if (millis() - last_conn_check_time_ > NET_CONN_CHECK_INTERVAL) { if (WiFi.status() != WL_CONNECTED) { sm().warning("Wi-Fi connection interrupted. Reconnecting..."); return transition_to(); - } else if (!sm().mqtt_client_.connected()) { - sm().state_ = Network::State::W_CONNECTED; - sm().warning("MQTT connection interrupted. Reconnecting..."); - return transition_to(); } last_conn_check_time_ = millis(); - } else { - SM::PublishQueueElement element; - uint8_t max_element_to_process = MQTT_QUEUE_ITEMS_PER_LOOP; - while (max_element_to_process > 0 && sm().mqtt_publish_queue_ != nullptr && - xQueueReceive(sm().mqtt_publish_queue_, &element, 0)) { - sm().debug("received payload (topic: %s)", element.topic.c_str()); - sm()._publish_unsafe(element.topic, element.payload.c_str(), - element.retained); - max_element_to_process--; - } } } -Network::Network(DeviceState &device_state, TopicHelper &topics) +Network::Network(DeviceState &device_state) : NetworkStateMachine("NetworkSM", *this), Logger("NET"), - device_state_(device_state), - mqtt_client_(wifi_client_), - topics_(topics) { - mqtt_publish_queue_ = - xQueueCreate(MQTT_QUEUE_SIZE, sizeof(PublishQueueElement)); - if (mqtt_publish_queue_ == nullptr) error("Failed to create publish queue"); -} - -Network::~Network() { - if (mqtt_publish_queue_ != nullptr) { - vQueueDelete(mqtt_publish_queue_); - } -} + device_state_(device_state) {} void Network::connect() { command_ = Command::CONNECT; @@ -238,62 +170,12 @@ void Network::disconnect(bool erase) { debug("cmd disconnect"); } -void Network::update() { - mqtt_client_.loop(); - loop(); -} +void Network::update() { loop(); } void Network::setup() { network_task_handle_ = xTaskGetCurrentTaskHandle(); } Network::State Network::get_state() { return state_; } -void Network::publish(const TopicType &topic, const PayloadType &payload, - bool retained) { - auto current_task = xTaskGetCurrentTaskHandle(); - - if (current_task == network_task_handle_) { - debug("publish from same task, no need to queue"); - _publish_unsafe(topic, payload.c_str(), retained); - } else { - PublishQueueElement element{topic, payload, retained}; - if (mqtt_publish_queue_ != nullptr && - xQueueSend(mqtt_publish_queue_, (void *)&element, (TickType_t)100)) { - debug("queue send successful (topic: %s)", topic.c_str()); - } else { - error("queue send failed (topic: %s)", topic.c_str()); - } - } -} - -void Network::publish(const TopicType &topic, const char *payload, - bool retained) { - publish(topic, PayloadType{payload}, retained); -} - -bool Network::subscribe(const TopicType &topic) { - if (xTaskGetCurrentTaskHandle() != network_task_handle_) { - error("cannot subscribe from another task"); - return false; - } - if (topic.empty()) { - warning("sub to empty topic blocked"); - return false; - } - bool ret; - ret = mqtt_client_.subscribe(topic.c_str()); - if (ret) { - debug("sub to: %s SUCCESS.", topic.c_str()); - } else { - warning("sub to: %s FAIL.", topic.c_str()); - } - return ret; -} - -void Network::set_mqtt_callback( - std::function callback) { - usr_callback_ = callback; -} - void Network::set_on_connect(std::function on_connect) { this->on_connect_callback_ = on_connect; } @@ -319,51 +201,6 @@ void Network::_pre_wifi_connect() { } } -bool Network::_connect_mqtt() { - if (device_state_.user_preferences().mqtt.user.length() > 0 && - device_state_.user_preferences().mqtt.password.length() > 0) { - return mqtt_client_.connect( - device_state_.factory().unique_id.c_str(), - device_state_.user_preferences().mqtt.user.c_str(), - device_state_.user_preferences().mqtt.password.c_str(), - topics_.t_avlb().c_str(), 1, true, "offline"); - } else { - return mqtt_client_.connect(device_state_.factory().unique_id.c_str(), NULL, - NULL, topics_.t_avlb().c_str(), 1, true, - "offline"); - } -} - -void Network::_mqtt_callback(const char *topic, uint8_t *payload, - uint32_t length) { - char buff[length + 1]; - memcpy(buff, payload, (size_t)length); - buff[length] = '\0'; // required so it can be converted to String - debug("msg on topic: %s, len: %d, payload: %s", topic, length, buff); - if (length < 1) { - return; - } - if (usr_callback_ != NULL) { - usr_callback_(topic, buff); - } -} - -void Network::_publish_unsafe(const TopicType &topic, const char *payload, - bool retained) { - bool ret; - if (retained) { - ret = mqtt_client_.publish(topic.c_str(), payload, true); - } else { - ret = mqtt_client_.publish(topic.c_str(), payload); - } - if (ret) { - debug("pub to: %s SUCCESS.", topic.c_str()); - debug("content: %s", payload); - } else { - error("pub to: %s FAIL.", topic.c_str()); - } -} - StaticIPConfig validate_static_ip_config(StaticIPConfig config) { bool ip_ok = config.static_ip != IPAddress(0, 0, 0, 0); bool gw_ok = config.gateway != IPAddress(0, 0, 0, 0); @@ -383,4 +220,4 @@ StaticIPConfig validate_static_ip_config(StaticIPConfig config) { config.dns2 = DEFAULT_DNS2; } return config; -} \ No newline at end of file +} diff --git a/Firmware/HomeButtonsArduino/src/network.h b/Firmware/HomeButtonsArduino/src/network.h index 8919600..aacd545 100644 --- a/Firmware/HomeButtonsArduino/src/network.h +++ b/Firmware/HomeButtonsArduino/src/network.h @@ -2,12 +2,9 @@ #define HOMEBUTTONS_NETWORK_H #include -#include #include #include "state_machine.h" -#include "mqtt_helper.h" // For TopicType -#include "freertos/queue.h" #include "logger.h" #include "state.h" @@ -51,23 +48,11 @@ class NormalConnectState : public State { bool await_confirm_quick_wifi_settings_ = false; }; -class MQTTConnectState : public State { - public: - using State::State; - - void entry() override; - void loop() override; - - const char *get_name() override { return "MQTTConnectState"; } - - private: - uint32_t start_time_ = 0; -}; - class WifiConnectedState : public State { public: using State::State; + void entry() override; void loop() override; const char *get_name() override { return "WifiConnectedState"; } @@ -83,41 +68,41 @@ class DisconnectState : public State { const char *get_name() override { return "DisconnectState"; } }; -class FullyConnectedState : public State { +// Terminal state once the station is associated. Upstream had a further +// MQTTConnectState/FullyConnectedState pair; with the webhook transport +// there is no session to establish beyond Wi-Fi, so association is the +// whole story. +class ConnectedState : public State { public: using State::State; void entry() override; void loop() override; - const char *get_name() override { return "FullyConnectedState"; } + const char *get_name() override { return "ConnectedState"; } private: uint32_t last_conn_check_time_ = 0; }; } // namespace NetworkSMStates -class Network; - using NetworkStateMachine = StateMachine< Network, NetworkSMStates::IdleState, NetworkSMStates::QuickConnectState, - NetworkSMStates::NormalConnectState, NetworkSMStates::MQTTConnectState, - NetworkSMStates::WifiConnectedState, NetworkSMStates::DisconnectState, - NetworkSMStates::FullyConnectedState>; + NetworkSMStates::NormalConnectState, NetworkSMStates::WifiConnectedState, + NetworkSMStates::DisconnectState, NetworkSMStates::ConnectedState>; class Network : public NetworkStateMachine, public Logger { public: enum class State { DISCONNECTED, W_CONNECTED, - M_CONNECTED, }; enum class Command { NONE, CONNECT, DISCONNECT }; - explicit Network(DeviceState &device_state, TopicHelper &topics); + explicit Network(DeviceState &device_state); Network(const Network &) = delete; - ~Network(); + ~Network() = default; void connect(); void disconnect(bool erase = false); @@ -130,13 +115,6 @@ class Network : public NetworkStateMachine, public Logger { int32_t get_rssi() { return WiFi.RSSI(); } - void publish(const TopicType &topic, const PayloadType &payload, - bool retained = false); - void publish(const TopicType &topic, const char *payload, - bool retained = false); - bool subscribe(const TopicType &topic); - void set_mqtt_callback( - std::function callback); void set_on_connect(std::function on_connect); private: @@ -146,34 +124,18 @@ class Network : public NetworkStateMachine, public Logger { bool erase_ = false; DeviceState &device_state_; - WiFiClient wifi_client_; - PubSubClient mqtt_client_; - TopicHelper &topics_; - QueueHandle_t mqtt_publish_queue_ = nullptr; TaskHandle_t network_task_handle_ = nullptr; - struct PublishQueueElement { - TopicType topic; - PayloadType payload; - bool retained; - }; - - std::function usr_callback_; std::function on_connect_callback_; void _pre_wifi_connect(); - bool _connect_mqtt(); - void _mqtt_callback(const char *topic, uint8_t *payload, uint32_t length); - void _publish_unsafe(const TopicType &topic, const char *payload, - bool retained = false); friend class NetworkSMStates::IdleState; friend class NetworkSMStates::QuickConnectState; friend class NetworkSMStates::NormalConnectState; - friend class NetworkSMStates::MQTTConnectState; friend class NetworkSMStates::WifiConnectedState; friend class NetworkSMStates::DisconnectState; - friend class NetworkSMStates::FullyConnectedState; + friend class NetworkSMStates::ConnectedState; }; StaticIPConfig validate_static_ip_config(StaticIPConfig config); diff --git a/Firmware/HomeButtonsArduino/src/setup.cpp b/Firmware/HomeButtonsArduino/src/setup.cpp index f3da9b0..a6cb3c0 100644 --- a/Firmware/HomeButtonsArduino/src/setup.cpp +++ b/Firmware/HomeButtonsArduino/src/setup.cpp @@ -2,7 +2,6 @@ #include "app.h" #include -#include #include #include #include @@ -11,42 +10,35 @@ static WiFiManager wifi_manager; +// Wi-Fi association retries while bringing the config portal up. +static constexpr int MAX_WIFI_RETRIES_DURING_SETUP = 3; + static WiFiManagerParameter device_name_param("device_name", "Device Name", "", 20); -static WiFiManagerParameter mqtt_server_param("mqtt_server", "MQTT Server", "", - 32); -static WiFiManagerParameter mqtt_port_param("mqtt_port", "MQTT Port", "", 6); -static WiFiManagerParameter mqtt_user_param("mqtt_user", "MQTT User", "", 64); -static WiFiManagerParameter mqtt_password_param("mqtt_password", - "MQTT Password", "", 64); -static WiFiManagerParameter base_topic_param("base_topic", "Base Topic", "", - 64); -static WiFiManagerParameter discovery_prefix_param("disc_prefix", - "Discovery Prefix", "", 64); +static WiFiManagerParameter endpoint_url_param("endpoint", "Webhook URL", "", + ENDPOINT_URL_MAXLEN); +// The 5th arg is custom HTML: render the token as a password field so the +// portal page never shows the stored secret in cleartext. +static WiFiManagerParameter auth_token_param("auth_token", "Auth Token", "", + AUTH_TOKEN_MAXLEN, + "type=\"password\""); static WiFiManagerParameter static_ip_param("static_ip", "Static IP", "", 15); static WiFiManagerParameter gateway_param("gateway", "Gateway", "", 15); static WiFiManagerParameter subnet_param("subnet", "Subnet Mask", "", 15); static WiFiManagerParameter dns_param("dns", "Primary DNS Server", "", 15); static WiFiManagerParameter dns2_param("dns2", "Secondary DNS Server", "", 15); -#if defined(HAS_TH_SENSOR) -static WiFiManagerParameter temp_unit_param("temp_unit", "Temperature Unit", "", - 1); -#endif - -#if defined(HOME_BUTTONS_INDUSTRIAL) -static WiFiManagerParameter button_config_param("btn_conf", "Button Config", "", - NUM_BUTTONS - 1); -#endif - #if defined(HAS_DISPLAY) static char* button_ids[NUM_BUTTONS]; static char* button_labels[NUM_BUTTONS]; static WiFiManagerParameter* btn_label_params[NUM_BUTTONS]; -static WiFiManagerParameter icon_server_param("icon_srv", "Icon Server", "", - 128); void allocate_btn_label_params() { + // The allocations below are never freed (the params live for the lifetime + // of the portal), so guard against a second call leaking them. + static bool allocated = false; + if (allocated) return; + for (uint8_t i = 0; i < NUM_BUTTONS; i++) { button_ids[i] = new char[11]; button_labels[i] = new char[17]; @@ -56,6 +48,7 @@ void allocate_btn_label_params() { btn_label_params[i] = new WiFiManagerParameter( button_ids[i], button_labels[i], "", BTN_LABEL_MAXLEN); } + allocated = true; } void set_btn_label_params_from_device_state(DeviceState& device_state_) { @@ -74,7 +67,6 @@ void set_device_state_from_btn_label_params(DeviceState& device_state_) { void HBSetup::start_wifi_setup() { info("Wi-Fi setup"); - app_.bsl_input_.PauseSwitchModeAll(); #if defined(HAS_DISPLAY) app_.display_.disp_ap_config(); #else @@ -180,24 +172,12 @@ void HBSetup::start_wifi_setup() { void HBSetup::save_params_callback() { app_.device_state_.set_device_name(DeviceName{device_name_param.getValue()}); - app_.device_state_.set_mqtt_parameters( - mqtt_server_param.getValue(), String(mqtt_port_param.getValue()).toInt(), - mqtt_user_param.getValue(), mqtt_password_param.getValue(), - base_topic_param.getValue(), discovery_prefix_param.getValue()); + app_.device_state_.set_endpoint_url( + EndpointUrlType{endpoint_url_param.getValue()}); + app_.device_state_.set_auth_token(AuthTokenType{auth_token_param.getValue()}); #if defined(HAS_DISPLAY) set_device_state_from_btn_label_params(app_.device_state_); - app_.device_state_.set_icon_server(IconServerType{ - ensure_trailing_slash(IconServerType{icon_server_param.getValue()})}); -#endif - -#if defined(HAS_TH_SENSOR) - app_.device_state_.set_temp_unit(StaticString<1>(temp_unit_param.getValue())); -#endif - -#if defined(HOME_BUTTONS_INDUSTRIAL) - app_.device_state_.set_btn_conf_string( - BtnConfString{button_config_param.getValue()}); #endif SSIDType ssid(WiFi.SSID()); @@ -214,13 +194,11 @@ void HBSetup::save_params_callback() { void HBSetup::start_setup() { info("Setup"); - app_.bsl_input_.PauseSwitchModeAll(); // config wifi_manager.setTitle(app_.device_state_.get_model_name_w_rand_id().c_str()); wifi_manager.setSaveParamsCallback( std::bind(&HBSetup::save_params_callback, this)); wifi_manager.setBreakAfterConfig(true); - wifi_manager.setShowPassword(true); wifi_manager.setParamsPage(true); wifi_manager.setDarkMode(true); wifi_manager.setShowInfoUpdate(true); @@ -230,18 +208,10 @@ void HBSetup::start_setup() { // parameters device_name_param.setValue(app_.device_state_.device_name().c_str(), 20); - mqtt_server_param.setValue( - app_.device_state_.user_preferences().mqtt.server.c_str(), 32); - mqtt_port_param.setValue( - String(app_.device_state_.user_preferences().mqtt.port).c_str(), 6); - mqtt_user_param.setValue( - app_.device_state_.user_preferences().mqtt.user.c_str(), 64); - mqtt_password_param.setValue( - app_.device_state_.user_preferences().mqtt.password.c_str(), 64); - base_topic_param.setValue( - app_.device_state_.user_preferences().mqtt.base_topic.c_str(), 64); - discovery_prefix_param.setValue( - app_.device_state_.user_preferences().mqtt.discovery_prefix.c_str(), 64); + endpoint_url_param.setValue(app_.device_state_.endpoint_url().c_str(), + ENDPOINT_URL_MAXLEN); + auth_token_param.setValue(app_.device_state_.auth_token().c_str(), + AUTH_TOKEN_MAXLEN); static_ip_param.setValue(app_.device_state_.user_preferences() .network.static_ip.toString() .c_str(), @@ -261,27 +231,11 @@ void HBSetup::start_setup() { #if defined(HAS_DISPLAY) allocate_btn_label_params(); set_btn_label_params_from_device_state(app_.device_state_); - icon_server_param.setValue( - app_.device_state_.user_preferences().icon_server.c_str(), 128); -#endif - -#if defined(HAS_TH_SENSOR) - temp_unit_param.setValue(app_.device_state_.get_temp_unit().c_str(), 1); -#endif - -#if defined(HOME_BUTTONS_INDUSTRIAL) - button_config_param.setValue( - app_.device_state_.user_preferences().btn_conf_string.c_str(), - NUM_BUTTONS - 1); #endif wifi_manager.addParameter(&device_name_param); - wifi_manager.addParameter(&mqtt_server_param); - wifi_manager.addParameter(&mqtt_port_param); - wifi_manager.addParameter(&mqtt_user_param); - wifi_manager.addParameter(&mqtt_password_param); - wifi_manager.addParameter(&base_topic_param); - wifi_manager.addParameter(&discovery_prefix_param); + wifi_manager.addParameter(&endpoint_url_param); + wifi_manager.addParameter(&auth_token_param); wifi_manager.addParameter(&static_ip_param); wifi_manager.addParameter(&gateway_param); wifi_manager.addParameter(&subnet_param); @@ -292,15 +246,6 @@ void HBSetup::start_setup() { for (uint8_t i = 0; i < NUM_BUTTONS; i++) { wifi_manager.addParameter(btn_label_params[i]); } - wifi_manager.addParameter(&icon_server_param); -#endif - -#if defined(HAS_TH_SENSOR) - wifi_manager.addParameter(&temp_unit_param); -#endif - -#if defined(HOME_BUTTONS_INDUSTRIAL) - wifi_manager.addParameter(&button_config_param); #endif #if defined(HAS_DISPLAY) @@ -329,7 +274,7 @@ void HBSetup::start_setup() { // connect Wi-Fi WiFi.mode(WIFI_STA); - int remaining_tries = MAX_WIFI_RETRIES_DURING_MQTT_SETUP; + int remaining_tries = MAX_WIFI_RETRIES_DURING_SETUP; while (true) { uint32_t wifi_start_time = millis(); @@ -395,51 +340,23 @@ void HBSetup::start_setup() { ESP.restart(); } - // test MQTT connection - uint32_t mqtt_start_time = millis(); - WiFiClient wifi_client; - PubSubClient mqtt_client(wifi_client); - debug("Trying to connect to mqtt://%s:%d", - app_.device_state_.user_preferences().mqtt.server.c_str(), - app_.device_state_.user_preferences().mqtt.port); - mqtt_client.setServer( - app_.device_state_.user_preferences().mqtt.server.c_str(), - app_.device_state_.user_preferences().mqtt.port); - if (app_.device_state_.user_preferences().mqtt.user.length() > 0 && - app_.device_state_.user_preferences().mqtt.password.length() > 0) { - mqtt_client.connect( - app_.device_state_.factory().unique_id.c_str(), - app_.device_state_.user_preferences().mqtt.user.c_str(), - app_.device_state_.user_preferences().mqtt.password.c_str()); - } else { - mqtt_client.connect(app_.device_state_.factory().unique_id.c_str()); - } - -#if defined(HAS_DISPLAY) - app_.display_.disp_message("Confirming\nsetup..."); -#else - app_.bsl_input_.LEDPulse(1, LED_DFLT_BRIGHT, 250); -#endif - - while (!mqtt_client.connected()) { - delay(10); - if (millis() - mqtt_start_time >= MQTT_TIMEOUT) { - app_.device_state_.persisted().setup_done = false; - app_.device_state_.persisted().silent_restart = true; - app_.device_state_.save_all(); - warning("MQTT error."); + // A device with no webhook URL has nowhere to report presses, so treat an + // empty URL as a failed setup instead of silently completing. + if (app_.device_state_.endpoint_url().length() == 0) { + app_.device_state_.persisted().setup_done = false; + app_.device_state_.persisted().silent_restart = true; + app_.device_state_.save_all(); + warning("Webhook URL not set."); #if defined(HAS_DISPLAY) - app_.display_.disp_error("MQTT\nerror"); - delay(3000); + app_.display_.disp_error("Webhook\nURL\nmissing"); + delay(3000); #else - app_.bsl_input_.LEDBlink(1, 5, LED_DFLT_BRIGHT, 200, 160, false); - delay(3000); + app_.bsl_input_.LEDBlink(1, 5, LED_DFLT_BRIGHT, 200, 160, false); + delay(3000); #endif - ESP.restart(); - } + ESP.restart(); } - mqtt_client.disconnect(); WiFi.disconnect(true); app_.device_state_.persisted().setup_done = true; app_.device_state_.persisted().silent_restart = true; diff --git a/Firmware/HomeButtonsArduino/src/state.cpp b/Firmware/HomeButtonsArduino/src/state.cpp index 859cc56..b8e7f06 100644 --- a/Firmware/HomeButtonsArduino/src/state.cpp +++ b/Firmware/HomeButtonsArduino/src/state.cpp @@ -5,21 +5,11 @@ void DeviceState::save_user() { preferences_.begin("user", false); preferences_.putString("device_name", user_preferences_.device_name.c_str()); - preferences_.putString("mqtt_srv", user_preferences_.mqtt.server); - preferences_.putUInt("mqtt_port", user_preferences_.mqtt.port); - preferences_.putString("mqtt_user", user_preferences_.mqtt.user); - preferences_.putString("mqtt_pass", user_preferences_.mqtt.password); - preferences_.putString("base_topic", user_preferences_.mqtt.base_topic); - preferences_.putString("disc_prefix", - user_preferences_.mqtt.discovery_prefix); for (int i = 0; i < NUM_BUTTONS; i++) { preferences_.putString(StaticString<9>("btn%d_txt", i + 1).c_str(), user_preferences_.btn_labels[i].c_str()); } - preferences_.putUInt("sen_itv", user_preferences_.sensor_interval); - preferences_.putBool("use_f", user_preferences_.use_fahrenheit); - preferences_.putUInt("led_am_br", user_preferences_.led_amb_bright); - preferences_.putString("btn_conf", user_preferences_.btn_conf_string.c_str()); + preferences_.putUInt("sen_itv", user_preferences_.heartbeat_interval); preferences_.putString("ssid", user_preferences_.network.ssid.c_str()); preferences_.putString( "sta_ip", @@ -36,7 +26,8 @@ void DeviceState::save_user() { preferences_.putString( "dns2", ip_address_to_static_string(user_preferences_.network.dns2).c_str()); - preferences_.putString("icon_srv", user_preferences_.icon_server.c_str()); + preferences_.putString("endpoint", user_preferences_.endpoint_url.c_str()); + preferences_.putString("auth_tok", user_preferences_.auth_token.c_str()); preferences_.end(); } @@ -45,30 +36,19 @@ void DeviceState::load_user() { _load_to_static_string( user_preferences_.device_name, "device_name", (DeviceName{DEVICE_NAME_DFLT} + " " + factory_.random_id).c_str()); - user_preferences_.mqtt.server = preferences_.getString("mqtt_srv", ""); - user_preferences_.mqtt.port = - preferences_.getUInt("mqtt_port", MQTT_PORT_DFLT); - user_preferences_.mqtt.user = preferences_.getString("mqtt_user", ""); - user_preferences_.mqtt.password = preferences_.getString("mqtt_pass", ""); - user_preferences_.mqtt.base_topic = - preferences_.getString("base_topic", BASE_TOPIC_DFLT); - user_preferences_.mqtt.discovery_prefix = - preferences_.getString("disc_prefix", DISCOVERY_PREFIX_DFLT); + // Defaults describe the counter layout: buttons 1/3 increment counters A/B + // and show the current value, buttons 2/4 decrement, 5/6 are unassigned. + static const char* kDefaultLabels[NUM_BUTTONS] = {"A 0", "A -1", "B 0", + "B -1", "", ""}; for (int i = 0; i < NUM_BUTTONS; i++) { _load_to_static_string(user_preferences_.btn_labels[i], StaticString<9>("btn%d_txt", i + 1).c_str(), - StaticString<16>("mdi:numeric-%d", i + 1).c_str()); + kDefaultLabels[i]); } - user_preferences_.sensor_interval = - preferences_.getUInt("sen_itv", SEN_INTERVAL_DFLT); - user_preferences_.use_fahrenheit = preferences_.getBool("use_f", false); - user_preferences_.led_amb_bright = - preferences_.getUInt("led_am_br", LED_MAX_AMB_BRIGHT); - - _load_to_static_string(user_preferences_.btn_conf_string, "btn_conf", - BTN_CONF_DFLT); + user_preferences_.heartbeat_interval = + preferences_.getUInt("sen_itv", HEARTBEAT_INTERVAL_DFLT); _load_to_static_string(user_preferences_.network.ssid, "ssid", ""); _load_to_ip_address(user_preferences_.network.static_ip, "sta_ip", "0.0.0.0"); @@ -77,8 +57,8 @@ void DeviceState::load_user() { _load_to_ip_address(user_preferences_.network.dns, "dns", "0.0.0.0"); _load_to_ip_address(user_preferences_.network.dns2, "dns2", "0.0.0.0"); - _load_to_static_string(user_preferences_.icon_server, "icon_srv", - ICON_URL_DFLT); + _load_to_static_string(user_preferences_.endpoint_url, "endpoint", ""); + _load_to_static_string(user_preferences_.auth_token, "auth_tok", ""); preferences_.end(); } @@ -105,6 +85,11 @@ void DeviceState::save_persisted() { preferences_.putBool("setup_done", persisted_.setup_done); preferences_.putString("last_sw", persisted_.last_sw_ver); preferences_.putBool("u_awake", persisted_.user_awake_mode); + for (uint8_t i = 0; i < NUM_COUNTERS; i++) { + preferences_.putInt(StaticString<8>("cnt_%d", i).c_str(), + persisted_.counters[i]); + } + preferences_.putUInt("seq", persisted_.seq); preferences_.putBool("wifi_qc", persisted_.wifi_quick_connect); preferences_.putBool("chg_cpt_shwn", persisted_.charge_complete_showing); preferences_.putBool("u_msg_shwn", persisted_.user_msg_showing); @@ -112,20 +97,24 @@ void DeviceState::save_persisted() { preferences_.putUInt("faild_cons", persisted_.failed_connections); preferences_.putBool("rst_to_w_stp", persisted_.restart_to_wifi_setup); preferences_.putBool("rst_to_stp", persisted_.restart_to_setup); - preferences_.putBool("send_adisc", persisted_.send_discovery_config); preferences_.putBool("silent_rst", persisted_.silent_restart); - preferences_.putBool("dl_mdi", persisted_.download_mdi_icons); preferences_.putBool("con_on_r", persisted_.connect_on_restart); preferences_.end(); } void DeviceState::load_persisted() { - preferences_.begin("persisted", false); + // Read-only: upstream opened this namespace read-write on every boot. + preferences_.begin("persisted", true); persisted_.low_batt_mode = preferences_.getBool("lb_mode", false); persisted_.wifi_done = preferences_.getBool("wifi_done", false); persisted_.setup_done = preferences_.getBool("setup_done", false); persisted_.last_sw_ver = preferences_.getString("last_sw", ""); persisted_.user_awake_mode = preferences_.getBool("u_awake", false); + for (uint8_t i = 0; i < NUM_COUNTERS; i++) { + persisted_.counters[i] = + preferences_.getInt(StaticString<8>("cnt_%d", i).c_str(), 0); + } + persisted_.seq = preferences_.getUInt("seq", 0); persisted_.wifi_quick_connect = preferences_.getBool("wifi_qc", false); persisted_.charge_complete_showing = preferences_.getBool("chg_cpt_shwn", false); @@ -135,9 +124,7 @@ void DeviceState::load_persisted() { persisted_.restart_to_wifi_setup = preferences_.getBool("rst_to_w_stp", false); persisted_.restart_to_setup = preferences_.getBool("rst_to_stp", false); - persisted_.send_discovery_config = preferences_.getBool("send_adisc", false); persisted_.silent_restart = preferences_.getBool("silent_rst", false); - persisted_.download_mdi_icons = preferences_.getBool("dl_mdi", false); persisted_.connect_on_restart = preferences_.getBool("con_on_r", false); preferences_.end(); } @@ -156,7 +143,6 @@ void DeviceState::clear_persisted_flags() { persisted_.restart_to_wifi_setup = false; persisted_.restart_to_setup = false; persisted_.silent_restart = false; - persisted_.download_mdi_icons = false; persisted_.connect_on_restart = false; save_all(); } @@ -168,6 +154,7 @@ void DeviceState::_load_factory(HardwareDefinition& hw) { factory_.model_id = hw.get_model_id(); factory_.hw_version = hw.get_hw_version(); factory_.unique_id = hw.get_unique_id(); + ap_password_ = APPasswordType("HB-") + factory_.random_id.c_str(); } void DeviceState::save_all() { @@ -210,10 +197,12 @@ void DeviceState::set_btn_label(uint8_t i, const char* label) { void DeviceState::_load_to_ip_address(IPAddress& destination, const char* key, const char* defaultValue) { - char buffer[16]; - auto ret = preferences_.getString(key, buffer, 16); + // Zero-initialised: Preferences::getString does not guarantee NUL + // termination on every path, and an IPv4 string is exactly at the limit. + char buffer[16] = {}; + auto ret = preferences_.getString(key, buffer, sizeof(buffer)); if (ret == 0) destination.fromString(defaultValue); else destination.fromString(buffer); -} \ No newline at end of file +} diff --git a/Firmware/HomeButtonsArduino/src/state.h b/Firmware/HomeButtonsArduino/src/state.h index 932c599..632d878 100644 --- a/Firmware/HomeButtonsArduino/src/state.h +++ b/Firmware/HomeButtonsArduino/src/state.h @@ -33,23 +33,14 @@ class DeviceState : public Logger { struct UserPreferences { DeviceName device_name; ButtonLabel btn_labels[NUM_BUTTONS]; - uint16_t sensor_interval = 0; // minutes - bool use_fahrenheit = false; - uint8_t led_amb_bright = 0; // 0-100 - BtnConfString btn_conf_string; + // Timer wake interval, used only to report battery level when nobody + // presses a button. Stored under the legacy "sen_itv" NVS key. + uint16_t heartbeat_interval = 0; // minutes StaticIPConfig network; - struct { - String server = ""; - int32_t port = 0; - String user = ""; - String password = ""; - String base_topic = ""; - String discovery_prefix = ""; - } mqtt; - - IconServerType icon_server; + EndpointUrlType endpoint_url; + AuthTokenType auth_token; } user_preferences_; struct Persisted { @@ -60,6 +51,12 @@ class DeviceState : public Logger { String last_sw_ver = ""; bool user_awake_mode = false; + // Counters. Device is authoritative; the webhook receives absolute values. + int32_t counters[NUM_COUNTERS] = {0}; + // Monotonic per-device sequence number, used by the receiver to dedupe + // retries so a replayed press does not notify twice. + uint32_t seq = 0; + // Flags bool wifi_quick_connect = false; bool charge_complete_showing = false; @@ -68,9 +65,7 @@ class DeviceState : public Logger { uint8_t failed_connections = 0; bool restart_to_wifi_setup = false; bool restart_to_setup = false; - bool send_discovery_config = false; bool silent_restart = false; - bool download_mdi_icons = false; bool connect_on_restart = false; } persisted_; @@ -79,12 +74,9 @@ class DeviceState : public Logger { bool awake_mode = false; uint32_t schedule_wakeup_time = 0; uint32_t last_user_input_time = 0; - bool keep_frontlight_on = false; } flags_; struct Sensors { - float temperature = 0; - float humidity = 0; uint8_t battery_pct = 0; float battery_voltage = 0; bool charging = false; @@ -102,13 +94,7 @@ class DeviceState : public Logger { // User preferences const UserPreferences& user_preferences() const { return user_preferences_; } - void set_mqtt_parameters(const String& server, int32_t port, - const String& user, const String& password, - const String& base_topic, - const String& discovery_prefix) { - user_preferences_.mqtt = {server, port, user, - password, base_topic, discovery_prefix}; - } + void set_static_ip_config(SSIDType ssid, const IPAddress& static_ip, const IPAddress& gateway, const IPAddress& subnet, const IPAddress& dns = IPAddress(), @@ -134,45 +120,54 @@ class DeviceState : public Logger { void set_device_name(const DeviceName& device_name) { user_preferences_.device_name = device_name; } - uint16_t sensor_interval() const { return user_preferences_.sensor_interval; } - void set_sensor_interval(uint16_t interval_min) { - user_preferences_.sensor_interval = interval_min; + + uint16_t heartbeat_interval() const { + return user_preferences_.heartbeat_interval; + } + void set_heartbeat_interval(uint16_t interval_min) { + user_preferences_.heartbeat_interval = interval_min; } + const ButtonLabel& get_btn_label(uint8_t i) const; void set_btn_label(uint8_t i, const char* label); - bool get_use_fahrenheit() const { return user_preferences_.use_fahrenheit; } - StaticString<1> get_temp_unit() const { - return StaticString<1>(user_preferences_.use_fahrenheit ? "F" : "C"); + const EndpointUrlType& endpoint_url() const { + return user_preferences_.endpoint_url; } - void set_temp_unit(StaticString<1> unit) { - bool f = unit == "F" || unit == "f"; - user_preferences_.use_fahrenheit = f; + void set_endpoint_url(const EndpointUrlType& url) { + user_preferences_.endpoint_url = url; } - void set_led_brightness(uint8_t brightness) { - user_preferences_.led_amb_bright = brightness; + const AuthTokenType& auth_token() const { + return user_preferences_.auth_token; + } + void set_auth_token(const AuthTokenType& token) { + user_preferences_.auth_token = token; } - void set_btn_conf_string(const BtnConfString& btn_conf_string) { - BtnConfString btn_conf_string_upper(btn_conf_string); - btn_conf_string_upper.to_upper_case(); - for (char& c : btn_conf_string_upper) { - if (c != 'B' && c != 'S') { - c = 'B'; - } - user_preferences_.btn_conf_string = btn_conf_string_upper; - } + // Counters ----------------------------------------------------------- + // idx is 0-based. Returns 0 for an out-of-range index rather than + // reading past the array. + int32_t counter(uint8_t idx) const { + if (idx >= NUM_COUNTERS) return 0; + return persisted_.counters[idx]; } - void set_icon_server(const IconServerType& icon_server) { - if (!icon_server.empty()) { - user_preferences_.icon_server = icon_server; - } else { - user_preferences_.icon_server = ICON_URL_DFLT; - } + // Applies delta, clamps to [COUNTER_MIN, COUNTER_MAX], returns the new + // value. A clamped decrement below zero is a no-op rather than an error; + // the minus button is for corrections and should never go negative. + int32_t adjust_counter(uint8_t idx, int32_t delta) { + if (idx >= NUM_COUNTERS) return 0; + int64_t next = static_cast(persisted_.counters[idx]) + delta; + if (next < COUNTER_MIN) next = COUNTER_MIN; + if (next > COUNTER_MAX) next = COUNTER_MAX; + persisted_.counters[idx] = static_cast(next); + return persisted_.counters[idx]; } + uint32_t seq() const { return persisted_.seq; } + uint32_t next_seq() { return ++persisted_.seq; } + void save_user(); void load_user(); void clear_user(); @@ -199,7 +194,10 @@ class DeviceState : public Logger { SSIDType get_ap_ssid() const { return SSIDType("HB-") + factory_.random_id.c_str(); } - const char* get_ap_password() const { return SETUP_AP_PASSWORD; } + + // Per-device, derived from the eFuse random id which is printed on the + // case. Upstream shipped a single hardcoded password on every unit. + const char* get_ap_password() const { return ap_password_.c_str(); } void set_ip(const IPAddress& ip_address) { ip_address_.set("%u.%u.%u.%u", ip_address[0], ip_address[1], ip_address[2], @@ -223,7 +221,7 @@ class DeviceState : public Logger { template void _load_to_static_string(StaticString& destination, const char* key, const char* defaultValue) { - char buffer[MAX_SIZE + 1]; // +1 for '\0' at the end + char buffer[MAX_SIZE + 1] = {}; // +1 for '\0' at the end auto ret = preferences_.getString(key, buffer, MAX_SIZE + 1); if (ret == 0) destination.set(defaultValue); @@ -236,6 +234,7 @@ class DeviceState : public Logger { Preferences preferences_; StaticString<15> ip_address_; + APPasswordType ap_password_; }; #endif // HOMEBUTTONS_STATE_H diff --git a/Firmware/HomeButtonsArduino/src/topics.cpp b/Firmware/HomeButtonsArduino/src/topics.cpp deleted file mode 100644 index a777ec9..0000000 --- a/Firmware/HomeButtonsArduino/src/topics.cpp +++ /dev/null @@ -1,219 +0,0 @@ -#include "topics.h" - -TopicType TopicHelper::get_button_topic(UserInput::Event event) const { - if (event.btn_id < 1 || event.btn_id > NUM_BUTTONS) return {}; - - if (event.type == UserInput::EventType::kClickSingle) - return t_common() + "button_" + event.btn_id; - else if (event.type == UserInput::EventType::kClickDouble) - return t_common() + "button_" + event.btn_id + "_double"; - else if (event.type == UserInput::EventType::kClickTriple) - return t_common() + "button_" + event.btn_id + "_triple"; - else if (event.type == UserInput::EventType::kClickQuad) - return t_common() + "button_" + event.btn_id + "_quad"; - else if (event.type == UserInput::EventType::kSwitchOn) - return t_common() + "switch_" + event.btn_id; - else if (event.type == UserInput::EventType::kSwitchOff) - return t_common() + "switch_" + event.btn_id; - else - return {}; -} - -TopicType TopicHelper::t_switch_state(uint8_t switch_idx) const { - if (switch_idx > 0 && switch_idx <= NUM_BUTTONS) - return t_common() + "switch_" + (switch_idx); - else - return {}; -} - -TopicType TopicHelper::t_switch_cmd(uint8_t switch_idx) const { - if (switch_idx > 0 && switch_idx <= NUM_BUTTONS) - return t_cmd() + "switch_" + (switch_idx); - else - return {}; -} - -TopicType TopicHelper::t_common() const { - return TopicType(_device_state.user_preferences().mqtt.base_topic.c_str()) + - "/" + _device_state.user_preferences().device_name.c_str() + "/"; -} - -TopicType TopicHelper::t_cmd() const { return t_common() + "cmd/"; } -TopicType TopicHelper::t_temperature() const { - return t_common() + "temperature"; -} -TopicType TopicHelper::t_humidity() const { return t_common() + "humidity"; } -TopicType TopicHelper::t_battery() const { return t_common() + "battery"; } -TopicType TopicHelper::t_btn_press(uint8_t btn_idx) const { - if (btn_idx <= NUM_BUTTONS) - return t_common() + "button_" + (btn_idx); - else - return {}; -} - -TopicType TopicHelper::t_btn_label_state(uint8_t btn_idx) const { - if (btn_idx > 0 && btn_idx <= NUM_BUTTONS) - return t_common() + "btn_" + (btn_idx) + "_label"; - else - return {}; -} - -TopicType TopicHelper::t_btn_label_cmd(uint8_t btn_idx) const { - if (btn_idx > 0 && btn_idx <= NUM_BUTTONS) - return t_cmd() + "btn_" + (btn_idx) + "_label"; - else - return {}; -} - -TopicType TopicHelper::t_sensor_interval_state() const { - return t_common() + "sensor_interval"; -} - -TopicType TopicHelper::t_sensor_interval_cmd() const { - return t_cmd() + "sensor_interval"; -} - -TopicType TopicHelper::t_awake_mode_state() const { - return t_common() + "awake_mode"; -} - -TopicType TopicHelper::t_awake_mode_cmd() const { - return t_cmd() + "awake_mode"; -} - -TopicType TopicHelper::t_awake_mode_avlb() const { - return t_awake_mode_state() + "/available"; -} - -TopicType TopicHelper::t_disp_msg_cmd() const { return t_cmd() + "disp_msg"; } - -TopicType TopicHelper::t_disp_msg_state() const { - return t_common() + "disp_msg"; -} - -TopicType TopicHelper::t_schedule_wakeup_cmd() const { - return t_cmd() + "schedule_wakeup"; -} - -TopicType TopicHelper::t_schedule_wakeup_state() const { - return t_common() + "schedule_wakeup"; -} - -TopicType TopicHelper::t_led_amb_bright_cmd() const { - return t_cmd() + "led_amb_bright"; -} - -TopicType TopicHelper::t_led_amb_bright_state() const { - return t_common() + "led_amb_bright"; -} - -TopicType TopicHelper::t_avlb() const { return t_common() + "available"; } - -TopicType TopicHelper::t_system_state() const { - return t_common() + "system_state"; -} - -TopicType TopicHelper::t_btn_config(uint8_t btn_id) { - return TopicType( - "%s/device_automation/%s/button_%d/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str(), btn_id); -} - -TopicType TopicHelper::t_btn_double_config(uint8_t btn_id) { - return TopicType( - "%s/device_automation/%s/button_%d_double/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str(), btn_id); -} - -TopicType TopicHelper::t_btn_triple_config(uint8_t btn_id) { - return TopicType( - "%s/device_automation/%s/button_%d_triple/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str(), btn_id); -} - -TopicType TopicHelper::t_btn_quad_config(uint8_t btn_id) { - return TopicType( - "%s/device_automation/%s/button_%d_quad/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str(), btn_id); -} - -TopicType TopicHelper::t_switch_config(uint8_t switch_id) { - return TopicType( - "%s/switch/%s/switch_%d/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str(), switch_id); -} - -TopicType TopicHelper::t_kill_switch_config(uint8_t switch_id) { - return TopicType( - "%s/binary_sensor/%s/kill_switch_%d/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str(), switch_id); -} - -TopicType TopicHelper::t_temperature_config() { - return TopicType( - "%s/sensor/%s/temperature/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str()); -} - -TopicType TopicHelper::t_humidity_config() { - return TopicType( - "%s/sensor/%s/humidity/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str()); -} - -TopicType TopicHelper::t_sensor_interval_config() { - return TopicType( - "%s/number/%s/sensor_interval/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str()); -} - -TopicType TopicHelper::t_battery_config() { - return TopicType( - "%s/sensor/%s/battery/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str()); -} - -TopicType TopicHelper::t_btn_label_config(uint8_t btn_idx) { - return TopicType( - "%s/text/%s/button_%d_label/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str(), btn_idx); -} - -TopicType TopicHelper::t_user_message_config() { - return TopicType( - "%s/text/%s/user_message/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str()); -} - -TopicType TopicHelper::t_schedule_wakeup_config() { - return TopicType( - "%s/number/%s/schedule_wakeup/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str()); -} - -TopicType TopicHelper::t_awake_mode_config() { - return TopicType( - "%s/switch/%s/awake_mode/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str()); -} - -TopicType TopicHelper::t_led_amb_bright_config() { - return TopicType( - "%s/number/%s/led_amb_bright/config", - _device_state.user_preferences().mqtt.discovery_prefix.c_str(), - _device_state.factory().unique_id.c_str()); -} diff --git a/Firmware/HomeButtonsArduino/src/topics.h b/Firmware/HomeButtonsArduino/src/topics.h deleted file mode 100644 index 430b9fe..0000000 --- a/Firmware/HomeButtonsArduino/src/topics.h +++ /dev/null @@ -1,61 +0,0 @@ -#ifndef HOMEBUTTONS_TOPICS_H -#define HOMEBUTTONS_TOPICS_H - -#include "types.h" -#include "user_input.h" -#include "state.h" - -class TopicHelper { - public: - TopicHelper(DeviceState& device_state) : _device_state(device_state) {} - - // btn_id [1:NUM_BUTTONS] - TopicType get_button_topic(UserInput::Event event) const; - - // btn_idx [0:NUM_BUTTONS-1] - TopicType t_common() const; - TopicType t_cmd() const; - TopicType t_temperature() const; - TopicType t_humidity() const; - TopicType t_battery() const; - TopicType t_btn_press(uint8_t btn_idx) const; - TopicType t_btn_label_state(uint8_t btn_idx) const; - TopicType t_btn_label_cmd(uint8_t btn_idx) const; - TopicType t_sensor_interval_state() const; - TopicType t_sensor_interval_cmd() const; - TopicType t_awake_mode_state() const; - TopicType t_awake_mode_cmd() const; - TopicType t_awake_mode_avlb() const; - TopicType t_disp_msg_cmd() const; - TopicType t_disp_msg_state() const; - TopicType t_schedule_wakeup_cmd() const; - TopicType t_schedule_wakeup_state() const; - TopicType t_led_amb_bright_cmd() const; - TopicType t_led_amb_bright_state() const; - TopicType t_avlb() const; - TopicType t_switch_state(uint8_t switch_idx) const; - TopicType t_switch_cmd(uint8_t switch_idx) const; - TopicType t_system_state() const; - - // config topics - TopicType t_btn_config(uint8_t btn_id); - TopicType t_btn_double_config(uint8_t btn_id); - TopicType t_btn_triple_config(uint8_t btn_id); - TopicType t_btn_quad_config(uint8_t btn_id); - TopicType t_switch_config(uint8_t switch_id); - TopicType t_kill_switch_config(uint8_t switch_id); - TopicType t_temperature_config(); - TopicType t_humidity_config(); - TopicType t_sensor_interval_config(); - TopicType t_battery_config(); - TopicType t_btn_label_config(uint8_t btn_idx); - TopicType t_user_message_config(); - TopicType t_schedule_wakeup_config(); - TopicType t_awake_mode_config(); - TopicType t_led_amb_bright_config(); - - private: - DeviceState& _device_state; -}; - -#endif // HOMEBUTTONS_TOPICS_H \ No newline at end of file diff --git a/Firmware/HomeButtonsArduino/src/touch/touch.cpp b/Firmware/HomeButtonsArduino/src/touch/touch.cpp deleted file mode 100644 index bcabb13..0000000 --- a/Firmware/HomeButtonsArduino/src/touch/touch.cpp +++ /dev/null @@ -1,276 +0,0 @@ -#include "touch.h" - -#include "FunctionalInterrupt.h" - -bool TouchInput::InternalInit() { - touch_controller_ = new FT6X36(&Wire1, hw_.TOUCH_INT_PIN); - - touch_controller_->registerTouchHandler(std::bind(&TouchInput::TouchHandler, - this, std::placeholders::_1, - std::placeholders::_2)); - attachInterrupt(hw_.TOUCH_CLICK_PIN, std::bind(&TouchInput::ClickISR, this), - CHANGE); - return true; -} - -bool TouchInput::InternalStart() { - touch_controller_->begin(); - return true; -} - -bool TouchInput::InternalStop() { - detachInterrupt(hw_.TOUCH_CLICK_PIN); - return true; -} - -void TouchInput::InternalLoop() { - BtnUpdate(); - touch_controller_->loop(); -} - -void TouchInput::BtnUpdate() { - switch (btn_sm_state_) { - case 0: // idle - if (rising_flag_) { - rising_flag_ = false; - btn_sm_time_ = millis(); - btn_press_start_time_ = millis(); - btn_last_trigger_time_ = millis(); - click_started_ = true; - btn_sm_state_ = 1; - } - break; - case 1: // debounce - if (millis() - btn_press_start_time_ >= kDebounceTimeout) { - if (ReadPin()) { - btn_sm_state_ = 2; - } else { - btn_sm_state_ = 8; - } - } - break; - case 2: // pin is high, wait for release - if (falling_flag_ || !ReadPin()) { - falling_flag_ = false; - btn_num_clicks_++; - TriggerClick(millis() - btn_press_start_time_, btn_num_clicks_, false); - btn_sm_time_ = millis(); - btn_sm_state_ = 3; - } - // keep triggering click if button is held - if (millis() - btn_last_trigger_time_ >= kTriggerInterval) { - btn_last_trigger_time_ = millis(); - TriggerClick(millis() - btn_press_start_time_, btn_num_clicks_, false); - } - break; - case 3: // debounce - if (millis() - btn_sm_time_ >= kDebounceTimeout) { - btn_sm_state_ = 4; - } - break; - case 4: // end of single_wait for additional presses - if (rising_flag_) { - rising_flag_ = false; - btn_sm_time_ = millis(); - btn_sm_state_ = 5; - } else if (millis() - btn_sm_time_ >= kPressTimeout) { - TriggerClick(millis() - btn_press_start_time_, btn_num_clicks_, true); - btn_sm_state_ = 8; - } - break; - case 5: // debounce next press - if (millis() - btn_sm_time_ >= kDebounceTimeout) { - if (ReadPin()) { - btn_sm_state_ = 6; - } else { - btn_sm_state_ = 4; - } - } - break; - case 6: // pin is high, wait for release - if (falling_flag_ || !ReadPin()) { - falling_flag_ = false; - btn_num_clicks_++; - TriggerClick(millis() - btn_press_start_time_, btn_num_clicks_, false); - btn_sm_time_ = millis(); - btn_sm_state_ = 7; - } - break; - case 7: // debounce and return to 4 - if (millis() - btn_sm_time_ >= kDebounceTimeout) { - btn_sm_state_ = 4; - } - break; - case 8: // reset - click_started_ = false; - btn_num_clicks_ = 0; - btn_sm_state_ = 0; - } -} - -void TouchInput::TouchHandler(TPoint point, TEvent e) { - last_touch_point_ = {point.x, point.y}; - switch (e) { - case TEvent::TouchStart: - debug("touch start %d %d", point.x, point.y); - touch_started_ = true; - touch_start_point_ = last_touch_point_; - touch_start_time_ = millis(); - TriggerTouch(TEvent::TouchStart, last_touch_point_); - break; - case TEvent::TouchMove: - debug("touch move %d %d", point.x, point.y); - break; - case TEvent::TouchEnd: - debug("touch end %d %d", point.x, point.y); - TriggerTouch(TEvent::TouchEnd, last_touch_point_); - touch_started_ = false; - break; - case TEvent::Tap: - debug("tap %d %d", point.x, point.y); - TriggerTouch(TEvent::Tap, last_touch_point_); - break; - case TEvent::DragStart: - debug("drag start %d %d", point.x, point.y); - break; - case TEvent::DragMove: - debug("drag move %d %d", point.x, point.y); - break; - case TEvent::DragEnd: - debug("drag end %d %d", point.x, point.y); - break; - default: - debug("unknown touch event %d", static_cast(e)); - break; - } -} - -void IRAM_ATTR TouchInput::ClickISR() { - rising_flag_ = digitalRead(click_pin_); - falling_flag_ = !digitalRead(click_pin_); -} - -bool TouchInput::ReadPin() const { - if (active_high_) { - return digitalRead(click_pin_); - } else { - return !digitalRead(click_pin_); - } -} - -void TouchInput::TriggerClick(uint32_t duration, uint16_t btn_num_clicks_, - bool finished) { - debug("CLICK: duration: %d, num_clicks: %d, finished: %d", duration, - btn_num_clicks_, finished); - uint8_t btn_num = Touch2BtnNum(last_touch_point_); - - switch (btn_num_clicks_) { - case 0: - if (duration >= kLong2sTime && duration < kLong5sTime) { - TriggerEvent(Event{EventType::kHoldLong2s, last_touch_point_, btn_num}); - } else if (duration >= kLong5sTime && duration < kLong10sTime) { - TriggerEvent(Event{EventType::kHoldLong5s, last_touch_point_, btn_num}); - } else if (duration >= kLong10sTime && duration < kLong20sTime) { - TriggerEvent( - Event{EventType::kHoldLong10s, last_touch_point_, btn_num}); - } else if (duration >= kLong20sTime) { - TriggerEvent( - Event{EventType::kHoldLong20s, last_touch_point_, btn_num}); - } - break; - case 1: - if (duration >= kLong2sTime && duration < kLong5sTime) { - TriggerEvent( - Event{EventType::kClickLong2s, last_touch_point_, btn_num}); - } else if (duration >= kLong5sTime && duration < kLong10sTime) { - TriggerEvent( - Event{EventType::kClickLong5s, last_touch_point_, btn_num}); - } else if (duration >= kLong10sTime && duration < kLong20sTime) { - TriggerEvent( - Event{EventType::kClickLong10s, last_touch_point_, btn_num}); - } else if (duration >= kLong20sTime) { - TriggerEvent( - Event{EventType::kClickLong20s, last_touch_point_, btn_num}); - } else { - TriggerEvent( - Event{EventType::kClickSingle, last_touch_point_, btn_num}); - } - break; - case 2: - TriggerEvent(Event{EventType::kClickDouble, last_touch_point_, btn_num}); - break; - case 3: - TriggerEvent(Event{EventType::kClickTriple, last_touch_point_, btn_num}); - break; - case 4: - TriggerEvent(Event{EventType::kClickQuad, last_touch_point_, btn_num}); - break; - default: - break; - } -} - -void TouchInput::TriggerTouch(TEvent event, TouchPoint point) { - uint8_t btn_num = Touch2BtnNum(last_touch_point_); - switch (event) { - case TEvent::TouchStart: - TriggerEvent(Event{EventType::kTouchStart, point, btn_num}); - break; - case TEvent::Tap: - if (!click_started_) { - TriggerEvent(Event{EventType::kTap, point, btn_num}); - } - break; - case TEvent::TouchEnd: - if (touch_started_) { - if (millis() - touch_start_time_ <= kSwipeTimeout) { - if (abs(point.x - touch_start_point_.x) >= kSwipeDistance) { - if (point.x > touch_start_point_.x) { - TriggerEvent(Event{EventType::kSwipeRight, point, btn_num}); - } else { - TriggerEvent(Event{EventType::kSwipeLeft, point, btn_num}); - } - } else if (abs(point.y - touch_start_point_.y) >= kSwipeDistance) { - if (point.y > touch_start_point_.y) { - TriggerEvent(Event{EventType::kSwipeDown, point, btn_num}); - } else { - TriggerEvent(Event{EventType::kSwipeUp, point, btn_num}); - } - } - } - } - TriggerEvent(Event{EventType::kTouchEnd, point, btn_num}); - break; - default: - break; - } -} - -uint8_t TouchInput::Touch2BtnNum(TouchPoint point) { - // 9 buttons on 400x300 touchscreen - if (point.x < 134) { - if (point.y < 100) { - return 1; - } else if (point.y < 200) { - return 4; - } else { - return 7; - } - } else if (point.x < 266) { - if (point.y < 100) { - return 2; - } else if (point.y < 200) { - return 5; - } else { - return 8; - } - } else { - if (point.y < 100) { - return 3; - } else if (point.y < 200) { - return 6; - } else { - return 9; - } - } -} \ No newline at end of file diff --git a/Firmware/HomeButtonsArduino/src/touch/touch.h b/Firmware/HomeButtonsArduino/src/touch/touch.h deleted file mode 100644 index 32f4fa7..0000000 --- a/Firmware/HomeButtonsArduino/src/touch/touch.h +++ /dev/null @@ -1,59 +0,0 @@ -#ifndef HOMEBUTTONS_TOUCH_H -#define HOMEBUTTONS_TOUCH_H - -#include -#include -#include - -#include "user_input.h" -#include "hardware.h" -#include "freertos/semphr.h" - -static constexpr uint32_t kDebounceTimeout = 50L; -static constexpr uint32_t kPressTimeout = 500L; -static constexpr uint32_t kTriggerInterval = 250L; - -static constexpr uint32_t kSwipeTimeout = 500L; -static constexpr uint16_t kSwipeDistance = 50L; - -class TouchInput : public UserInput { - public: - TouchInput(HardwareDefinition& hw) : UserInput("TOUCH") { hw_ = hw; } - void TouchHandler(TPoint point, TEvent e); - - private: - bool InternalInit() override { return true; }; - bool InternalStart() override; - bool InternalStop() override; - void InternalLoop() override; - - FT6X36* touch_controller_; - HardwareDefinition& hw_; - - bool rising_flag_ = false; - bool falling_flag_ = false; - - TouchPoint last_touch_point_ = {0, 0}; - TouchPoint touch_start_point_ = {0, 0}; - uint32_t touch_start_time_ = 0; - bool touch_started_ = false; - - bool click_started_ = false; - - uint32_t btn_sm_time_ = 0; - uint8_t btn_sm_state_ = 0; - uint8_t btn_num_clicks_ = 0; - uint32_t btn_last_trigger_time_ = 0; - uint32_t btn_press_start_time_ = 0; - - void IRAM_ATTR ClickISR(); - bool ReadPin() const; - void BtnUpdate(); - void TriggerClick(uint32_t duration, uint16_t num_clicks, - bool finished = true); - void TriggerTouch(TEvent event, TouchPoint point); - void TriggerEvent(Event event); - static uint8_t Touch2BtnNum(TouchPoint point); -}; - -#endif \ No newline at end of file diff --git a/Firmware/HomeButtonsArduino/src/types.h b/Firmware/HomeButtonsArduino/src/types.h index cac1077..417a130 100644 --- a/Firmware/HomeButtonsArduino/src/types.h +++ b/Firmware/HomeButtonsArduino/src/types.h @@ -15,18 +15,17 @@ using DeviceName = StaticString<20>; using ButtonLabel = StaticString; using MDIName = StaticString<48>; using UserMessage = StaticString; -using BtnConfString = StaticString<16>; -using TouchActionString = StaticString<16>; -using ClickActionString = StaticString<16>; - -using TopicType = StaticString; -using PayloadType = StaticString; +using EndpointUrlType = StaticString; +using AuthTokenType = StaticString; +using PayloadType = StaticString; using SSIDType = StaticString<32>; using HostnameType = StaticString<32>; -using IconServerType = StaticString<128>; +// AP password is "HB-" + the 6-char eFuse random id, so 9 chars — comfortably +// over the 8-char WPA2 minimum. +using APPasswordType = StaticString<16>; enum class DisplayPage { EMPTY, @@ -58,4 +57,4 @@ struct UIState { enum class LabelType : uint8_t { None, Text, Icon, Mixed }; -#endif // HOMEBUTTONS_TYPES_H; \ No newline at end of file +#endif // HOMEBUTTONS_TYPES_H diff --git a/Firmware/HomeButtonsArduino/src/webhook.cpp b/Firmware/HomeButtonsArduino/src/webhook.cpp new file mode 100644 index 0000000..8173e65 --- /dev/null +++ b/Firmware/HomeButtonsArduino/src/webhook.cpp @@ -0,0 +1,101 @@ +#include "webhook.h" + +#include + +#include "config.h" + +// Provided by the ESP-IDF certificate bundle (CONFIG_MBEDTLS_CERTIFICATE_BUNDLE +// in sdkconfig.defaults). A bundle rather than a pinned root: the backend sits +// behind Cloudflare, which rotates edge CAs without notice, and upstream's +// single pinned root had already expired. +extern const uint8_t rootca_crt_bundle_start[] asm( + "_binary_x509_crt_bundle_start"); + +void Webhook::begin() { + if (begun_) return; + client_.setCACertBundle(rootca_crt_bundle_start); + client_.setTimeout(HTTP_TIMEOUT / 1000); // seconds + http_.setReuse(true); + http_.setTimeout(HTTP_TIMEOUT); + http_.setConnectTimeout(HTTP_TIMEOUT); + begun_ = true; + debug("webhook client ready"); +} + +bool Webhook::configured() const { + return !device_state_.endpoint_url().empty(); +} + +size_t Webhook::_build_body(char* out, size_t out_size, const Event& event, + bool heartbeat) { + StaticJsonDocument doc; + doc["device"] = device_state_.factory().unique_id.c_str(); + doc["seq"] = event.seq; + doc["event"] = heartbeat ? "heartbeat" : "press"; + if (!heartbeat) { + doc["counter"] = COUNTER_NAMES[event.counter_idx]; + doc["button"] = event.button_id; + doc["delta"] = event.delta; + doc["count"] = event.count; + } + // The device has no RTC, so the receiver stamps wall-clock time. age_ms + // lets it back-date a press that is only now being delivered. + doc["age_ms"] = event.age_ms; + doc["battery_pct"] = device_state_.sensors().battery_pct; + doc["battery_v"] = device_state_.sensors().battery_voltage; + doc["sw_version"] = SW_VERSION; + return serializeJson(doc, out, out_size); +} + +bool Webhook::_post(char* body, size_t len) { + if (!configured()) { + warning("no endpoint configured, dropping event"); + return false; + } + begin(); + + for (uint8_t attempt = 1; attempt <= HTTP_MAX_ATTEMPTS; attempt++) { + if (!http_.begin(client_, device_state_.endpoint_url().c_str())) { + error("http begin failed (attempt %u)", attempt); + continue; + } + http_.addHeader("Content-Type", "application/json"); + const auto& token = device_state_.auth_token(); + if (!token.empty()) { + http_.addHeader("Authorization", String("Bearer ") + token.c_str()); + } + + int code = http_.POST(reinterpret_cast(body), len); + http_.end(); // with setReuse(true) this keeps the socket open + + if (code >= 200 && code < 300) { + info("posted ok (%d) on attempt %u", code, attempt); + return true; + } + // 4xx other than 408/429 will not improve on retry. + if (code >= 400 && code < 500 && code != 408 && code != 429) { + error("post rejected (%d), not retrying", code); + return false; + } + warning("post failed (%d) on attempt %u", code, attempt); + delay(200 * attempt); + } + error("post failed after %u attempts", HTTP_MAX_ATTEMPTS); + return false; +} + +bool Webhook::send_press(const Event& event) { + char body[HTTP_PAYLOAD_SIZE]; + size_t len = _build_body(body, sizeof(body), event, false); + debug("press body: %s", body); + return _post(body, len); +} + +bool Webhook::send_heartbeat() { + Event event{}; + event.seq = device_state_.next_seq(); + char body[HTTP_PAYLOAD_SIZE]; + size_t len = _build_body(body, sizeof(body), event, true); + debug("heartbeat body: %s", body); + return _post(body, len); +} diff --git a/Firmware/HomeButtonsArduino/src/webhook.h b/Firmware/HomeButtonsArduino/src/webhook.h new file mode 100644 index 0000000..72eec24 --- /dev/null +++ b/Firmware/HomeButtonsArduino/src/webhook.h @@ -0,0 +1,58 @@ +#ifndef HOMEBUTTONS_WEBHOOK_H +#define HOMEBUTTONS_WEBHOOK_H + +#include +#include + +#include "logger.h" +#include "state.h" +#include "types.h" + +// Posts counter events to an HTTPS webhook (an n8n Webhook node in the +// intended deployment). +// +// The device is authoritative for the counter, so every request carries the +// absolute value as well as the delta. A dropped request therefore repairs +// itself on the next press rather than needing a durable retry queue. `seq` +// is monotonic per device and exists only so the receiver can dedupe a +// replayed request and avoid notifying twice. +class Webhook : public Logger { + public: + struct Event { + uint8_t counter_idx = 0; // 0-based + uint8_t button_id = 0; // 1-based, 0 for a heartbeat + int32_t delta = 0; + int32_t count = 0; // absolute value after the delta was applied + uint32_t seq = 0; + uint32_t age_ms = 0; // >0 when this is a delayed retry + }; + + explicit Webhook(DeviceState& device_state) + : Logger("HOOK"), device_state_(device_state) {} + Webhook(const Webhook&) = delete; + + // Must be called once after Wi-Fi is up, before the first send(). + void begin(); + + // True when an endpoint URL has been configured in the setup portal. + bool configured() const; + + // Posts a press. Retries up to HTTP_MAX_ATTEMPTS within the caller's + // awake window. Returns true on a 2xx. + bool send_press(const Event& event); + + // Posts battery level only, used by the timer wake. + bool send_heartbeat(); + + private: + bool _post(char* body, size_t len); + size_t _build_body(char* out, size_t out_size, const Event& event, + bool heartbeat); + + DeviceState& device_state_; + WiFiClientSecure client_; + HTTPClient http_; + bool begun_ = false; +}; + +#endif // HOMEBUTTONS_WEBHOOK_H diff --git a/Firmware/HomeButtonsArduino/tools/make_icons.py b/Firmware/HomeButtonsArduino/tools/make_icons.py new file mode 100644 index 0000000..0206248 --- /dev/null +++ b/Firmware/HomeButtonsArduino/tools/make_icons.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Generate the counter button icons as BMPs for the SPIFFS data image. + +Icons used to be downloaded at runtime from an MDI icon server. This fork +drops that whole path, so the handful of glyphs the device can show are +generated here and flashed with `pio run -t uploadfs`. + +Output layout matches what Display::open_mdi_file() reads: + data/mdi//.bmp + +Pure stdlib on purpose: no Pillow, so CI needs nothing extra. +""" +import os +import struct + +SIZES = (64, 48) +OUT_ROOT = os.path.join(os.path.dirname(__file__), "..", "data", "mdi") + +BLACK = (0, 0, 0) +WHITE = (255, 255, 255) + + +def blank(size): + return [[WHITE for _ in range(size)] for _ in range(size)] + + +def hbar(px, size): + """Horizontal bar, centred, ~62% wide and ~14% thick.""" + thick = max(2, round(size * 0.14)) + length = round(size * 0.62) + x0 = (size - length) // 2 + y0 = (size - thick) // 2 + for y in range(y0, y0 + thick): + for x in range(x0, x0 + length): + px[y][x] = BLACK + + +def vbar(px, size): + thick = max(2, round(size * 0.14)) + length = round(size * 0.62) + y0 = (size - length) // 2 + x0 = (size - thick) // 2 + for y in range(y0, y0 + length): + for x in range(x0, x0 + thick): + px[y][x] = BLACK + + +def write_bmp(path, px, size): + """24-bit uncompressed BMP, bottom-up. size*3 is 4-byte aligned for + both 64 and 48, so no row padding is needed - assert it rather than + silently emitting a corrupt file if SIZES ever changes.""" + row_bytes = size * 3 + assert row_bytes % 4 == 0, "row padding needed for size %d" % size + pixel_data = bytearray() + for y in range(size - 1, -1, -1): + for x in range(size): + r, g, b = px[y][x] + pixel_data += bytes((b, g, r)) + offset = 14 + 40 + header = struct.pack("<2sIHHI", b"BM", offset + len(pixel_data), 0, 0, offset) + info = struct.pack(" +This is a derivative work, not a drop-in replacement for upstream firmware — +MQTT and Home Assistant integration have been removed entirely. --- -
+## How it works + +``` +press + ──▶ LED blinks immediately (tactile confirmation) + ──▶ counter increments locally, display redraws + ──▶ HTTPS POST to the webhook + ──▶ stay awake ~30 s for more presses, then deep sleep +``` + +The **device is authoritative** for the counter. Every request carries the +absolute value as well as the delta, so a dropped request repairs itself on the +next press — there is no retry queue to get out of sync. The display never +waits on the network. + +After the first press the Wi-Fi association and TLS session stay open for +`SESSION_IDLE_TIMEOUT`, so a burst of presses costs one handshake instead of +one per press. Presses 2..N land in roughly 200 ms rather than several seconds. + +### Request format + +```http +POST +Authorization: Bearer +Content-Type: application/json +``` +```jsonc +{ + "device": "HBTNS-24011234-ABC123", // eFuse unique id + "seq": 837, // monotonic, for dedupe + "event": "press", // or "heartbeat" + "counter": "a", // "a" or "b" + "button": 1, + "delta": 1, // +1 or -1 + "count": 42, // absolute, authoritative + "age_ms": 0, // >0 if this is a delayed retry + "battery_pct": 87, + "battery_v": 3.92, + "sw_version": "v3.0.0-counter.1" +} +``` + +Any `2xx` is success. `4xx` other than 408/429 is treated as permanent and not +retried. + +`age_ms` exists because the device has no RTC — let the receiver stamp +wall-clock time and back-date by `age_ms` so a delayed press does not land with +the wrong timestamp. + +### Notes for an n8n receiver + +- Set the Webhook node to respond **immediately**, or put a *Respond to + Webhook* node **before** the Telegram step. Otherwise the device holds the + connection open until Telegram round-trips and burns awake time for nothing. +- Because `count` is absolute, storing it is a plain upsert — no + read-modify-write, no `$getWorkflowStaticData` dance, no concurrency handling. +- Keep the last-seen `seq` per device and short-circuit on a repeat. That is the + only state you need, and it is what stops a retry notifying twice. +- Use the **production** URL (`/webhook/…`). The test URL (`/webhook-test/…`) + only responds while the editor is actively listening. + +## Setup + +Hold two buttons for 5 s → settings → setup. The device starts a Wi-Fi access +point; its password is derived per-device from the random ID printed on the +case (upstream shipped the same hardcoded password on every unit). + +Portal fields: device name, **webhook URL**, **auth token**, static IP settings, +and the six button labels. + +## Building + +```bash +cd Firmware/HomeButtonsArduino +pio run -e original_release # firmware +python tools/make_icons.py # generate icon BMPs +pio run -e original_release -t buildfs # SPIFFS image +pio run -e original_release -t upload -t uploadfs +``` + +Only `original_release` and `original_debug` exist. The mini, pro and +industrial targets are gone. - - -***Home Buttons*** lets you control you smart home in a simple and intuitive way. -With a couple of push buttons, you can set scenes, control lights, trigger automations, and more. - -The device features an e-paper display that shows a label for each button. So you always know what it does! -All while consuming next to no power. - -The labels and button actions can be easily configured directly in *Home Assistant*. - -*Home Buttons* communicates via the MQTT protocol on a local network. No cloud required. - -> It's also possible to use *Home Buttons* without *Home Assistant*. It can be integrated into most smart home systems that support MQTT, though some features may not be available. - -
- -## Documentation - -Learn more [here](https://docs.home-buttons.com). - -## Development - -The project uses *PlatformIO* for development. To set up development environment, clone the repository and open `Firmware/HomeButtonsArduino` -folder in the *Visual Studio Code* IDE. - -## Where To Get - -You can buy *Home Buttons* on [*Tindie*](https://www.tindie.com/stores/plab/?ref=offsite_badges&utm_source=sellers_nplan&utm_medium=badges&utm_campaign=badge_medium) or -[*Lectronz*](https://www.lectronz.com/stores/plab). - -I sell on Tindie +--- -I sell on Lectronz +## What changed from upstream + +**Removed** + +- MQTT client, Home Assistant discovery, and all topic handling +- Temperature/humidity sensor (battery reporting is kept) +- The runtime MDI icon downloader (`src/mdi/`) — icons are now flashed into + SPIFFS as a data image +- The factory self-test and its serial provisioning hooks +- The mini, pro and industrial variants. The `pro_*` targets could not compile + in upstream and had not been able to for some time. + +**Added** + +- HTTPS webhook transport (`src/webhook.{h,cpp}`) +- Persistent counters and a monotonic sequence number in NVS +- `SessionState` — the post-press awake window +- A CI job that actually builds the firmware, with a flash-headroom check + +**Bugs fixed along the way** (all present upstream at `v2.6.1`) + +| Fix | Was | +|---|---| +| `__builtin_ctzll` for the wake-pin decode | `log(mask)/log(2)` on floats — wrong pin with two buttons held, undefined for a zero mask | +| Elapsed-time network timeout | Compared absolute `millis()` against the timeout | +| `sizeof()` in `_nvs_2_efuse()` | Passed eFuse *bit* counts as buffer lengths into `char[9]` — a struct overflow | +| Bounded text-shrink loops | Could spin forever when the label was narrower than one glyph | +| Zero-initialised `_efuse_burned()` buffer | Declared `uint8_t buf[8]` and read 8 *bits* | +| Task names passed as arguments | Formatted buffer passed as the format string | +| Default member initialisers on `HardwareDefinition` | Any field a revision loader missed was indeterminate | +| Zero-initialised IP string buffer | `getString` does not guarantee NUL termination at the limit | +| Read-only NVS open in `load_persisted()` | Opened read-write on every boot | +| Per-device setup AP password | `"password123"` on every unit shipped | +| Certificate **bundle**, not a pinned root | The pinned "ISRG Root X1" was the DST cross-sign, expired 2024-09-30, while the comment claimed 2030 | +| Every request validates TLS | A custom icon server got `nullptr` as the CA — HTTPS with no verification at all | + +The certificate change matters most in this deployment: the backend sits behind +Cloudflare, which rotates edge issuers, so pinning any single root would break +without warning. + +**Flash budget.** Upstream's baseline build used **95.8%** of its 0x140000 app +partition. After the strip, and with SPIFFS shrunk to make room, this build uses +**69.3%** of 0x1A0000. CI fails if it crosses 90%. --- ## License -- The software is **open source** and licensed under the GNU GPLv3. -- The hardware is **open source** and licensed under the CERN-OHL-S-2.0. +The firmware is **GPLv3**, inherited from upstream — see `Firmware/LICENSE.txt`. +Hardware designs remain **CERN-OHL-S-2.0** (`Hardware/LICENSE.txt`). + +Original work © *PLab* / [nplan](https://github.com/nplan). Modifications +described above. This fork is not affiliated with or endorsed by PLab. From 3573ef36b3f18f6e17a4058c55209cb8d08c93c5 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sat, 25 Jul 2026 21:51:46 +0200 Subject: [PATCH 02/41] CI: include ota_data_initial.bin in the artifact bundle The partition layout changed in this fork, so a device coming from stock firmware has to be flashed over USB with otadata reset - otherwise the bootloader can follow a stale ota_1 pointer into what is now a different offset. The artifact bundle was missing that file, which made it insufficient for a clean flash. Ship partitions.csv alongside it as a record of the layout each build was made for. Co-Authored-By: Claude --- .github/workflows/firmware.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 40b2a1a..d28d2d2 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -79,5 +79,7 @@ jobs: Firmware/HomeButtonsArduino/.pio/build/original_release/firmware.bin Firmware/HomeButtonsArduino/.pio/build/original_release/bootloader.bin Firmware/HomeButtonsArduino/.pio/build/original_release/partitions.bin + Firmware/HomeButtonsArduino/.pio/build/original_release/ota_data_initial.bin Firmware/HomeButtonsArduino/.pio/build/original_release/spiffs.bin + Firmware/HomeButtonsArduino/partitions.csv if-no-files-found: error From 1ba7557a893e4a60d52574ece9db65aca75b3b25 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sat, 25 Jul 2026 21:59:38 +0200 Subject: [PATCH 03/41] CI: flatten the flash-image artifact upload-artifact derives the archive layout from the common ancestor of the paths it is given, so listing the images by their build-tree location buried them under .pio/build/original_release/ in the download - four levels deep before you reach a file you can flash. Stage them into a single directory instead so unzipping the artifact gives the five images and partitions.csv side by side. Co-Authored-By: Claude --- .github/workflows/firmware.yml | 27 +++++++++++++++++++------- Firmware/HomeButtonsArduino/.gitignore | 3 +++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index d28d2d2..008c26c 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -72,14 +72,27 @@ jobs: exit 1 fi + # upload-artifact derives the archive layout from the common ancestor of + # its paths, so listing files by their build-tree location buries them + # under .pio/build/original_release/ in the download. Stage them in one + # flat directory instead, so unzipping gives you the images next to each + # other, ready to flash. + - name: Stage flash images + working-directory: Firmware/HomeButtonsArduino + run: | + set -euo pipefail + mkdir -p dist + cp .pio/build/original_release/bootloader.bin \ + .pio/build/original_release/partitions.bin \ + .pio/build/original_release/ota_data_initial.bin \ + .pio/build/original_release/firmware.bin \ + .pio/build/original_release/spiffs.bin \ + partitions.csv \ + dist/ + ls -l dist + - uses: actions/upload-artifact@v4 with: name: firmware-original - path: | - Firmware/HomeButtonsArduino/.pio/build/original_release/firmware.bin - Firmware/HomeButtonsArduino/.pio/build/original_release/bootloader.bin - Firmware/HomeButtonsArduino/.pio/build/original_release/partitions.bin - Firmware/HomeButtonsArduino/.pio/build/original_release/ota_data_initial.bin - Firmware/HomeButtonsArduino/.pio/build/original_release/spiffs.bin - Firmware/HomeButtonsArduino/partitions.csv + path: Firmware/HomeButtonsArduino/dist/ if-no-files-found: error diff --git a/Firmware/HomeButtonsArduino/.gitignore b/Firmware/HomeButtonsArduino/.gitignore index 8a36508..0790365 100644 --- a/Firmware/HomeButtonsArduino/.gitignore +++ b/Firmware/HomeButtonsArduino/.gitignore @@ -14,3 +14,6 @@ sdkconfig.pro_debug # Generated per-build and deleted by pre_script.py sdkconfig.original_release sdkconfig.original_debug + +# CI artifact staging dir +dist/ From 1b7bb02fb16cabda218290902b99c8a57f0719bc Mon Sep 17 00:00:00 2001 From: Alex K Date: Sat, 25 Jul 2026 22:28:33 +0200 Subject: [PATCH 04/41] Derive the setup AP password from the serial, not the random id The AP SSID is "HB-" and is broadcast, so deriving the password from the same random id made it readable over the air by anyone in range - no better than the hardcoded "password123" it replaced, just less obvious. Use the serial number instead. It does not appear in the SSID, and it is recoverable from the device itself via the Device Info screen, which shows the unique id as HBTNS--. Also adds docs/counter-guide.md covering flashing offsets, the setup portal, the n8n receiver, troubleshooting and the compile-time tunables, and points the README at it. The rest of docs/ is inherited from upstream and documents the MQTT firmware, which no longer applies here - called out in both places rather than silently left to mislead. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/src/state.cpp | 7 +- README.md | 15 +- docs/counter-guide.md | 192 ++++++++++++++++++++++ 3 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 docs/counter-guide.md diff --git a/Firmware/HomeButtonsArduino/src/state.cpp b/Firmware/HomeButtonsArduino/src/state.cpp index b8e7f06..0e7fbab 100644 --- a/Firmware/HomeButtonsArduino/src/state.cpp +++ b/Firmware/HomeButtonsArduino/src/state.cpp @@ -154,7 +154,12 @@ void DeviceState::_load_factory(HardwareDefinition& hw) { factory_.model_id = hw.get_model_id(); factory_.hw_version = hw.get_hw_version(); factory_.unique_id = hw.get_unique_id(); - ap_password_ = APPasswordType("HB-") + factory_.random_id.c_str(); + // Derived from the serial number, NOT the random id: the AP SSID is + // "HB-" and is broadcast, so a password built from the random + // id would be readable over the air by anyone in range. The serial does + // not appear in the SSID; it is shown on the Device Info screen as part + // of the unique id (HBTNS--). + ap_password_ = APPasswordType("HB-") + factory_.serial_number.c_str(); } void DeviceState::save_all() { diff --git a/README.md b/README.md index 9910e4d..6afda07 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,17 @@ the wrong timestamp. ## Setup -Hold two buttons for 5 s → settings → setup. The device starts a Wi-Fi access -point; its password is derived per-device from the random ID printed on the -case (upstream shipped the same hardcoded password on every unit). +**→ [Configuration guide](docs/counter-guide.md)** — flashing, the setup portal, +the n8n receiver, troubleshooting, and the compile-time tunables. -Portal fields: device name, **webhook URL**, **auth token**, static IP settings, -and the six button labels. +The short version: hold any two buttons for 5 s → settings → button 1. The +device starts an access point `HB-` with password +`HB-`, both unique per device and taken from the burnt eFuse. +Portal fields are device name, **webhook URL**, **auth token**, static IP +settings, and the six button labels. + +> The remaining pages under `docs/` are inherited from upstream and describe +> the MQTT firmware. They do not apply to this fork. ## Building diff --git a/docs/counter-guide.md b/docs/counter-guide.md new file mode 100644 index 0000000..0723e53 --- /dev/null +++ b/docs/counter-guide.md @@ -0,0 +1,192 @@ +# Counter — configuration guide + +Everything needed to take a Home Buttons Original from a stock unit to a +working two-channel counter posting into n8n. + +> The other pages under `docs/` are inherited from upstream and describe the +> **MQTT** firmware. They do not apply to this fork — there is no MQTT, no +> Home Assistant discovery and no temperature sensor here. + +--- + +## 1. Flash + +The partition layout differs from upstream, so this cannot be applied over +OTA — it has to go on over USB. + +Grab the `firmware-original` artifact from a +[CI run](https://github.com/sengine-cloud/HomeButtons/actions), unzip, and: + +```bash +# Recommended: start clean. eFuse identity (serial, model, HW rev) is burnt +# and survives this — only settings and Wi-Fi credentials are cleared. +esptool.py --chip esp32s2 --port /dev/ttyACM0 erase_flash + +esptool.py --chip esp32s2 --port /dev/ttyACM0 --baud 921600 \ + --before default_reset --after hard_reset \ + write_flash -z --flash_mode dio --flash_freq 80m --flash_size 4MB \ + 0x1000 bootloader.bin \ + 0x8000 partitions.bin \ + 0xe000 ota_data_initial.bin \ + 0x10000 firmware.bin \ + 0x350000 spiffs.bin +``` + +Or from a checkout: `pio run -e original_release -t upload -t uploadfs`. + +**Erase first if the device previously ran stock firmware.** NVS survives a +plain flash, which means `setup_done` stays `true` while the new webhook +fields are empty — the device boots, counts locally, and silently logs +`no endpoint configured, dropping event`. + +## 2. Enter setup + +| Action | Result | +|---|---| +| Hold **any two buttons, 5 s** | Settings menu | +| Then press **button 1** | Setup portal (Wi-Fi + all settings) | +| Then press **button 2** | Wi-Fi only | +| Then press **button 3** | Restart | +| Then press **button 4** | Cancel | +| In settings, hold **button 1, 2 s** | Device Info screen | +| In settings, hold **button 3, 10 s** | Factory reset | + +The settings menu times out after 30 s. + +## 3. Join the setup access point + +| | | +|---|---| +| **SSID** | `HB-` — e.g. `HB-123XYZ` | +| **Password** | `HB-` — e.g. `HB-24011234` | + +Both come from the burnt eFuse and are unique per device. The serial number +is *not* part of the SSID, which is why the password uses it — a password +built from the random ID would be readable over the air from the broadcast +SSID alone. + +If you don't know the serial, read it off the **Device Info** screen: it is +the middle section of the unique ID, `HBTNS--`. + +The portal opens at `http://192.168.4.1` and closes after 10 minutes. + +## 4. Portal fields + +| Field | Notes | +|---|---| +| **Device Name** | Cosmetic; shown on the settings screen | +| **Webhook URL** | Full HTTPS URL of the n8n **production** webhook. Max 128 chars | +| **Auth Token** | Sent as `Authorization: Bearer `. Max 128 chars. Masked in the page | +| Static IP / Gateway / Subnet / DNS / DNS 2 | Optional — leave blank for DHCP. All three of IP, gateway and subnet must be set for static to apply | +| Button 1-6 Label | See below | + +**HTTPS is required.** The device attaches the ESP-IDF root CA bundle and +verifies the chain; a plain `http://` URL or an untrusted certificate will +fail the POST. + +### Button labels + +| Button | Role | Label behaviour | +|---|---|---| +| 1 | Counter **A** +1 | **Overwritten** each press with `A ` | +| 2 | Counter **A** −1 | Yours to set. Default `A -1` | +| 3 | Counter **B** +1 | **Overwritten** each press with `B ` | +| 4 | Counter **B** −1 | Yours to set. Default `B -1` | +| 5, 6 | unassigned | Yours to set. A press is ignored | + +Labels 1 and 3 carry the running totals, so anything you type there is +replaced on the next press. Labels support `mdi:` for an icon and +`mdi: Text` for both — `plus` and `minus` ship in the SPIFFS image; +any other name renders a placeholder glyph, since there is no runtime icon +download in this fork. + +Counters clamp to `0 … 999999`. Decrementing at zero is a no-op, not an +error. + +## 5. n8n receiver + +Add a **Webhook** node: + +| Setting | Value | +|---|---| +| HTTP Method | `POST` | +| Path | anything; use the **production** URL in the device | +| Authentication | **Header Auth** — name `Authorization`, value `Bearer ` | +| Respond | **Immediately**, or via a *Respond to Webhook* node placed **before** the Telegram step | + +Respond mode matters: on *When Last Node Finishes* the device holds the +connection open until Telegram round-trips, burning awake time on battery +for a result it does not use. + +Request body: + +```jsonc +{ + "device": "HBTNS-24011234-123XYZ", + "seq": 837, // monotonic per device + "event": "press", // or "heartbeat" + "counter": "a", // "a" or "b" + "button": 1, + "delta": 1, // +1 or -1 + "count": 42, // absolute — this is the authoritative value + "age_ms": 0, // >0 if this is a delayed retry + "battery_pct": 87, + "battery_v": 3.92, + "sw_version": "v3.0.0-counter.1" +} +``` + +Two things that make the workflow simple: + +- **Store `count` directly.** It is absolute, so it is a plain upsert — no + read-modify-write, no `$getWorkflowStaticData`, no concurrency handling. + A dropped request repairs itself on the next press. +- **Dedupe on `seq`.** Keep the last seen value per `device` and + short-circuit on a repeat. This is the only state you need, and it is what + stops a retry firing a second Telegram message. + +Use `age_ms` to back-date: the device has no clock, so stamp the time on +receipt and subtract `age_ms` for a delayed delivery. + +A `"event": "heartbeat"` request arrives on the timer wake (default every +12 h) carrying battery level only — no `counter`, `delta` or `count`. + +## 6. Verify + +1. Press button 1. The LED blinks immediately — that is local, and confirms + nothing about the network. +2. The display updates within a second or two. +3. n8n shows an execution; Telegram gets a message. +4. Press three more times in quick succession. Only the first should be + slow — presses 2-4 reuse the open TLS session and land in ~200 ms. +5. After ~30 s of no input the device goes back to deep sleep. + +Serial at 115200 baud shows the whole flow (`pio device monitor`). + +## 7. Troubleshooting + +| Symptom | Cause | +|---|---| +| Count moves, nothing in n8n | No webhook URL set, or `setup_done` carried over from stock firmware. Log line: `no endpoint configured, dropping event` | +| `post failed (-1)` | TLS failure — check the URL is `https://`, and that the cert chains to a public root | +| `post rejected (403)` | Token mismatch, or Cloudflare Bot Fight Mode challenging the device. Exclude the webhook path with a WAF rule | +| `post rejected (404)` | Using the n8n **test** URL (`/webhook-test/…`), which only answers while the editor is listening | +| Two Telegram messages for one press | `seq` dedupe not implemented in the workflow | +| `Check connection!` on screen | Five consecutive failed timer-wake connections | +| Device never sleeps | It is on USB power, so it stays in awake mode | +| Placeholder glyph instead of an icon | Icon not in the SPIFFS image — only `plus` and `minus` ship. Re-run `tools/make_icons.py` and `-t uploadfs` | + +## 8. Compile-time settings + +These have no portal field and need a rebuild — all in +`Firmware/HomeButtonsArduino/src/config.h`: + +| Constant | Default | What it does | +|---|---|---| +| `SESSION_IDLE_TIMEOUT` | `30000` ms | How long to stay awake after a press. Your original ask was ~2 min; raise if a burst spans longer gaps | +| `HEARTBEAT_INTERVAL_DFLT` | `720` min | Battery-only timer wake | +| `HTTP_TIMEOUT` | `10000` ms | Per-attempt timeout | +| `HTTP_MAX_ATTEMPTS` | `3` | Retries per press, within the awake window | +| `COUNTER_MIN` / `COUNTER_MAX` | `0` / `999999` | Clamp range | +| `BTN_COUNTER_*` | 1-4 | Which button drives which counter | +| `COUNTER_NAMES` | `"a"`, `"b"` | The `counter` field in the payload | From b5b29ef420324cefc6da2593fdd66249af5c6370 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 09:03:13 +0200 Subject: [PATCH 05/41] Route the debug build's console to USB CDC, add an Awake Mode toggle Logs now come out of the USB-C connector in original_debug, so reading them no longer requires wiring a serial adapter to the CMSIS-DAP header. Debug build only. Release stays on UART0 on purpose: UART works from the first ROM bootloader byte, survives deep sleep, and lets the device run on battery while you read it. USB CDC does none of those, and release is the build that runs untethered. ESP32-S2 has no USB_SERIAL_JTAG peripheral, so this goes through the USB OTG controller and TinyUSB. Deep sleep tears the USB device down, which would make the port drop on every wake. Awake Mode fixes that, but with MQTT removed there was no way left to reach user_awake_mode - upstream set it over MQTT, so the flag was read on every boot and written by nothing. It is now a portal field, which both makes USB logging usable and un-orphans the flag. Verified in the generated sdkconfig for each env: original_release CONFIG_ESP_CONSOLE_UART_DEFAULT=y original_debug CONFIG_ESP_CONSOLE_USB_CDC=y Co-Authored-By: Claude --- .../sdkconfig.original_debug.defaults | 19 ++++++++++ Firmware/HomeButtonsArduino/src/setup.cpp | 14 +++++++ docs/counter-guide.md | 38 ++++++++++++++++++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/Firmware/HomeButtonsArduino/sdkconfig.original_debug.defaults b/Firmware/HomeButtonsArduino/sdkconfig.original_debug.defaults index a00bf59..3908c40 100644 --- a/Firmware/HomeButtonsArduino/sdkconfig.original_debug.defaults +++ b/Firmware/HomeButtonsArduino/sdkconfig.original_debug.defaults @@ -9,3 +9,22 @@ CONFIG_ARDUHAL_LOG_DEFAULT_LEVEL_DEBUG=y CONFIG_FREERTOS_USE_TRACE_FACILITY=y CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y + +# Route the console to USB CDC so logs come out of the USB-C connector +# instead of the TX/RX pins on the CMSIS-DAP header. +# +# Debug build only. Release stays on UART0 deliberately: UART works from +# the first ROM bootloader byte, survives deep sleep, and lets the device +# run on battery while you read logs. USB CDC does none of those, and the +# release build is the one that runs untethered. +# +# Caveats that apply here: +# - The port only exists after USB enumeration, so bootloader and early +# IDF output is still lost. Anything boot-related needs the UART pins. +# - Deep sleep tears the USB device down; the port disappears and +# re-enumerates on every wake. Turn on Awake Mode in the setup portal +# while debugging to keep the device up and the port stable. +# - ESP32-S2 has no USB_SERIAL_JTAG peripheral, so this goes through the +# USB OTG controller and TinyUSB, not a dedicated console block. +CONFIG_ESP_CONSOLE_UART_DEFAULT=n +CONFIG_ESP_CONSOLE_USB_CDC=y diff --git a/Firmware/HomeButtonsArduino/src/setup.cpp b/Firmware/HomeButtonsArduino/src/setup.cpp index a6cb3c0..91e92eb 100644 --- a/Firmware/HomeButtonsArduino/src/setup.cpp +++ b/Firmware/HomeButtonsArduino/src/setup.cpp @@ -22,6 +22,12 @@ static WiFiManagerParameter endpoint_url_param("endpoint", "Webhook URL", "", static WiFiManagerParameter auth_token_param("auth_token", "Auth Token", "", AUTH_TOKEN_MAXLEN, "type=\"password\""); +// Keeps the device from deep sleeping. Needed to hold a USB CDC console +// open while debugging, since sleep tears the USB device down. Upstream set +// this over MQTT; with MQTT gone the portal is the only way to reach it. +// Drains the battery quickly - leave it off for normal use. +static WiFiManagerParameter awake_mode_param( + "awake_mode", "Awake Mode (debug, drains battery) - 1 or 0", "", 1); static WiFiManagerParameter static_ip_param("static_ip", "Static IP", "", 15); static WiFiManagerParameter gateway_param("gateway", "Gateway", "", 15); static WiFiManagerParameter subnet_param("subnet", "Subnet Mask", "", 15); @@ -175,6 +181,11 @@ void HBSetup::save_params_callback() { app_.device_state_.set_endpoint_url( EndpointUrlType{endpoint_url_param.getValue()}); app_.device_state_.set_auth_token(AuthTokenType{auth_token_param.getValue()}); + { + const char* v = awake_mode_param.getValue(); + app_.device_state_.persisted().user_awake_mode = + (v != nullptr && (v[0] == '1' || v[0] == 'y' || v[0] == 'Y')); + } #if defined(HAS_DISPLAY) set_device_state_from_btn_label_params(app_.device_state_); @@ -212,6 +223,8 @@ void HBSetup::start_setup() { ENDPOINT_URL_MAXLEN); auth_token_param.setValue(app_.device_state_.auth_token().c_str(), AUTH_TOKEN_MAXLEN); + awake_mode_param.setValue( + app_.device_state_.persisted().user_awake_mode ? "1" : "0", 1); static_ip_param.setValue(app_.device_state_.user_preferences() .network.static_ip.toString() .c_str(), @@ -236,6 +249,7 @@ void HBSetup::start_setup() { wifi_manager.addParameter(&device_name_param); wifi_manager.addParameter(&endpoint_url_param); wifi_manager.addParameter(&auth_token_param); + wifi_manager.addParameter(&awake_mode_param); wifi_manager.addParameter(&static_ip_param); wifi_manager.addParameter(&gateway_param); wifi_manager.addParameter(&subnet_param); diff --git a/docs/counter-guide.md b/docs/counter-guide.md index 0723e53..ba0db8f 100644 --- a/docs/counter-guide.md +++ b/docs/counter-guide.md @@ -77,6 +77,7 @@ The portal opens at `http://192.168.4.1` and closes after 10 minutes. | **Device Name** | Cosmetic; shown on the settings screen | | **Webhook URL** | Full HTTPS URL of the n8n **production** webhook. Max 128 chars | | **Auth Token** | Sent as `Authorization: Bearer `. Max 128 chars. Masked in the page | +| **Awake Mode** | `1` keeps the device from deep sleeping — needed to hold a USB serial console open. `0` for normal use. Drains the battery fast | | Static IP / Gateway / Subnet / DNS / DNS 2 | Optional — leave blank for DHCP. All three of IP, gateway and subnet must be set for static to apply | | Button 1-6 Label | See below | @@ -176,7 +177,42 @@ Serial at 115200 baud shows the whole flow (`pio device monitor`). | Device never sleeps | It is on USB power, so it stays in awake mode | | Placeholder glyph instead of an icon | Icon not in the SPIFFS image — only `plus` and `minus` ship. Re-run `tools/make_icons.py` and `-t uploadfs` | -## 8. Compile-time settings +## 8. Reading logs + +Two routes, and they differ by build: + +| Build | Console | How to read it | +|---|---|---| +| `original_release` | **UART0**, 115200 | `TX` + `GND` on the CMSIS-DAP header, via a 3.3 V USB-serial adapter | +| `original_debug` | **USB CDC**, 115200 | Just the USB-C cable | + +```bash +pio run -e original_debug -t upload && pio device monitor +``` + +The `esp32_exception_decoder` filter is preconfigured, so panics come back +symbolised. + +**For USB CDC, set Awake Mode to `1` first.** Deep sleep tears the USB device +down, so the port disappears and re-enumerates on every wake and your +terminal drops — right across the transition you probably want to watch. + +Two things USB CDC cannot do, by construction: + +- **Early boot is lost.** The port only exists after USB enumeration, so ROM + bootloader and early IDF output never appear. Anything boot-related needs + the UART pins. +- **It changes what you are observing.** USB-C supplies power, so + `is_dc_connected()` goes true and the device may pick awake mode on its + own. With the UART header you connect only `TX` and `GND` — leave `5V` + and `3V3` alone — and the device keeps running on battery, so you see the + real wake → connect → post → session → sleep cycle. + +A CMSIS-DAP probe on the same header gives gdb as well: +`pio run -e original_debug -t upload` then `pio debug`, using the +`esp32s2_cmsisdap.cfg` already in the repo. + +## 9. Compile-time settings These have no portal field and need a rebuild — all in `Firmware/HomeButtonsArduino/src/config.h`: From 14e17a3f430bd7fb81f0fe02c3a22b572ff4ff02 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 09:10:30 +0200 Subject: [PATCH 06/41] Make the button LED report delivery, not wakefulness The LED latched on at the first press and stayed lit for the whole awake session, so it read as "device is awake" rather than "press not yet delivered". Rewire it around the webhook result instead: solid press accepted, POST in flight dark delivered (2xx) 3 fast blinks send failed - counter moved locally, notification did not 2 fast blinks button is not bound to a counter Presses no longer blink from the state handlers; _handle_counter_press owns lighting the LED and _flush_pending owns clearing it, so there is one place that decides what the light means. Tracking this per press was not enough. Pressing a button again while its first press was still in flight lit the LED, and then the first press's 200 cleared it while the second was still queued - dark with a press pending. The LED is per button, so the state has to be too: inflight_ counts queued -but-unconfirmed presses per button and the LED is only released when that returns to zero. It is incremented on the UI task and decremented on the main task, hence atomic. send_failed_ carries a failure across the rest of a burst so the button ends on the error pattern rather than just going out. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/src/app.cpp | 33 ++++++++++++++++++++++--- Firmware/HomeButtonsArduino/src/app.h | 13 ++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index 0265340..b82930e 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -257,6 +257,8 @@ void App::_handle_counter_press(uint8_t btn_id) { int32_t delta = 0; if (!_btn_to_counter(btn_id, idx, delta)) { debug("button %u is not assigned to a counter", btn_id); + // Two quick blinks: registered, but nothing is bound to this button. + bsl_input_.LEDBlink(btn_id, 2, 0, 0, 0, false); return; } @@ -267,6 +269,14 @@ void App::_handle_counter_press(uint8_t btn_id) { _refresh_counter_labels(); device_state_.flags().display_redraw = true; + // Solid while the press is in flight. _flush_pending() clears it once + // every press on this button has been delivered, so the LED reports + // delivery rather than merely "the device is awake". + if (inflight_[btn_id - 1].fetch_add(1) == 0) { + send_failed_[btn_id - 1] = false; // start of a fresh burst + } + bsl_input_.LEDOn(btn_id); + PressQueueElement element{}; element.event.counter_idx = idx; element.event.button_id = btn_id; @@ -281,6 +291,10 @@ void App::_handle_counter_press(uint8_t btn_id) { // lost. The next delivered press carries the corrected absolute count. error("press queue full, event for counter %s not sent", COUNTER_NAMES[idx]); + send_failed_[btn_id - 1] = true; + if (inflight_[btn_id - 1].fetch_sub(1) == 1) { + bsl_input_.LEDBlink(btn_id, 3, 0, 0, 0, false); + } } } @@ -296,8 +310,23 @@ void App::_flush_pending() { PressQueueElement element; while (xQueueReceive(press_queue_, &element, 0) == pdTRUE) { element.event.age_ms = millis() - element.queued_at; + const uint8_t btn = element.event.button_id; if (!webhook_.send_press(element.event)) { warning("failed to deliver press seq %u", element.event.seq); + if (btn >= 1 && btn <= NUM_BUTTONS) send_failed_[btn - 1] = true; + } + // Only release the LED once nothing is left in flight for this button: + // a press that lands while a later one is still queued must not take + // the light out from under it. + if (btn >= 1 && btn <= NUM_BUTTONS && + inflight_[btn - 1].fetch_sub(1) == 1) { + if (send_failed_[btn - 1]) { + // Three fast blinks, then dark. Distinguishable from the solid + // in-flight state and from the two-blink unassigned pattern. + bsl_input_.LEDBlink(btn, 3, 0, 0, 0, false); + } else { + bsl_input_.LEDOff(btn); + } } esp_task_wdt_reset(); } @@ -628,7 +657,6 @@ void AppSMStates::AwakeModeIdleState::handle_ui_event(UserInput::Event event) { switch (event.type) { case UserInput::EventType::kClickSingle: sm()._handle_counter_press(event.btn_id); - sm().bsl_input_.LEDBlink(event.btn_id, 1, 0, 0, 0, false); break; default: break; @@ -687,7 +715,6 @@ void AppSMStates::SleepModeHandleInput::handle_ui_event( ESP.restart(); } sm()._handle_counter_press(event.btn_id); - sm().bsl_input_.LEDBlink(event.btn_id, 1, 0, 0, 0, true); if (sm().device_state_.sensors().battery_low) { sm().display_.disp_message_large(BATT_EMPTY_MSG, 3000); } @@ -757,7 +784,6 @@ void AppSMStates::NetConnectingState::handle_ui_event(UserInput::Event event) { if (event.final && event.type == UserInput::EventType::kClickSingle) { // Queued now, delivered as soon as the link comes up. sm()._handle_counter_press(event.btn_id); - sm().bsl_input_.LEDBlink(event.btn_id, 1, 0, 0, 0, true); } } @@ -794,7 +820,6 @@ void AppSMStates::SessionState::handle_ui_event(UserInput::Event event) { if (event.final) { if (event.type == UserInput::EventType::kClickSingle) { sm()._handle_counter_press(event.btn_id); - sm().bsl_input_.LEDBlink(event.btn_id, 1, 0, 0, 0, false); } } else { switch (event.type) { diff --git a/Firmware/HomeButtonsArduino/src/app.h b/Firmware/HomeButtonsArduino/src/app.h index 72cf485..a43dbb6 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -2,6 +2,7 @@ #define HOMEBUTTONS_APP_H #include +#include #include "freertos/FreeRTOS.h" // must precede queue.h #include "freertos/queue.h" #include "state.h" @@ -243,6 +244,18 @@ class App : public AppStateMachine, public Logger { }; QueueHandle_t press_queue_ = nullptr; + // Presses queued but not yet confirmed delivered, per button. Incremented + // on the UI task and decremented on the main task, hence atomic. + // + // The LED is only released when a button's count returns to zero. Without + // this, pressing the same button while its first press is still in flight + // would light the LED, then have the first press's 200 immediately clear + // it again - LED dark while a press was still pending. + std::array, NUM_BUTTONS> inflight_{}; + // Set if any press in the current burst failed, so the button can end on + // the error pattern rather than simply going dark. + std::array, NUM_BUTTONS> send_failed_{}; + BootCause boot_cause_; uint8_t wakeup_btn_id_ = 0; From 856b10cebcc759990e7489627ea73316c1e53595 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 09:14:56 +0200 Subject: [PATCH 07/41] Lay the counters out as columns, one per counter The buttons are physically two columns of three, so treat each column as a whole counter read top to bottom rather than pairing them across rows: 1 2 title your label or icon, no action 3 4 count shows the running total, press to add one 5 6 minus press to correct draw_main() already places even label indices on the left and odd on the right in three rows, so label N lands next to button N with no display changes needed. The number stands alone on the middle row now - the title above says what it counts, so the label no longer needs an "A "/"B " prefix, which frees the whole cell for a legible figure. Only that middle row is owned by the firmware; titles and minus glyphs stay exactly as configured. Button mapping moves from four scalar constants to BTN_COUNTER_TITLE / _INC / _DEC arrays indexed by counter, so _btn_to_counter is a loop and adding a third counter would not need new branches. Defaults are now A/B for the titles and mdi:minus for the decrements - minus.bmp already ships in the SPIFFS image. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/src/app.cpp | 42 +++++++++-------------- Firmware/HomeButtonsArduino/src/config.h | 22 ++++++++---- Firmware/HomeButtonsArduino/src/state.cpp | 10 +++--- README.md | 17 +++++++-- docs/counter-guide.md | 33 +++++++++++++----- 5 files changed, 77 insertions(+), 47 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index b82930e..20b68d0 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -216,39 +216,31 @@ void App::_start_tasks() { // --------------------------------------------------------------------------- bool App::_btn_to_counter(uint8_t btn_id, uint8_t& idx, int32_t& delta) { - switch (btn_id) { - case BTN_COUNTER_A_INC: - idx = 0; + for (uint8_t i = 0; i < NUM_COUNTERS; i++) { + if (btn_id == BTN_COUNTER_INC[i]) { + idx = i; delta = 1; return true; - case BTN_COUNTER_A_DEC: - idx = 0; - delta = -1; - return true; - case BTN_COUNTER_B_INC: - idx = 1; - delta = 1; - return true; - case BTN_COUNTER_B_DEC: - idx = 1; + } + if (btn_id == BTN_COUNTER_DEC[i]) { + idx = i; delta = -1; return true; - default: - return false; + } } + return false; // title buttons and anything unmapped } void App::_refresh_counter_labels() { - // Only the increment buttons carry the running total; the decrement and - // spare button labels stay whatever the user configured in the portal. - device_state_.set_btn_label( - BTN_COUNTER_A_INC, - ButtonLabel("A %ld", static_cast(device_state_.counter(0))) - .c_str()); - device_state_.set_btn_label( - BTN_COUNTER_B_INC, - ButtonLabel("B %ld", static_cast(device_state_.counter(1))) - .c_str()); + // Middle row only. The title above says what the counter is and the minus + // below is a fixed glyph, so both stay exactly as configured in the + // portal - only the number is owned by the firmware. + for (uint8_t i = 0; i < NUM_COUNTERS; i++) { + device_state_.set_btn_label( + BTN_COUNTER_INC[i], + ButtonLabel("%ld", static_cast(device_state_.counter(i))) + .c_str()); + } } // Runs on the UI task. RAM only: no NVS write, no HTTP. diff --git a/Firmware/HomeButtonsArduino/src/config.h b/Firmware/HomeButtonsArduino/src/config.h index f589d42..56e2ddb 100644 --- a/Firmware/HomeButtonsArduino/src/config.h +++ b/Firmware/HomeButtonsArduino/src/config.h @@ -40,13 +40,23 @@ static constexpr uint8_t BTN_LABEL_MAXLEN = 56; static constexpr uint8_t USER_MSG_MAXLEN = 64; // ------ counters ------ -// Two independent counters, each with an increment and a decrement button. -// Buttons 5 and 6 are unassigned. +// The buttons are physically two columns of three: +// +// 1 2 +// 3 4 +// 5 6 +// +// One counter per column, read top to bottom: +// row 1 title - user-set label or icon, no action +// row 2 count - shows the running total, pressing it increments +// row 3 minus - decrements, for corrections +// +// draw_main() places even label indices on the left and odd on the right, +// in three rows, so label N already lands next to button N. static constexpr uint8_t NUM_COUNTERS = 2; -static constexpr uint8_t BTN_COUNTER_A_INC = 1; -static constexpr uint8_t BTN_COUNTER_A_DEC = 2; -static constexpr uint8_t BTN_COUNTER_B_INC = 3; -static constexpr uint8_t BTN_COUNTER_B_DEC = 4; +static constexpr uint8_t BTN_COUNTER_TITLE[NUM_COUNTERS] = {1, 2}; +static constexpr uint8_t BTN_COUNTER_INC[NUM_COUNTERS] = {3, 4}; +static constexpr uint8_t BTN_COUNTER_DEC[NUM_COUNTERS] = {5, 6}; static constexpr int32_t COUNTER_MIN = 0; static constexpr int32_t COUNTER_MAX = 999999; static constexpr char COUNTER_NAMES[NUM_COUNTERS][2] = {"a", "b"}; diff --git a/Firmware/HomeButtonsArduino/src/state.cpp b/Firmware/HomeButtonsArduino/src/state.cpp index 0e7fbab..73219b4 100644 --- a/Firmware/HomeButtonsArduino/src/state.cpp +++ b/Firmware/HomeButtonsArduino/src/state.cpp @@ -37,10 +37,12 @@ void DeviceState::load_user() { user_preferences_.device_name, "device_name", (DeviceName{DEVICE_NAME_DFLT} + " " + factory_.random_id).c_str()); - // Defaults describe the counter layout: buttons 1/3 increment counters A/B - // and show the current value, buttons 2/4 decrement, 5/6 are unassigned. - static const char* kDefaultLabels[NUM_BUTTONS] = {"A 0", "A -1", "B 0", - "B -1", "", ""}; + // Two columns of three, one counter per column: + // row 1 title, user-editable + // row 2 running total, overwritten by the firmware on every press + // row 3 decrement; minus.bmp ships in the SPIFFS image + static const char* kDefaultLabels[NUM_BUTTONS] = { + "A", "B", "0", "0", "mdi:minus", "mdi:minus"}; for (int i = 0; i < NUM_BUTTONS; i++) { _load_to_static_string(user_preferences_.btn_labels[i], StaticString<9>("btn%d_txt", i + 1).c_str(), diff --git a/README.md b/README.md index 6afda07..09d1bae 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,20 @@ A fork of [**nplan/HomeButtons**](https://github.com/nplan/HomeButtons) that tur a *Home Buttons Original* (model A1) into a **two-channel tally counter** which reports each press to an HTTPS webhook. -Press `+`, the number on the e-paper display goes up and a notification fires. -Press `−` to correct a miscount. Two independent counters, four buttons, two -spare. +Press the number, it goes up on the e-paper display and a notification fires. +Press `−` below it to correct a miscount. + +The six buttons are two columns of three — one counter per column: + +``` +┌─────────┬─────────┐ +│ Bread │ Pastry │ title (yours to label, no action) +├─────────┼─────────┤ +│ 42 │ 7 │ count (press to add one) +├─────────┼─────────┤ +│ − │ − │ minus (press to correct) +└─────────┴─────────┘ +``` This is a derivative work, not a drop-in replacement for upstream firmware — MQTT and Home Assistant integration have been removed entirely. diff --git a/docs/counter-guide.md b/docs/counter-guide.md index ba0db8f..6d74cc2 100644 --- a/docs/counter-guide.md +++ b/docs/counter-guide.md @@ -87,16 +87,29 @@ fail the POST. ### Button labels +The buttons are two columns of three, one counter per column: + +``` + counter A counter B + ┌─────────┬─────────┐ +row1 │ btn 1 │ btn 2 │ title - your label or icon, no action + ├─────────┼─────────┤ +row2 │ btn 3 │ btn 4 │ count - shows the total, press to add one + ├─────────┼─────────┤ +row3 │ btn 5 │ btn 6 │ minus - press to correct + └─────────┴─────────┘ +``` + | Button | Role | Label behaviour | |---|---|---| -| 1 | Counter **A** +1 | **Overwritten** each press with `A ` | -| 2 | Counter **A** −1 | Yours to set. Default `A -1` | -| 3 | Counter **B** +1 | **Overwritten** each press with `B ` | -| 4 | Counter **B** −1 | Yours to set. Default `B -1` | -| 5, 6 | unassigned | Yours to set. A press is ignored | - -Labels 1 and 3 carry the running totals, so anything you type there is -replaced on the next press. Labels support `mdi:` for an icon and +| 1, 2 | Title | Yours to set. Defaults `A` / `B`. Pressing does nothing (two blinks) | +| 3, 4 | Count, **+1** | **Overwritten** each press with the running total | +| 5, 6 | **−1** | Yours to set. Default `mdi:minus` | + +Labels 3 and 4 carry the running totals, so anything you type there is +replaced on the next press. Everything else is left exactly as you set it. + +Labels support `mdi:` for an icon and `mdi: Text` for both — `plus` and `minus` ship in the SPIFFS image; any other name renders a placeholder glyph, since there is no runtime icon download in this fork. @@ -224,5 +237,7 @@ These have no portal field and need a rebuild — all in | `HTTP_TIMEOUT` | `10000` ms | Per-attempt timeout | | `HTTP_MAX_ATTEMPTS` | `3` | Retries per press, within the awake window | | `COUNTER_MIN` / `COUNTER_MAX` | `0` / `999999` | Clamp range | -| `BTN_COUNTER_*` | 1-4 | Which button drives which counter | +| `BTN_COUNTER_TITLE` | `{1, 2}` | Title buttons, per counter | +| `BTN_COUNTER_INC` | `{3, 4}` | Count / increment buttons | +| `BTN_COUNTER_DEC` | `{5, 6}` | Decrement buttons | | `COUNTER_NAMES` | `"a"`, `"b"` | The `counter` field in the payload | From 6a9dcfc09ae44ab2b2604638d7c415c2d83616b8 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 09:24:25 +0200 Subject: [PATCH 08/41] CI: build and publish the debug environment too USB CDC logging landed in original_debug, but the workflow only ever ran original_release - so the build you need in order to read logs over the USB-C cable was not being produced, let alone published. Build both environments and publish them as two artifacts. They are not interchangeable: original_debug routes the console to USB CDC and uses QIO flash mode, so its bootloader.bin differs from the release one as well as its firmware.bin (27408 vs 20048 bytes). Two artifacts rather than one with subdirectories, because both environments produce a firmware.bin and a bootloader.bin - a single flat directory cannot hold both, and subdirectories would reintroduce exactly the nesting the flattening removed. The headroom check now covers both and reports each on the step summary, rather than passing on release alone. Co-Authored-By: Claude --- .github/workflows/firmware.yml | 70 +++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 008c26c..a2ba446 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -45,15 +45,19 @@ jobs: - name: Install PlatformIO run: pip install --upgrade platformio + # Both environments are built and published. They are not + # interchangeable: original_debug routes the console to USB CDC and + # uses QIO flash mode, so it has its own bootloader.bin as well as its + # own firmware.bin. - name: Build firmware working-directory: Firmware/HomeButtonsArduino - run: pio run -e original_release + run: pio run -e original_release -e original_debug - name: Build SPIFFS image working-directory: Firmware/HomeButtonsArduino run: | python tools/make_icons.py - pio run -e original_release -t buildfs + pio run -e original_release -e original_debug -t buildfs # The app partition was 95.8% full before this fork stripped MQTT and # the icon downloader. Fail loudly rather than discovering at flash @@ -62,37 +66,57 @@ jobs: working-directory: Firmware/HomeButtonsArduino run: | set -euo pipefail - SIZE=$(stat -c%s .pio/build/original_release/firmware.bin) LIMIT=$((0x1A0000)) - PCT=$((SIZE * 100 / LIMIT)) - echo "firmware.bin: ${SIZE} bytes of ${LIMIT} (${PCT}%)" - echo "firmware.bin: ${SIZE} / ${LIMIT} bytes (${PCT}%)" >> "$GITHUB_STEP_SUMMARY" - if [ "$PCT" -ge 90 ]; then - echo "::error::firmware is ${PCT}% of the app partition" - exit 1 - fi + FAIL=0 + for ENV in original_release original_debug; do + SIZE=$(stat -c%s ".pio/build/${ENV}/firmware.bin") + PCT=$((SIZE * 100 / LIMIT)) + echo "${ENV}: ${SIZE} of ${LIMIT} bytes (${PCT}%)" + echo "\`${ENV}\`: ${SIZE} / ${LIMIT} bytes (${PCT}%)" >> "$GITHUB_STEP_SUMMARY" + if [ "$PCT" -ge 90 ]; then + echo "::error::${ENV} firmware is ${PCT}% of the app partition" + FAIL=1 + fi + done + exit "$FAIL" # upload-artifact derives the archive layout from the common ancestor of # its paths, so listing files by their build-tree location buries them - # under .pio/build/original_release/ in the download. Stage them in one - # flat directory instead, so unzipping gives you the images next to each - # other, ready to flash. + # under .pio/build// in the download. Stage each environment into + # its own flat directory instead, so unzipping either artifact gives + # you the images side by side, ready to flash. + # + # Two artifacts rather than one with subdirectories: both environments + # produce a firmware.bin and a bootloader.bin, so a single flat + # directory cannot hold both, and subdirectories would reintroduce the + # nesting this exists to avoid. - name: Stage flash images working-directory: Firmware/HomeButtonsArduino run: | set -euo pipefail - mkdir -p dist - cp .pio/build/original_release/bootloader.bin \ - .pio/build/original_release/partitions.bin \ - .pio/build/original_release/ota_data_initial.bin \ - .pio/build/original_release/firmware.bin \ - .pio/build/original_release/spiffs.bin \ - partitions.csv \ - dist/ - ls -l dist + for ENV in original_release original_debug; do + mkdir -p "dist/${ENV}" + cp ".pio/build/${ENV}/bootloader.bin" \ + ".pio/build/${ENV}/partitions.bin" \ + ".pio/build/${ENV}/ota_data_initial.bin" \ + ".pio/build/${ENV}/firmware.bin" \ + ".pio/build/${ENV}/spiffs.bin" \ + partitions.csv \ + "dist/${ENV}/" + echo "--- dist/${ENV} ---" + ls -l "dist/${ENV}" + done - uses: actions/upload-artifact@v4 with: name: firmware-original - path: Firmware/HomeButtonsArduino/dist/ + path: Firmware/HomeButtonsArduino/dist/original_release/ + if-no-files-found: error + + # Console on USB CDC - flash this one when you want logs over the + # USB-C cable instead of the UART pins on the debug header. + - uses: actions/upload-artifact@v4 + with: + name: firmware-original-debug + path: Firmware/HomeButtonsArduino/dist/original_debug/ if-no-files-found: error From e8690625a4e54e770ad7cc872581e5a5d7cd00e4 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 09:26:51 +0200 Subject: [PATCH 09/41] Allow baking any MDI icon in at build time, and emit 1-bit BMPs make_icons.py now takes icon names on the command line and fetches them from the same server the stock firmware pulled from at runtime: python tools/make_icons.py coffee bread-slice Names come from https://pictogrammers.com/library/mdi/. Doing this at build time rather than on the device keeps the runtime free of HTTP, certs and SPIFFS eviction, while still giving access to the whole icon set. A name that does not exist fails the build instead of becoming a placeholder glyph noticed later on the device. A plain run stays offline and reproducible: plus and minus are still drawn locally. Also switched the local drawing from 24-bit to 1-bit BMP. Every icon the server returns is 1-bit, so the 24-bit output was sending locally drawn icons down a branch of draw_bmp() that upstream's own icons never exercised - a needless difference in a path not yet tested on hardware. Output now matches the server byte for byte in structure: 574 bytes at 64px against 12342 before, offset 62, palette [black, white], set bit meaning white. Co-Authored-By: Claude --- .../HomeButtonsArduino/tools/make_icons.py | 92 ++++++++++++++++--- docs/counter-guide.md | 29 +++++- 2 files changed, 103 insertions(+), 18 deletions(-) diff --git a/Firmware/HomeButtonsArduino/tools/make_icons.py b/Firmware/HomeButtonsArduino/tools/make_icons.py index 0206248..839a467 100644 --- a/Firmware/HomeButtonsArduino/tools/make_icons.py +++ b/Firmware/HomeButtonsArduino/tools/make_icons.py @@ -2,20 +2,43 @@ """Generate the counter button icons as BMPs for the SPIFFS data image. Icons used to be downloaded at runtime from an MDI icon server. This fork -drops that whole path, so the handful of glyphs the device can show are -generated here and flashed with `pio run -t uploadfs`. +drops that from the device, so glyphs are prepared here and flashed with +`pio run -t uploadfs`. Output layout matches what Display::open_mdi_file() reads: data/mdi//.bmp +The default set (plus, minus) is drawn locally, so a plain run needs no +network and is byte-for-byte reproducible. + +Any other Material Design Icon can be baked in by name: + + python tools/make_icons.py coffee bread-slice + +Names come from https://pictogrammers.com/library/mdi/ - use the name as +shown there, lowercase and hyphenated. Those are fetched at BUILD time +from the icon server upstream used; the device still never downloads +anything, and a missing icon is a loud build failure rather than a +placeholder glyph discovered in the field. + +Set a label to `mdi:coffee` (or `mdi:coffee Beans` for icon plus text) in +the setup portal to use one. + Pure stdlib on purpose: no Pillow, so CI needs nothing extra. """ import os import struct +import sys +import urllib.request SIZES = (64, 48) OUT_ROOT = os.path.join(os.path.dirname(__file__), "..", "data", "mdi") +# Same host the stock firmware pulled from at runtime. Only contacted when +# extra icon names are requested on the command line. +ICON_SERVER = "https://icons.home-buttons.com/mdi/" +FETCH_TIMEOUT = 15 # seconds + BLACK = (0, 0, 0) WHITE = (255, 255, 255) @@ -46,25 +69,62 @@ def vbar(px, size): def write_bmp(path, px, size): - """24-bit uncompressed BMP, bottom-up. size*3 is 4-byte aligned for - both 64 and 48, so no row padding is needed - assert it rather than - silently emitting a corrupt file if SIZES ever changes.""" - row_bytes = size * 3 - assert row_bytes % 4 == 0, "row padding needed for size %d" % size + """1-bit uncompressed BMP, bottom-up, matching byte for byte what the + icon server returns: palette [black, white], a set bit meaning white. + + Deliberately 1-bit rather than 24: it is what every fetched MDI glyph + is, so the firmware's draw_bmp() takes the same, well-exercised branch + for locally drawn icons as for downloaded ones. A 64px icon is 574 + bytes this way against 12342 as 24-bit.""" + row_bytes = ((size + 31) // 32) * 4 # rows pad to a 4-byte boundary pixel_data = bytearray() for y in range(size - 1, -1, -1): + row = bytearray(row_bytes) for x in range(size): - r, g, b = px[y][x] - pixel_data += bytes((b, g, r)) - offset = 14 + 40 - header = struct.pack("<2sIHHI", b"BM", offset + len(pixel_data), 0, 0, offset) - info = struct.pack("> (x % 8) # set bit = white + pixel_data += row + + palette = bytes((0, 0, 0, 0, 255, 255, 255, 255)) # index 0 black, 1 white + offset = 14 + 40 + len(palette) + header = struct.pack("<2sIHHI", b"BM", offset + len(pixel_data), 0, 0, + offset) + info = struct.pack("` for an icon and -`mdi: Text` for both — `plus` and `minus` ship in the SPIFFS image; -any other name renders a placeholder glyph, since there is no runtime icon -download in this fork. +### Icons + +Labels support `mdi:` for an icon, or `mdi: Text` for both. + +**Icon list: ** — use the name +exactly as shown there, lowercase and hyphenated (`bread-slice`, `coffee`, +`cash-register`). + +The device downloads nothing at runtime, so an icon has to be baked into +the SPIFFS image first. `plus` and `minus` are drawn locally and always +present; anything else is fetched **at build time** and flashed: + +```bash +cd Firmware/HomeButtonsArduino +python tools/make_icons.py coffee bread-slice +pio run -e original_release -t buildfs -t uploadfs +``` + +A name that doesn't exist fails the build rather than turning into a +placeholder glyph you'd only notice on the device. + +Then set the label — e.g. button 1 to `mdi:coffee` or `mdi:coffee Beans`. + +A label naming an icon that isn't in the image renders the +`file_question_outline` placeholder. Counters clamp to `0 … 999999`. Decrementing at zero is a no-op, not an error. From ced8d9c5a811586af2beee0a617313097e532903 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 09:39:41 +0200 Subject: [PATCH 10/41] Source icons from Templarian/MaterialDesign, scale counter text up Three changes that belong together. Icons are now listed declaratively in icons.txt next to platformio.ini, one name per line. CI runs make_icons.py with no arguments, so whatever is in that file lands in the published artifacts - previously extra icons could only be passed on the command line, which meant CI never built any. Added food-drumstick and dog-service. SVGs come from the canonical Templarian/MaterialDesign repository, pinned to commit 2424e748e0cc, and are rasterised locally with ImageMagick rather than pulled pre-rendered from the stock firmware's icon CDN. That removes the dependency on a third party's hobby infrastructure staying up and makes the output reproducible from a pinned ref. Verified against the old CDN's rendering of food-drumstick: 2.1% of pixels differ, all of it edge antialiasing either side of the threshold. Costs an ImageMagick dependency at build time; the device is unchanged and still downloads nothing. The counter total was rendering in helvB24 like any other label, which left a two-digit number adrift in a cell nearly 100px tall. An all-digit label now steps down a ladder of large numeric fonts - logisoso42, logisoso32, helvB24, helvB18 - taking the first that fits half the display width, since the two counters share a row. Numbers are never trimmed: "12..." is a wrong value, not a shortened word, so an implausibly long count runs small instead. Costs 1.4 kB of flash for the two extra fonts. Co-Authored-By: Claude --- .github/workflows/firmware.yml | 10 + Firmware/HomeButtonsArduino/icons.txt | 21 ++ .../src/display/display.cpp | 32 +++- .../HomeButtonsArduino/tools/make_icons.py | 181 ++++++++++++++---- docs/counter-guide.md | 23 ++- 5 files changed, 225 insertions(+), 42 deletions(-) create mode 100644 Firmware/HomeButtonsArduino/icons.txt diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index a2ba446..779bf25 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -45,6 +45,16 @@ jobs: - name: Install PlatformIO run: pip install --upgrade platformio + # make_icons.py rasterises the MDI SVGs listed in icons.txt. Normally + # already on the runner image; install only if it is not, rather than + # paying for an apt update on every run. + - name: Ensure ImageMagick + run: | + if ! command -v convert >/dev/null && ! command -v magick >/dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq imagemagick + fi + (magick -version || convert -version) 2>/dev/null | head -1 + # Both environments are built and published. They are not # interchangeable: original_debug routes the console to USB CDC and # uses QIO flash mode, so it has its own bootloader.bin as well as its diff --git a/Firmware/HomeButtonsArduino/icons.txt b/Firmware/HomeButtonsArduino/icons.txt new file mode 100644 index 0000000..a28a836 --- /dev/null +++ b/Firmware/HomeButtonsArduino/icons.txt @@ -0,0 +1,21 @@ +# Material Design Icons baked into the SPIFFS image. +# +# One name per line. Browse and search names at: +# https://pictogrammers.com/library/mdi/ +# Use the name exactly as shown there - lowercase and hyphenated. +# +# These are fetched at BUILD time by tools/make_icons.py and flashed into +# SPIFFS; the device never downloads anything at runtime. CI reads this +# file too, so anything listed here ends up in the published artifacts. +# +# A name that does not exist fails the build rather than turning into a +# placeholder glyph you would only notice on the device. +# +# `plus` and `minus` are drawn locally by the script and are always +# present - no need to list them. +# +# Reference one from a button label as `mdi:food-drumstick`, or +# `mdi:food-drumstick Wings` for icon plus text. + +food-drumstick +dog-service diff --git a/Firmware/HomeButtonsArduino/src/display/display.cpp b/Firmware/HomeButtonsArduino/src/display/display.cpp index 253b160..4f03843 100644 --- a/Firmware/HomeButtonsArduino/src/display/display.cpp +++ b/Firmware/HomeButtonsArduino/src/display/display.cpp @@ -68,6 +68,18 @@ ButtonLabel Display::get_text(ButtonLabel label) { } } +// True when a label is nothing but a number, optionally negative - which in +// practice means a counter total. Those get a much larger font and are never +// trimmed, so a count reads at a glance from across the room. +static bool is_numeric_label(const ButtonLabel& label) { + size_t i = (label[0] == '-') ? 1 : 0; + if (i >= label.length()) return false; // empty, or "-" on its own + for (; i < label.length(); i++) { + if (label[i] < '0' || label[i] > '9') return false; + } + return true; +} + // Shrinks label one character at a time, keeping a trailing ".", until it is // narrower than max_width. The length() > 1 bound is load bearing: without it // a max_width smaller than a single glyph underflows length() - 2 to SIZE_MAX, @@ -441,7 +453,21 @@ void Display::draw_main() { u8g2.print(text.c_str()); } else { uint16_t max_label_width = WIDTH - min_btn_clearance; - if (label.index_of('_') == 0) { + const bool numeric = is_numeric_label(label); + if (numeric) { + // A counter total shares its row with the other counter, so budget + // half the display. helvB24 leaves a two-digit count adrift in a + // cell nearly 100px tall, so step down a ladder of large numeric + // fonts instead and take the first that fits. + max_label_width = WIDTH / 2 - h_padding * 2; + static const uint8_t* const kNumericFonts[] = { + u8g2_font_logisoso42_tn, u8g2_font_logisoso32_tn, + u8g2_font_helvB24_te, u8g2_font_helvB18_te}; + for (const uint8_t* font : kNumericFonts) { + u8g2.setFont(font); + if (u8g2.getUTF8Width(label.c_str()) < max_label_width) break; + } + } else if (label.index_of('_') == 0) { // force small font label = label.substring(1); u8g2.setFont(u8g2_font_helvB18_te); @@ -450,7 +476,9 @@ void Display::draw_main() { } uint16_t w, h; w = u8g2.getUTF8Width(label.c_str()); - if (w >= max_label_width) { + // Numbers are never trimmed: "12..." is a wrong value, not a + // shortened word. An implausibly long count just runs small. + if (!numeric && w >= max_label_width) { u8g2.setFont(u8g2_font_helvB18_te); // trim_text() is bounded, so this always terminates label = trim_text(label, max_label_width); diff --git a/Firmware/HomeButtonsArduino/tools/make_icons.py b/Firmware/HomeButtonsArduino/tools/make_icons.py index 839a467..b28a231 100644 --- a/Firmware/HomeButtonsArduino/tools/make_icons.py +++ b/Firmware/HomeButtonsArduino/tools/make_icons.py @@ -11,33 +11,61 @@ The default set (plus, minus) is drawn locally, so a plain run needs no network and is byte-for-byte reproducible. -Any other Material Design Icon can be baked in by name: +Any other Material Design Icon is baked in by listing it in icons.txt +next to platformio.ini, one name per line. CI runs this script with no +arguments, so whatever is in that file ends up in the artifacts. Names +can also be passed on the command line for a one-off: python tools/make_icons.py coffee bread-slice Names come from https://pictogrammers.com/library/mdi/ - use the name as -shown there, lowercase and hyphenated. Those are fetched at BUILD time -from the icon server upstream used; the device still never downloads -anything, and a missing icon is a loud build failure rather than a -placeholder glyph discovered in the field. +shown there, lowercase and hyphenated. + +SVGs are pulled at BUILD time straight from the canonical Material Design +Icons repository, pinned to a commit, and rasterised locally with +ImageMagick. The device still never downloads anything, and a missing +icon is a loud build failure rather than a placeholder glyph discovered +in the field. + +Going to the source rather than the stock firmware's icon CDN means no +dependency on a third party's hobby infrastructure staying up, and a +pinned ref means the same commit produces the same icons. + +MDI icons are under the Pictogrammers Free License; see +https://github.com/Templarian/MaterialDesign/blob/master/LICENSE + +Requires ImageMagick (`convert` or `magick`) on PATH, but no Python +packages - the BMP writer below is stdlib. Set a label to `mdi:coffee` (or `mdi:coffee Beans` for icon plus text) in the setup portal to use one. -Pure stdlib on purpose: no Pillow, so CI needs nothing extra. """ import os +import shutil import struct +import subprocess import sys +import tempfile import urllib.request SIZES = (64, 48) OUT_ROOT = os.path.join(os.path.dirname(__file__), "..", "data", "mdi") -# Same host the stock firmware pulled from at runtime. Only contacted when -# extra icon names are requested on the command line. -ICON_SERVER = "https://icons.home-buttons.com/mdi/" +# Canonical Material Design Icons, pinned so a given commit of this repo +# always produces the same glyphs. Bump deliberately: +# gh api /repos/Templarian/MaterialDesign/commits/master --jq '.sha' +MDI_REPO = "Templarian/MaterialDesign" +MDI_REF = "2424e748e0cc" +MDI_SVG_URL = "https://raw.githubusercontent.com/%s/%s/svg/%%s.svg" % ( + MDI_REPO, MDI_REF) + FETCH_TIMEOUT = 15 # seconds +ICON_LIST = "icons.txt" # relative to the project root + +# Rendered well above target size then downsampled, which is what keeps the +# curves clean before the 1-bit threshold. +RENDER_DENSITY = 300 BLACK = (0, 0, 0) WHITE = (255, 255, 255) @@ -69,13 +97,13 @@ def vbar(px, size): def write_bmp(path, px, size): - """1-bit uncompressed BMP, bottom-up, matching byte for byte what the - icon server returns: palette [black, white], a set bit meaning white. + """1-bit uncompressed BMP, bottom-up: palette [black, white], a set + bit meaning white. - Deliberately 1-bit rather than 24: it is what every fetched MDI glyph - is, so the firmware's draw_bmp() takes the same, well-exercised branch - for locally drawn icons as for downloaded ones. A 64px icon is 574 - bytes this way against 12342 as 24-bit.""" + Deliberately 1-bit rather than 24. Every icon the stock firmware ever + rendered was 1-bit, so this keeps draw_bmp() on the one branch that + has actually seen use, for locally drawn and rendered icons alike. A + 64px icon is 574 bytes this way against 12342 as 24-bit.""" row_bytes = ((size + 31) // 32) * 4 # rows pad to a 4-byte boundary pixel_data = bytearray() for y in range(size - 1, -1, -1): @@ -95,35 +123,114 @@ def write_bmp(path, px, size): fh.write(header + info + palette + pixel_data) -def fetch_icon(name, size, out_dir): - """Pull one MDI glyph, already rasterised to a 1-bit BMP, from the - icon server. Fails the build rather than leaving a gap that would only - show up as a placeholder glyph on the device.""" - url = "%s%dx%d/%s.bmp" % (ICON_SERVER, size, size, name) +def imagemagick(): + """ImageMagick 7 renamed the CLI; accept either.""" + for exe in ("magick", "convert"): + if shutil.which(exe): + return exe + raise SystemExit( + "ImageMagick not found - install it (apt install imagemagick) or " + "drop the extra names from %s to build only the local set" + % ICON_LIST) + + +def fetch_svg(name): + url = MDI_SVG_URL % name try: with urllib.request.urlopen(url, timeout=FETCH_TIMEOUT) as resp: - if resp.status != 200: - raise RuntimeError("HTTP %s" % resp.status) - data = resp.read() + return resp.read() except Exception as exc: raise SystemExit( - "failed to fetch '%s' at %dpx from %s: %s\n" + "failed to fetch '%s' from %s: %s\n" "check the name at https://pictogrammers.com/library/mdi/" - % (name, size, url, exc) - ) - if not data.startswith(b"BM"): - raise SystemExit( - "'%s' at %dpx did not come back as a BMP - is the name right? " - "see https://pictogrammers.com/library/mdi/" % (name, size) - ) + % (name, url, exc)) + + +def parse_pbm(data): + """Minimal binary PBM (P4) reader. Returns (width, height, rows) where + a set bit means black, matching PBM's own convention.""" + if not data.startswith(b"P4"): + raise SystemExit("expected a binary PBM from ImageMagick") + fields, pos = [], 2 + while len(fields) < 2: + while pos < len(data) and data[pos:pos + 1].isspace(): + pos += 1 + if data[pos:pos + 1] == b"#": # comment runs to end of line + while pos < len(data) and data[pos:pos + 1] not in (b"\n", b"\r"): + pos += 1 + continue + start = pos + while pos < len(data) and not data[pos:pos + 1].isspace(): + pos += 1 + fields.append(int(data[start:pos])) + pos += 1 # single whitespace byte terminates the header + w, h = fields + stride = (w + 7) // 8 + return w, h, data[pos:pos + stride * h], stride + + +def render_icon(name, size, out_dir, exe): + """SVG -> ImageMagick -> PBM -> the same 1-bit BMP the local glyphs + use, so every icon on the device is byte-compatible in format.""" + svg = fetch_svg(name) + with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tf: + tf.write(svg) + svg_path = tf.name + try: + cmd = [exe] + if exe == "magick": + cmd.append("convert") + cmd += ["-background", "white", "-alpha", "remove", + "-density", str(RENDER_DENSITY), svg_path, + "-resize", "%dx%d" % (size, size), + "-gravity", "center", "-extent", "%dx%d" % (size, size), + "-colorspace", "Gray", "-threshold", "50%", "pbm:-"] + out = subprocess.run(cmd, capture_output=True) + if out.returncode != 0: + raise SystemExit("ImageMagick failed on '%s' at %dpx:\n%s" + % (name, size, out.stderr.decode(errors="replace"))) + finally: + os.unlink(svg_path) + + w, h, bits, stride = parse_pbm(out.stdout) + if (w, h) != (size, size): + raise SystemExit("'%s' rendered %dx%d, expected %dx%d" + % (name, w, h, size, size)) + + px = blank(size) + for y in range(size): + for x in range(size): + if bits[y * stride + (x // 8)] & (0x80 >> (x % 8)): + px[y][x] = BLACK # set bit is black in PBM path = os.path.join(out_dir, "%s.bmp" % name) - with open(path, "wb") as fh: - fh.write(data) - return len(data) + write_bmp(path, px, size) + return os.path.getsize(path) + + +def read_icon_list(): + """Names from icons.txt, ignoring blanks and # comments. Absent file is + not an error - it just means only plus and minus get built.""" + path = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", ICON_LIST)) + if not os.path.isfile(path): + print("no %s, building the local set only" % ICON_LIST) + return [] + names = [] + with open(path, "r") as fh: + for line in fh: + line = line.split("#", 1)[0].strip() + if line: + names.append(line) + print("%s lists %d icon(s): %s" % (ICON_LIST, len(names), + ", ".join(names) or "-")) + return names def main(): - extra = [n.strip() for n in sys.argv[1:] if n.strip()] + extra = read_icon_list() + extra += [n.strip() for n in sys.argv[1:] if n.strip()] + extra = list(dict.fromkeys(extra)) # de-dupe, keep order + exe = imagemagick() if extra else None for size in SIZES: out_dir = os.path.abspath(os.path.join(OUT_ROOT, str(size))) @@ -141,8 +248,8 @@ def main(): print("wrote %s/{plus,minus}.bmp" % out_dir) for name in extra: - n = fetch_icon(name, size, out_dir) - print("fetched %s/%s.bmp (%d bytes)" % (out_dir, name, n)) + n = render_icon(name, size, out_dir, exe) + print("rendered %s/%s.bmp (%d bytes)" % (out_dir, name, n)) if __name__ == "__main__": diff --git a/docs/counter-guide.md b/docs/counter-guide.md index 3323afd..05df763 100644 --- a/docs/counter-guide.md +++ b/docs/counter-guide.md @@ -119,18 +119,35 @@ exactly as shown there, lowercase and hyphenated (`bread-slice`, `coffee`, The device downloads nothing at runtime, so an icon has to be baked into the SPIFFS image first. `plus` and `minus` are drawn locally and always -present; anything else is fetched **at build time** and flashed: +present. Anything else goes in **`Firmware/HomeButtonsArduino/icons.txt`**, +one name per line: + +``` +food-drumstick +dog-service +``` + +Then rebuild and flash the image: ```bash cd Firmware/HomeButtonsArduino -python tools/make_icons.py coffee bread-slice +python tools/make_icons.py pio run -e original_release -t buildfs -t uploadfs ``` +CI reads the same file, so anything listed there is in the published +artifacts too. A name can also be passed on the command line for a one-off +(`python tools/make_icons.py coffee`). + +SVGs come straight from the canonical +[Templarian/MaterialDesign](https://github.com/Templarian/MaterialDesign) +repository, pinned to a commit in `make_icons.py`, and are rasterised +locally with ImageMagick — no dependency on anyone's icon CDN staying up. A name that doesn't exist fails the build rather than turning into a placeholder glyph you'd only notice on the device. -Then set the label — e.g. button 1 to `mdi:coffee` or `mdi:coffee Beans`. +Then set the label — e.g. button 1 to `mdi:food-drumstick`, or +`mdi:food-drumstick Wings` for icon plus text. A label naming an icon that isn't in the image renders the `file_question_outline` placeholder. From 85c918eb0440c98963613473bd56070110896f76 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 10:00:22 +0200 Subject: [PATCH 11/41] Build the SPIFFS image with ESP-IDF parameters, not ESP8266 Arduino ones Icons rendered as the unknown-glyph placeholder even with the image flashed. The image was fine; nothing could read it. PlatformIO packs the data directory with mkspiffs_espressif8266_arduino, which is compiled with the ESP8266 Arduino SPIFFS parameters. This project runs the ESP-IDF SPIFFS driver, configured CONFIG_SPIFFS_OBJ_NAME_LEN=56 and CONFIG_SPIFFS_META_LENGTH=4 against that tool's 32 and 0. The object index layouts differ, so the device mounted the partition, enumerated no files, and every mdi: label fell through to file_question_outline. Same 655360-byte image, 3650 bytes different in content. ESP-IDF ships spiffsgen.py for this. Regenerate the image in place as a post-action on $BUILD_DIR/spiffs.bin, so -t buildfs and -t uploadfs keep working normally rather than needing a separate command nobody remembers. Parameters are read out of the generated sdkconfig and the partition size out of partitions.csv, so neither can drift from what the firmware was compiled to read - this fork has already moved that partition once. Upstream never hit this: it downloaded icons at runtime into a filesystem the device had formatted itself, so no image was ever packed on a host. Baking the icons in at build time is what exposed it. Verified: the regenerated image is byte-identical to a manual spiffsgen.py run with the same parameters. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/post_script.py | 74 ++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/Firmware/HomeButtonsArduino/post_script.py b/Firmware/HomeButtonsArduino/post_script.py index a8c3eee..1306032 100644 --- a/Firmware/HomeButtonsArduino/post_script.py +++ b/Firmware/HomeButtonsArduino/post_script.py @@ -1,9 +1,12 @@ +import csv import os +import re import zipfile Import("env") partition_gen_path = "components/partition_table/gen_esp32part.py" +spiffsgen_path = "components/spiffs/spiffsgen.py" files_to_zip = ["firmware.bin", "bootloader.bin", "partitions.bin", "ota_data_initial.bin", "partitions.csv"] zip_filename = "firmware.zip" @@ -26,6 +29,76 @@ def create_partitions_csv(): print(f"{part_csv_path} created successfully.") +def _sdkconfig_values(): + """SPIFFS parameters straight from the generated sdkconfig, so the image + cannot drift from what the firmware was compiled to read.""" + path = os.path.join(env.subst("$PROJECT_DIR"), + "sdkconfig." + env.subst("$PIOENV")) + values = {} + with open(path) as fh: + for line in fh: + m = re.match(r"^(CONFIG_SPIFFS_\w+)=(.+)$", line.strip()) + if m: + values[m.group(1)] = m.group(2) + return values + + +def _spiffs_partition_size(): + """Size of the spiffs partition, read from partitions.csv rather than + hardcoded, since this fork already moved it once.""" + path = os.path.join(env.subst("$PROJECT_DIR"), "partitions.csv") + with open(path) as fh: + for row in csv.reader(fh): + if not row or row[0].strip().startswith("#"): + continue + if row[0].strip() == "spiffs": + return int(row[4].strip(), 0) + raise Exception("no spiffs partition in partitions.csv") + + +def rebuild_spiffs(source, target, env): + """Replace PlatformIO's SPIFFS image with one ESP-IDF can actually read. + + PlatformIO packs the data directory with mkspiffs_espressif8266_arduino, + which is built with the ESP8266 Arduino SPIFFS parameters. This project + runs the ESP-IDF SPIFFS driver with CONFIG_SPIFFS_OBJ_NAME_LEN=56 and + CONFIG_SPIFFS_META_LENGTH=4, so the object index layouts do not match: + the device mounts the image, finds no files, and every icon silently + falls back to the placeholder glyph. + + ESP-IDF ships spiffsgen.py for exactly this. Run it over the same data + directory with the parameters read out of sdkconfig and overwrite the + image in place, so `-t buildfs` and `-t uploadfs` keep working normally. + """ + image = os.path.join(env.subst("$BUILD_DIR"), "spiffs.bin") + data_dir = env.subst("$PROJECT_DATA_DIR") + if not os.path.isdir(data_dir): + print(f"no data dir at {data_dir}, leaving {image} alone") + return + + cfg = _sdkconfig_values() + package_dir = env.PioPlatform().get_package_dir("framework-espidf") + script = os.path.join(package_dir, spiffsgen_path) + + cmd = [ + "python", script, + "--page-size", cfg.get("CONFIG_SPIFFS_PAGE_SIZE", "256"), + "--obj-name-len", cfg.get("CONFIG_SPIFFS_OBJ_NAME_LEN", "32"), + "--meta-len", cfg.get("CONFIG_SPIFFS_META_LENGTH", "4"), + ] + if cfg.get("CONFIG_SPIFFS_USE_MAGIC") == "y": + cmd.append("--use-magic") + if cfg.get("CONFIG_SPIFFS_USE_MAGIC_LENGTH") == "y": + cmd.append("--use-magic-len") + cmd += [str(_spiffs_partition_size()), data_dir, image] + + print("#### REBUILDING SPIFFS IMAGE FOR ESP-IDF ####") + print(" ".join(cmd)) + if env.Execute(" ".join(f'"{c}"' if " " in c else c for c in cmd)): + raise Exception("spiffsgen.py failed") + print(f"{image} regenerated with ESP-IDF SPIFFS parameters.") + + def post_build(source, target, env): print("#### POST BUILD ####") create_partitions_csv() @@ -34,3 +107,4 @@ def post_build(source, target, env): print("#### POST SCRIPT ####") env.AddPostAction("buildprog", post_build) +env.AddPostAction("$BUILD_DIR/spiffs.bin", rebuild_spiffs) From 7d4d67f46a96fc8ed970bad2fc762b698ada0615 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 10:32:27 +0200 Subject: [PATCH 12/41] Revert the USB CDC console: it is not supported alongside TinyUSB Flashing the debug build left the device hung - enumerating as 303a:0002 and not answering esptool on either a normal or a bootloader boot. esp_system/Kconfig declares: config ESP_CONSOLE_USB_CDC depends on (IDF_TARGET_ESP32S2 || IDF_TARGET_ESP32S3) && !TINY_USB with the comment "the ROM CDC driver is currently incompatible with TinyUSB". This project builds framework = arduino, espidf, and Arduino brings TinyUSB in, so that dependency can never hold here. Forcing the symbol from a defaults file overrides the unmet dependency rather than failing the build. It compiled, and the symbol really was present in the generated sdkconfig - which is exactly what I checked, and why this got through. On hardware the ROM CDC console and TinyUSB then both drive the same USB OTG peripheral and the device hangs at boot. Checking that a config symbol is set is not the same as checking it is valid; the dependency is the thing that mattered. The file now carries that reasoning so the option is not reintroduced. Console is back on UART0 for both builds, read from TX and GND on the CMSIS-DAP header. The release build was never affected - it kept CONFIG_ESP_CONSOLE_UART_DEFAULT=y throughout, so it is the safe image to recover with. If USB logging is wanted, the supported route is Arduino's own stack, not the IDF console: -DARDUINO_USB_CDC_ON_BOOT=1 plus Serial.setDebugOutput(true), leaving one owner of the peripheral. Not attempted here, and not to be merged without a hardware test. Co-Authored-By: Claude --- .../sdkconfig.original_debug.defaults | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/Firmware/HomeButtonsArduino/sdkconfig.original_debug.defaults b/Firmware/HomeButtonsArduino/sdkconfig.original_debug.defaults index 3908c40..020d442 100644 --- a/Firmware/HomeButtonsArduino/sdkconfig.original_debug.defaults +++ b/Firmware/HomeButtonsArduino/sdkconfig.original_debug.defaults @@ -10,21 +10,29 @@ CONFIG_FREERTOS_USE_TRACE_FACILITY=y CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y -# Route the console to USB CDC so logs come out of the USB-C connector -# instead of the TX/RX pins on the CMSIS-DAP header. +# DO NOT set CONFIG_ESP_CONSOLE_USB_CDC=y here. It hangs the device. # -# Debug build only. Release stays on UART0 deliberately: UART works from -# the first ROM bootloader byte, survives deep sleep, and lets the device -# run on battery while you read logs. USB CDC does none of those, and the -# release build is the one that runs untethered. +# esp_system/Kconfig declares: +# config ESP_CONSOLE_USB_CDC +# depends on (IDF_TARGET_ESP32S2 || IDF_TARGET_ESP32S3) && !TINY_USB +# with the comment "the ROM CDC driver is currently incompatible with +# TinyUSB". This project builds framework = arduino, espidf, and Arduino +# pulls TinyUSB in (CONFIG_TINYUSB_CDC_ENABLED=y), so that dependency +# cannot be satisfied here. # -# Caveats that apply here: -# - The port only exists after USB enumeration, so bootloader and early -# IDF output is still lost. Anything boot-related needs the UART pins. -# - Deep sleep tears the USB device down; the port disappears and -# re-enumerates on every wake. Turn on Awake Mode in the setup portal -# while debugging to keep the device up and the port stable. -# - ESP32-S2 has no USB_SERIAL_JTAG peripheral, so this goes through the -# USB OTG controller and TinyUSB, not a dedicated console block. -CONFIG_ESP_CONSOLE_UART_DEFAULT=n -CONFIG_ESP_CONSOLE_USB_CDC=y +# Forcing the symbol from a defaults file overrides the unmet dependency +# instead of failing: the build succeeds and the symbol really is set in +# the generated sdkconfig, so it looks verified. On the device the ROM CDC +# console and TinyUSB then both drive the same USB OTG peripheral. It +# hangs at boot and enumerates as 303a:0002 without answering esptool, +# and has to be recovered by holding BOOT, tapping RST, and flashing with +# `--before no_reset`. +# +# The console therefore stays on UART0 for both builds - read it from TX +# and GND on the CMSIS-DAP header. +# +# If USB logging is wanted, the supported route on this project is +# Arduino's own stack rather than the IDF console: build with +# -DARDUINO_USB_CDC_ON_BOOT=1 and call Serial.setDebugOutput(true) early +# so esp_log output is redirected into Arduino's TinyUSB CDC, leaving one +# owner of the peripheral. Untested on hardware here. From aeda465781024b46b319ef9f0b4d7d1e5ee52be9 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 10:52:03 +0200 Subject: [PATCH 13/41] Place counter totals like icons, not like text Icons and text use different vertical placement in draw_main(). A full size icon sits at a fixed top per row - 17, 116, 215 - shared by both columns, so icons line up across the display. Text is placed at HEIGHT/12 + i*HEIGHT/6, which steps per button index rather than per row and yields 25, 74, 123, 172, 222, 271: the left column high, the right column low, alternating down the screen. That is defensible for captions, where each label just needs to sit near its own button. It is wrong for two counter totals that are meant to read as a pair, and it also made the number jump vertically whenever the digit count pushed it onto a different font, because the baseline was derived from the font ascent. A numeric label is now positioned exactly where a full-size icon would be: one vertical centre per row shared by both columns, centred within the column rather than flush to the edge. Titles and any other text keep the original behaviour. Also collapsed the icon placement itself onto a shared MDI_ROW_TOP_Y constant. Upstream spelled it out as three identical if/else pairs on i with the row tops as magic numbers, which is what made the difference from the text path easy to miss in the first place. Co-Authored-By: Claude --- .../src/display/display.cpp | 45 ++++++++++--------- .../HomeButtonsArduino/src/display/display.h | 7 +++ 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/display/display.cpp b/Firmware/HomeButtonsArduino/src/display/display.cpp index 4f03843..1eff8c7 100644 --- a/Firmware/HomeButtonsArduino/src/display/display.cpp +++ b/Firmware/HomeButtonsArduino/src/display/display.cpp @@ -394,28 +394,15 @@ void Display::draw_main() { // calculate icon position on display uint16_t x = i % 2 == 0 ? 0 : WIDTH - size; + // A shrunken icon centres on its own button, a full-size one sits at + // the shared row top so both columns line up. Upstream spelled this + // out as three identical if/else pairs on i. uint16_t y; - if (i < 2) { - if (small) { - y = static_cast(round(HEIGHT / 12. + i * HEIGHT / 6.)) - - size / 2; - } else { - y = 17; - } - } else if (i < 4) { - if (small) { - y = static_cast(round(HEIGHT / 12. + i * HEIGHT / 6.)) - - size / 2; - } else { - y = 116; - } + if (small) { + y = static_cast(round(HEIGHT / 12. + i * HEIGHT / 6.)) - + size / 2; } else { - if (small) { - y = static_cast(round(HEIGHT / 12. + i * HEIGHT / 6.)) - - size / 2; - } else { - y = 215; - } + y = MDI_ROW_TOP_Y[i / 2]; } draw_mdi(icon.c_str(), size, x, y); } else if (label_type[i] == LabelType::Mixed) { @@ -486,12 +473,26 @@ void Display::draw_main() { } h = u8g2.getFontAscent(); int16_t x, y; - if (i % 2 == 0) { + if (numeric) { + // Placed exactly where a full-size icon would sit: one vertical + // centre per row shared by both columns, and centred within the + // column. Text's own placement is per button index, which puts the + // left column high and the right column low - fine for a caption, + // wrong for two counters meant to read as a pair. + const uint16_t cy = MDI_ROW_TOP_Y[i / 2] + MDI_SIZE_LARGE / 2; + const uint16_t cx = + (i % 2 == 0) ? MDI_SIZE_LARGE / 2 : WIDTH - MDI_SIZE_LARGE / 2; + x = static_cast(cx) - w / 2; + y = static_cast(cy) + h / 2; + } else if (i % 2 == 0) { x = h_padding; + y = static_cast(round(HEIGHT / 12. + i * HEIGHT / 6.)) + + h / 2; } else { x = WIDTH - w - h_padding; + y = static_cast(round(HEIGHT / 12. + i * HEIGHT / 6.)) + + h / 2; } - y = static_cast(round(HEIGHT / 12. + i * HEIGHT / 6.)) + h / 2; u8g2.setCursor(x, y); u8g2.print(label.c_str()); } diff --git a/Firmware/HomeButtonsArduino/src/display/display.h b/Firmware/HomeButtonsArduino/src/display/display.h index 59b0054..6f79039 100644 --- a/Firmware/HomeButtonsArduino/src/display/display.h +++ b/Firmware/HomeButtonsArduino/src/display/display.h @@ -20,6 +20,13 @@ static constexpr uint16_t max_palette_pixels = 256; static constexpr uint16_t MDI_SIZE_LARGE = 64; static constexpr uint16_t MDI_SIZE_SMALL = 48; +// Top edge of a full-size icon, per button row. Both columns of a row share +// one value, so icons line up across the display. Text labels deliberately +// do not use these - they are placed per button index, which staggers the +// two columns - but a counter total is drawn as though it were an icon so +// the numbers sit level with each other and with icons in the other rows. +static constexpr uint16_t MDI_ROW_TOP_Y[3] = {17, 116, 215}; + struct HardwareDefinition; class DeviceState; From 111f523470a8219efd3adf16965dc6b0fc6e2ea3 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 11:17:56 +0200 Subject: [PATCH 14/41] Redraw the count on press, not on delivery The counter moved in RAM the moment a button was pressed, but the redraw that showed it only ran inside AwakeModeIdleState and SessionState. A press from sleep goes SleepModeHandleInput -> NetConnectingState -> SessionState, and neither of the first two redrew - so the number on the display did not move until Wi-Fi and TLS were up, six to ten seconds later. The screen was reporting delivery while pretending to report the press. Moved the redraw into the main loop as _service_display(), so it follows the press whatever the state machine is doing, and dropped the two per-state copies. Fixed the coalescing while moving it. Both copies updated last_m_display_redraw_ on every pass of the outer interval check, not just when something was drawn, so the timestamp ticked continuously in the background and any press could wait out most of an interval before being shown. The timestamp now moves only on an actual draw, so the first press after an idle spell redraws immediately and only a genuine burst gets coalesced. The e-paper refresh runs in the display task, so it now overlaps the network work rather than queueing behind it. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/src/app.cpp | 30 ++++++++++++------------- Firmware/HomeButtonsArduino/src/app.h | 5 +++++ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index 20b68d0..36969fc 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -336,6 +336,19 @@ void App::_handle_ui_event_global(UserInput::Event event) { device_state_.flags().last_user_input_time = millis(); } +void App::_service_display() { + if (!device_state_.flags().display_redraw) return; + // Coalesce a burst so a run of presses does not queue up a full e-paper + // refresh each. The timestamp only moves when something is actually + // drawn, so the first press after an idle spell redraws straight away + // rather than waiting out an interval that has been ticking in the + // background. + if (millis() - last_m_display_redraw_ < AWAKE_REDRAW_INTERVAL) return; + device_state_.flags().display_redraw = false; + last_m_display_redraw_ = millis(); + display_.disp_main(); +} + void App::_main_task() { info("woke up."); info("cpu freq: %d MHz", getCpuFrequencyMhz()); @@ -560,6 +573,7 @@ void App::_main_task() { debug("Starting main state machine loop"); while (true) { loop(); + _service_display(); esp_task_wdt_reset(); delay(10); } @@ -616,14 +630,6 @@ void AppSMStates::AwakeModeIdleState::exit() { void AppSMStates::AwakeModeIdleState::loop() { sm()._flush_pending(); - if (millis() - sm().last_m_display_redraw_ >= AWAKE_REDRAW_INTERVAL) { - if (sm().device_state_.flags().display_redraw) { - sm().device_state_.flags().display_redraw = false; - sm().display_.disp_main(); - } - sm().last_m_display_redraw_ = millis(); - } - if (!sm().hw_.is_dc_connected()) { sm().device_state_.sensors().charging = false; return transition_to(); @@ -792,14 +798,6 @@ void AppSMStates::SessionState::exit() { sm().bsl_input_.ClearEventCallback(); } void AppSMStates::SessionState::loop() { sm()._flush_pending(); - if (millis() - sm().last_m_display_redraw_ >= AWAKE_REDRAW_INTERVAL) { - if (sm().device_state_.flags().display_redraw) { - sm().device_state_.flags().display_redraw = false; - sm().display_.disp_main(); - } - sm().last_m_display_redraw_ = millis(); - } - if (millis() - sm().session_last_input_time_ > SESSION_IDLE_TIMEOUT) { sm().info("session idle, shutting down"); return transition_to(); diff --git a/Firmware/HomeButtonsArduino/src/app.h b/Firmware/HomeButtonsArduino/src/app.h index a43dbb6..4e1d75f 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -200,6 +200,11 @@ class App : public AppStateMachine, public Logger { void _handle_ui_event_global(UserInput::Event event); void _net_on_connect(); + // Redraws the main screen when a press has changed it. Driven from the + // main loop rather than from individual states, so the number on the + // display follows the button press regardless of what the state machine + // is doing - notably while the network is still connecting. + void _service_display(); // Counter plumbing --------------------------------------------------- // Returns true and fills idx/delta when btn_id is one of the four From 23ea594ff98bcb31bd8de574a16f6903d0c2b072 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 15:00:51 +0200 Subject: [PATCH 15/41] Add scheduled counter reset, clocked from the webhook response The counters accumulated forever. They now clear on a configurable boundary, without the device needing SNTP, a timezone, or an RTC chip. Time comes from the receiver. Every response carries a UTC epoch and the current offset in seconds, so any request doubles as a clock sync and DST stays where the calendar already lives. A bare {"event":"time"} POST exists for when there is nothing else to send; the receiver's press branch ignores it, and it is only sent when the clock is older than CLOCK_RESYNC_SECONDS and no press is queued. The clock survives deep sleep via the RTC timer, so the boundary is testable before the network is up. That matters twice: the reset wake can act immediately, and _handle_counter_press() tests the boundary before applying, so a press just after 03:00 counts toward the new period rather than the old one. Getting that ordering wrong would have had the device show a cleared count while reporting yesterday's total. Schedule lives in one portal field so four modes need one input: off | daily 03:00 | weekly mon 03:00 | monthly 1 03:00 Parsed into a canonical form on save, so a typo cannot quietly disable the reset. Monthly is capped at day 28 - 29-31 would skip short months, which is a surprising way for a reset not to happen. reset_schedule reduces all three modes to one integer period per instant, so the reset test is an equality check rather than date arithmetic at the call site, and seconds_until_next() feeds the existing but until now unused flags().schedule_wakeup_time. The reset wake replaces the heartbeat rather than adding to it: one wake per day that clears, redraws, reports battery and resyncs the clock in a single round trip. Guards: a clock older than CLOCK_STALE_SECONDS is not trusted enough to reset on, and last_reset_period == 0 adopts the current period without clearing, so setting up a device does not wipe a count it was just given. SCHEDULE_WAKEUP_MAX caps a sleep at 24h, so weekly and monthly wake daily and re-evaluate instead of drifting for a month on an RC oscillator. One event reports the whole reset - {"event":"reset","counts":{...}} - rather than one request per counter. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/src/app.cpp | 91 +++++++ Firmware/HomeButtonsArduino/src/app.h | 16 ++ Firmware/HomeButtonsArduino/src/config.h | 14 + .../HomeButtonsArduino/src/reset_schedule.cpp | 253 ++++++++++++++++++ .../HomeButtonsArduino/src/reset_schedule.h | 62 +++++ Firmware/HomeButtonsArduino/src/setup.cpp | 15 ++ Firmware/HomeButtonsArduino/src/state.cpp | 9 + Firmware/HomeButtonsArduino/src/state.h | 45 ++++ Firmware/HomeButtonsArduino/src/types.h | 1 + Firmware/HomeButtonsArduino/src/webhook.cpp | 68 ++++- Firmware/HomeButtonsArduino/src/webhook.h | 14 +- 11 files changed, 582 insertions(+), 6 deletions(-) create mode 100644 Firmware/HomeButtonsArduino/src/reset_schedule.cpp create mode 100644 Firmware/HomeButtonsArduino/src/reset_schedule.h diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index 36969fc..08aafd3 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "esp_ota_ops.h" #include "config.h" @@ -69,6 +70,7 @@ void App::_start_esp_sleep() { } void App::_go_to_sleep() { + _schedule_next_wake(); device_state_.save_all(); hw_.set_all_leds(0); _start_esp_sleep(); @@ -243,8 +245,72 @@ void App::_refresh_counter_labels() { } } +reset_schedule::Spec App::_reset_spec() { + return reset_schedule::parse(device_state_.reset_spec().c_str(), *this); +} + +bool App::_clock_fresh() const { + if (!device_state_.clock_valid()) return false; + const time_t now = time(nullptr); + const time_t synced = static_cast(device_state_.last_time_sync()); + // Drift on the internal RC oscillator is fine for hours and meaningless + // after days, so refuse to act on a clock that old. + return now >= synced && + (now - synced) <= static_cast(CLOCK_STALE_SECONDS); +} + +void App::_check_reset() { + if (!_clock_fresh()) return; + + const reset_schedule::Spec spec = _reset_spec(); + if (spec.mode == reset_schedule::Mode::kOff) return; + + const time_t local = + time(nullptr) + static_cast(device_state_.tz_offset()); + const int32_t period = reset_schedule::period_of(spec, local); + const int32_t last = device_state_.last_reset_period(); + + if (last == 0) { + // First time the date has ever been known. Adopt the period without + // clearing, so setting up a device does not wipe a count it was just + // given. + device_state_.set_last_reset_period(period); + return; + } + if (period == last) return; + + const bool had_counts = device_state_.clear_counters(); + device_state_.set_last_reset_period(period); + _refresh_counter_labels(); + device_state_.flags().display_redraw = true; + reset_to_report_ = true; + info("reset boundary crossed (%s, period %d -> %d), counters cleared%s", + reset_schedule::mode_name(spec.mode), last, period, + had_counts ? "" : " (already zero)"); +} + +void App::_schedule_next_wake() { + device_state_.flags().schedule_wakeup_time = 0; + if (!device_state_.clock_valid()) return; + + const reset_schedule::Spec spec = _reset_spec(); + if (spec.mode == reset_schedule::Mode::kOff) return; + + const time_t local = + time(nullptr) + static_cast(device_state_.tz_offset()); + const uint32_t secs = reset_schedule::seconds_until_next(spec, local); + device_state_.flags().schedule_wakeup_time = secs; + info("next reset wake in %u s (%s)", secs, + reset_schedule::mode_name(spec.mode)); +} + // Runs on the UI task. RAM only: no NVS write, no HTTP. void App::_handle_counter_press(uint8_t btn_id) { + // Before anything else: a press just after a boundary belongs to the new + // period, not the one that ended. The clock survives deep sleep, so this + // is knowable without the network. + _check_reset(); + uint8_t idx = 0; int32_t delta = 0; if (!_btn_to_counter(btn_id, idx, delta)) { @@ -327,8 +393,29 @@ void App::_flush_pending() { void App::_net_on_connect() { webhook_.begin(); + const bool have_presses = + press_queue_ != nullptr && uxQueueMessagesWaiting(press_queue_) > 0; + const time_t now = time(nullptr); + const bool clock_old = + !device_state_.clock_valid() || + (now - static_cast(device_state_.last_time_sync())) > + static_cast(CLOCK_RESYNC_SECONDS); + if (boot_cause_ == BootCause::TIMER) { webhook_.send_heartbeat(); + } else if (!have_presses && clock_old) { + // Every response carries the clock, so a bare sync is only worth + // sending when nothing else is going out anyway. + webhook_.sync_time(); + } + + // The clock may only just have become valid, so re-test the boundary now + // that it has. + _check_reset(); + if (reset_to_report_) { + if (webhook_.send_reset(reset_schedule::mode_name(_reset_spec().mode))) { + reset_to_report_ = false; + } } } @@ -466,6 +553,10 @@ void App::_main_task() { device_state_.sensors().battery_pct = hw_.read_battery_percent(); device_state_.sensors().battery_voltage = hw_.read_battery_voltage(); + // The clock survives deep sleep, so the boundary can be tested before the + // network is up - which matters when the reset wake is what woke us. + _check_reset(); + // Labels carry the running totals, so make them match the counters // restored from NVS before anything is drawn. _refresh_counter_labels(); diff --git a/Firmware/HomeButtonsArduino/src/app.h b/Firmware/HomeButtonsArduino/src/app.h index 4e1d75f..9943537 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -11,6 +11,7 @@ #include "logger.h" #include "hardware.h" #include "setup.h" +#include "reset_schedule.h" #include "display/display.h" #include "button_ui/btn_sw_led.h" @@ -213,6 +214,16 @@ class App : public AppStateMachine, public Logger { // Applies the press locally (counter, label, redraw) and either sends it // straight away or queues it until the network is up. void _handle_counter_press(uint8_t btn_id); + // Clears the counters when the configured reset boundary has been + // crossed. RAM only, so it is safe to call from the UI task: persistence + // rides along with the next save_all(). The clock survives deep sleep, so + // this can run before the network is up - and must, so that a press just + // after the boundary counts toward the new period rather than the old. + void _check_reset(); + reset_schedule::Spec _reset_spec(); + bool _clock_fresh() const; + // Seconds until the next boundary, into flags().schedule_wakeup_time. + void _schedule_next_wake(); void _refresh_counter_labels(); void _flush_pending(); @@ -261,6 +272,11 @@ class App : public AppStateMachine, public Logger { // the error pattern rather than simply going dark. std::array, NUM_BUTTONS> send_failed_{}; + // Set when _check_reset() clears the counters, so the following connect + // reports it. RAM only: if the report is lost, the next press carries + // absolute counts and the receiver self-heals. + bool reset_to_report_ = false; + BootCause boot_cause_; uint8_t wakeup_btn_id_ = 0; diff --git a/Firmware/HomeButtonsArduino/src/config.h b/Firmware/HomeButtonsArduino/src/config.h index 56e2ddb..c26bf8c 100644 --- a/Firmware/HomeButtonsArduino/src/config.h +++ b/Firmware/HomeButtonsArduino/src/config.h @@ -61,6 +61,20 @@ static constexpr int32_t COUNTER_MIN = 0; static constexpr int32_t COUNTER_MAX = 999999; static constexpr char COUNTER_NAMES[NUM_COUNTERS][2] = {"a", "b"}; +// ------ counter reset ------ +// One free-text portal field covers every mode: +// off | daily 03:00 | weekly mon 03:00 | monthly 1 03:00 +static constexpr size_t RESET_SPEC_MAXLEN = 24; +static constexpr char RESET_SPEC_DFLT[] = "daily 03:00"; +// The device has no trustworthy clock of its own; it is set from the `ts` +// and `tz_offset` the webhook returns. If the last successful sync is +// older than this, skip the reset rather than act on a drifting clock - +// the internal RC oscillator is good for hours, not weeks. +static constexpr uint32_t CLOCK_STALE_SECONDS = 48UL * 60UL * 60UL; +// Resync opportunistically when a connection is up anyway and the clock is +// older than this, so a boundary is never approached on a stale clock. +static constexpr uint32_t CLOCK_RESYNC_SECONDS = 6UL * 60UL * 60UL; + // ------ webhook ------ static constexpr size_t ENDPOINT_URL_MAXLEN = 128; static constexpr size_t AUTH_TOKEN_MAXLEN = 128; diff --git a/Firmware/HomeButtonsArduino/src/reset_schedule.cpp b/Firmware/HomeButtonsArduino/src/reset_schedule.cpp new file mode 100644 index 0000000..a4f5459 --- /dev/null +++ b/Firmware/HomeButtonsArduino/src/reset_schedule.cpp @@ -0,0 +1,253 @@ +#include "reset_schedule.h" + +#include +#include + +namespace reset_schedule { +namespace { + +constexpr int64_t kSecondsPerDay = 24LL * 60LL * 60LL; + +const char* const kWeekdayNames[7] = {"sun", "mon", "tue", "wed", + "thu", "fri", "sat"}; + +// Local time here is a UTC epoch already shifted by the offset the receiver +// reported, so plain UTC calendar functions are the correct ones. TZ is +// never set on this device and tzset() is never called. +struct tm to_tm(int64_t local_time) { + time_t t = static_cast(local_time); + struct tm out = {}; + gmtime_r(&t, &out); + return out; +} + +// Floor division: C truncates toward zero, which would put times before the +// boundary on the wrong day for negative values. +int64_t floordiv(int64_t a, int64_t b) { + int64_t q = a / b; + if ((a % b != 0) && ((a < 0) != (b < 0))) q--; + return q; +} + +// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm). +// Used to build a boundary date; extraction goes through gmtime_r. +int64_t days_from_civil(int y, unsigned m, unsigned d) { + y -= m <= 2; + const int64_t era = (y >= 0 ? y : y - 399) / 400; + const unsigned yoe = static_cast(y - era * 400); + const unsigned doy = (153u * (m + (m > 2 ? -3 : 9)) + 2u) / 5u + d - 1u; + const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy; + return era * 146097LL + static_cast(doe) - 719468LL; +} + +bool parse_hhmm(const char* text, uint16_t& minute_of_day) { + if (text == nullptr) return false; + char* end = nullptr; + long hour = strtol(text, &end, 10); + if (end == text || *end != ':') return false; + const char* min_start = end + 1; + long minute = strtol(min_start, &end, 10); + if (end == min_start) return false; + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return false; + minute_of_day = static_cast(hour * 60 + minute); + return true; +} + +int weekday_from_name(const char* text) { + for (int i = 0; i < 7; i++) { + if (strncasecmp(text, kWeekdayNames[i], 3) == 0) return i; + } + return -1; +} + +// Start of the period containing local_time, in days since the epoch, +// measured in boundary-shifted space. +int64_t period_day(const Spec& spec, int64_t shifted) { + return floordiv(shifted, kSecondsPerDay); +} + +} // namespace + +const char* mode_name(Mode mode) { + switch (mode) { + case Mode::kOff: + return "off"; + case Mode::kDaily: + return "daily"; + case Mode::kWeekly: + return "weekly"; + case Mode::kMonthly: + return "monthly"; + default: + return "unknown"; + } +} + +Spec parse(const char* text, const Logger& log) { + Spec spec; // defaults to daily 03:00 + if (text == nullptr || *text == '\0') return spec; + + char buf[RESET_SPEC_MAXLEN + 1] = {}; + snprintf(buf, sizeof(buf), "%s", text); + + char* save = nullptr; + const char* mode_tok = strtok_r(buf, " \t", &save); + if (mode_tok == nullptr) return spec; + + if (strcasecmp(mode_tok, "off") == 0) { + spec.mode = Mode::kOff; + return spec; + } + + if (strcasecmp(mode_tok, "daily") == 0) { + spec.mode = Mode::kDaily; + if (!parse_hhmm(strtok_r(nullptr, " \t", &save), spec.minute_of_day)) { + log.warning("reset spec '%s': bad time, using 03:00", text); + } + return spec; + } + + if (strcasecmp(mode_tok, "weekly") == 0) { + spec.mode = Mode::kWeekly; + const char* day_tok = strtok_r(nullptr, " \t", &save); + int weekday = (day_tok != nullptr) ? weekday_from_name(day_tok) : -1; + if (weekday < 0) { + log.warning("reset spec '%s': bad weekday, using mon", text); + weekday = 1; + } + spec.weekday = static_cast(weekday); + if (!parse_hhmm(strtok_r(nullptr, " \t", &save), spec.minute_of_day)) { + log.warning("reset spec '%s': bad time, using 03:00", text); + } + return spec; + } + + if (strcasecmp(mode_tok, "monthly") == 0) { + spec.mode = Mode::kMonthly; + const char* day_tok = strtok_r(nullptr, " \t", &save); + long day = (day_tok != nullptr) ? strtol(day_tok, nullptr, 10) : 0; + // Capped at 28 deliberately: 29-31 would skip short months, which is a + // surprising way for a reset to quietly not happen. + if (day < 1 || day > 28) { + log.warning("reset spec '%s': day must be 1-28, using 1", text); + day = 1; + } + spec.day_of_month = static_cast(day); + if (!parse_hhmm(strtok_r(nullptr, " \t", &save), spec.minute_of_day)) { + log.warning("reset spec '%s': bad time, using 03:00", text); + } + return spec; + } + + log.warning("reset spec '%s' not understood, using daily 03:00", text); + return Spec{}; +} + +StaticString format(const Spec& spec) { + const int hour = spec.minute_of_day / 60; + const int minute = spec.minute_of_day % 60; + switch (spec.mode) { + case Mode::kOff: + return StaticString("off"); + case Mode::kWeekly: + return StaticString( + "weekly %s %02d:%02d", kWeekdayNames[spec.weekday % 7], hour, minute); + case Mode::kMonthly: + return StaticString("monthly %u %02d:%02d", + spec.day_of_month, hour, minute); + case Mode::kDaily: + default: + return StaticString("daily %02d:%02d", hour, minute); + } +} + +int32_t period_of(const Spec& spec, time_t local_time) { + if (spec.mode == Mode::kOff) return 0; + + // Shift back by the boundary time so a period starts at the reset moment + // rather than at local midnight; everything below is then whole days. + const int64_t shifted = + static_cast(local_time) - static_cast(spec.minute_of_day) * 60; + const int64_t day_number = period_day(spec, shifted); + + switch (spec.mode) { + case Mode::kDaily: + // +1 throughout so a valid period is never 0, which means "unset". + return static_cast(day_number + 1); + + case Mode::kWeekly: { + // 1970-01-01 was a Thursday (weekday 4); align periods to the + // configured weekday. + const int64_t offset = (4 - static_cast(spec.weekday) + 7) % 7; + return static_cast(floordiv(day_number + offset, 7) + 1); + } + + case Mode::kMonthly: { + struct tm t = to_tm(shifted); + int32_t months = (t.tm_year + 1900) * 12 + t.tm_mon; + if (t.tm_mday < static_cast(spec.day_of_month)) months -= 1; + return months + 1; + } + + default: + return 0; + } +} + +uint32_t seconds_until_next(const Spec& spec, time_t local_time) { + if (spec.mode == Mode::kOff) return 0; + + const int64_t boundary_offset = static_cast(spec.minute_of_day) * 60; + const int64_t shifted = static_cast(local_time) - boundary_offset; + const int64_t day_number = period_day(spec, shifted); + + int64_t boundary_day = 0; + switch (spec.mode) { + case Mode::kDaily: + boundary_day = day_number + 1; + break; + + case Mode::kWeekly: { + const int64_t offset = (4 - static_cast(spec.weekday) + 7) % 7; + boundary_day = (floordiv(day_number + offset, 7) + 1) * 7 - offset; + break; + } + + case Mode::kMonthly: { + struct tm t = to_tm(shifted); + int year = t.tm_year + 1900; + int month = t.tm_mon; // 0-based + if (t.tm_mday >= static_cast(spec.day_of_month)) { + month++; + if (month > 11) { + month = 0; + year++; + } + } + boundary_day = + days_from_civil(year, static_cast(month + 1), + static_cast(spec.day_of_month)); + break; + } + + default: + return 0; + } + + const int64_t boundary = + boundary_day * kSecondsPerDay + boundary_offset; + int64_t delta = boundary - static_cast(local_time); + + // Clamped into what the deep sleep timer accepts. SCHEDULE_WAKEUP_MAX is + // 24h, so weekly and monthly simply wake once a day and re-evaluate - + // which also keeps the clock resynced rather than drifting for a month. + if (delta < static_cast(SCHEDULE_WAKEUP_MIN)) { + delta = SCHEDULE_WAKEUP_MIN; + } + if (delta > static_cast(SCHEDULE_WAKEUP_MAX)) { + delta = SCHEDULE_WAKEUP_MAX; + } + return static_cast(delta); +} + +} // namespace reset_schedule diff --git a/Firmware/HomeButtonsArduino/src/reset_schedule.h b/Firmware/HomeButtonsArduino/src/reset_schedule.h new file mode 100644 index 0000000..e513596 --- /dev/null +++ b/Firmware/HomeButtonsArduino/src/reset_schedule.h @@ -0,0 +1,62 @@ +#ifndef HOMEBUTTONS_RESET_SCHEDULE_H +#define HOMEBUTTONS_RESET_SCHEDULE_H + +#include + +#include "config.h" +#include "logger.h" +#include "types.h" + +// When the counters clear themselves. +// +// The device has no clock of its own worth trusting and deliberately knows +// nothing about timezones: every webhook response carries a UTC epoch and +// the current offset in seconds, and the offset is whatever the receiver +// says it is, DST included. Everything here works in local seconds derived +// from those two numbers. +// +// The spec is a single free-text portal field so one string covers every +// mode without four more inputs to fill in: +// +// off +// daily 03:00 +// weekly mon 03:00 +// monthly 1 03:00 +// +// An unparseable spec falls back to the default rather than disabling the +// reset silently. +namespace reset_schedule { + +enum class Mode : uint8_t { kOff, kDaily, kWeekly, kMonthly }; + +struct Spec { + Mode mode = Mode::kDaily; + uint16_t minute_of_day = 3 * 60; // 03:00 local + uint8_t weekday = 1; // 0 = Sunday, matches struct tm + uint8_t day_of_month = 1; // capped at 28, see parse() +}; + +const char* mode_name(Mode mode); + +// Never fails: an unrecognised spec logs and yields the default. +Spec parse(const char* text, const Logger& log); + +// Renders a spec back to its canonical text, for the portal field. +StaticString format(const Spec& spec); + +// The reset period a given local time falls in, as a plain integer that +// only changes when a boundary is crossed. Comparing this against the +// stored value is the whole reset test - no date arithmetic at the call +// site, and it works identically for all three modes. +// +// Returns 0 for Mode::kOff, which never matches a stored non-zero period. +int32_t period_of(const Spec& spec, time_t local_time); + +// Seconds from local_time until the next boundary, clamped into the range +// the deep sleep timer accepts. Returns 0 when the mode is off, meaning +// "no scheduled wake, fall back to the heartbeat interval". +uint32_t seconds_until_next(const Spec& spec, time_t local_time); + +} // namespace reset_schedule + +#endif // HOMEBUTTONS_RESET_SCHEDULE_H diff --git a/Firmware/HomeButtonsArduino/src/setup.cpp b/Firmware/HomeButtonsArduino/src/setup.cpp index 91e92eb..0315bf8 100644 --- a/Firmware/HomeButtonsArduino/src/setup.cpp +++ b/Firmware/HomeButtonsArduino/src/setup.cpp @@ -28,6 +28,10 @@ static WiFiManagerParameter auth_token_param("auth_token", "Auth Token", "", // Drains the battery quickly - leave it off for normal use. static WiFiManagerParameter awake_mode_param( "awake_mode", "Awake Mode (debug, drains battery) - 1 or 0", "", 1); +// off | daily 03:00 | weekly mon 03:00 | monthly 1 03:00 +static WiFiManagerParameter reset_spec_param( + "reset_spec", "Counter Reset (e.g. daily 03:00, weekly mon 03:00, off)", + "", RESET_SPEC_MAXLEN); static WiFiManagerParameter static_ip_param("static_ip", "Static IP", "", 15); static WiFiManagerParameter gateway_param("gateway", "Gateway", "", 15); static WiFiManagerParameter subnet_param("subnet", "Subnet Mask", "", 15); @@ -181,6 +185,14 @@ void HBSetup::save_params_callback() { app_.device_state_.set_endpoint_url( EndpointUrlType{endpoint_url_param.getValue()}); app_.device_state_.set_auth_token(AuthTokenType{auth_token_param.getValue()}); + { + // Normalised through the parser so whatever lands in NVS is canonical + // and a typo cannot silently disable the reset. + const ResetSpecType entered{reset_spec_param.getValue()}; + const auto spec = reset_schedule::parse( + entered.empty() ? RESET_SPEC_DFLT : entered.c_str(), app_); + app_.device_state_.set_reset_spec(reset_schedule::format(spec)); + } { const char* v = awake_mode_param.getValue(); app_.device_state_.persisted().user_awake_mode = @@ -225,6 +237,8 @@ void HBSetup::start_setup() { AUTH_TOKEN_MAXLEN); awake_mode_param.setValue( app_.device_state_.persisted().user_awake_mode ? "1" : "0", 1); + reset_spec_param.setValue(app_.device_state_.reset_spec().c_str(), + RESET_SPEC_MAXLEN); static_ip_param.setValue(app_.device_state_.user_preferences() .network.static_ip.toString() .c_str(), @@ -250,6 +264,7 @@ void HBSetup::start_setup() { wifi_manager.addParameter(&endpoint_url_param); wifi_manager.addParameter(&auth_token_param); wifi_manager.addParameter(&awake_mode_param); + wifi_manager.addParameter(&reset_spec_param); wifi_manager.addParameter(&static_ip_param); wifi_manager.addParameter(&gateway_param); wifi_manager.addParameter(&subnet_param); diff --git a/Firmware/HomeButtonsArduino/src/state.cpp b/Firmware/HomeButtonsArduino/src/state.cpp index 73219b4..2e95b44 100644 --- a/Firmware/HomeButtonsArduino/src/state.cpp +++ b/Firmware/HomeButtonsArduino/src/state.cpp @@ -28,6 +28,7 @@ void DeviceState::save_user() { ip_address_to_static_string(user_preferences_.network.dns2).c_str()); preferences_.putString("endpoint", user_preferences_.endpoint_url.c_str()); preferences_.putString("auth_tok", user_preferences_.auth_token.c_str()); + preferences_.putString("rst_spec", user_preferences_.reset_spec.c_str()); preferences_.end(); } @@ -61,6 +62,8 @@ void DeviceState::load_user() { _load_to_static_string(user_preferences_.endpoint_url, "endpoint", ""); _load_to_static_string(user_preferences_.auth_token, "auth_tok", ""); + _load_to_static_string(user_preferences_.reset_spec, "rst_spec", + RESET_SPEC_DFLT); preferences_.end(); } @@ -92,6 +95,9 @@ void DeviceState::save_persisted() { persisted_.counters[i]); } preferences_.putUInt("seq", persisted_.seq); + preferences_.putInt("rst_per", persisted_.last_reset_period); + preferences_.putInt("tz_off", persisted_.tz_offset); + preferences_.putUInt("last_sync", persisted_.last_time_sync); preferences_.putBool("wifi_qc", persisted_.wifi_quick_connect); preferences_.putBool("chg_cpt_shwn", persisted_.charge_complete_showing); preferences_.putBool("u_msg_shwn", persisted_.user_msg_showing); @@ -117,6 +123,9 @@ void DeviceState::load_persisted() { preferences_.getInt(StaticString<8>("cnt_%d", i).c_str(), 0); } persisted_.seq = preferences_.getUInt("seq", 0); + persisted_.last_reset_period = preferences_.getInt("rst_per", 0); + persisted_.tz_offset = preferences_.getInt("tz_off", 0); + persisted_.last_time_sync = preferences_.getUInt("last_sync", 0); persisted_.wifi_quick_connect = preferences_.getBool("wifi_qc", false); persisted_.charge_complete_showing = preferences_.getBool("chg_cpt_shwn", false); diff --git a/Firmware/HomeButtonsArduino/src/state.h b/Firmware/HomeButtonsArduino/src/state.h index 632d878..e895bc9 100644 --- a/Firmware/HomeButtonsArduino/src/state.h +++ b/Firmware/HomeButtonsArduino/src/state.h @@ -41,6 +41,8 @@ class DeviceState : public Logger { EndpointUrlType endpoint_url; AuthTokenType auth_token; + // When the counters clear themselves. See reset_schedule.h. + ResetSpecType reset_spec; } user_preferences_; struct Persisted { @@ -57,6 +59,17 @@ class DeviceState : public Logger { // retries so a replayed press does not notify twice. uint32_t seq = 0; + // Which reset period the current counts belong to, as produced by + // reset_schedule::period_of(). 0 means "not yet established", which + // suppresses the first reset so a fresh device does not clear counts + // it has only just been told about. + int32_t last_reset_period = 0; + // Seconds to add to UTC for local time, as reported by the webhook. + // Persisted so local time is known on wake, before any request. + int32_t tz_offset = 0; + // UTC epoch of the last successful clock sync; 0 means never. + uint32_t last_time_sync = 0; + // Flags bool wifi_quick_connect = false; bool charge_complete_showing = false; @@ -145,6 +158,38 @@ class DeviceState : public Logger { user_preferences_.auth_token = token; } + const ResetSpecType& reset_spec() const { + return user_preferences_.reset_spec; + } + void set_reset_spec(const ResetSpecType& spec) { + user_preferences_.reset_spec = spec; + } + + // Clock ------------------------------------------------------------- + int32_t tz_offset() const { return persisted_.tz_offset; } + uint32_t last_time_sync() const { return persisted_.last_time_sync; } + bool clock_valid() const { return persisted_.last_time_sync > 0; } + + void set_clock_synced(uint32_t utc_epoch, int32_t tz_offset) { + persisted_.last_time_sync = utc_epoch; + persisted_.tz_offset = tz_offset; + } + + int32_t last_reset_period() const { return persisted_.last_reset_period; } + void set_last_reset_period(int32_t period) { + persisted_.last_reset_period = period; + } + + // Zeroes every counter. Returns true if anything actually changed. + bool clear_counters() { + bool changed = false; + for (uint8_t i = 0; i < NUM_COUNTERS; i++) { + if (persisted_.counters[i] != 0) changed = true; + persisted_.counters[i] = 0; + } + return changed; + } + // Counters ----------------------------------------------------------- // idx is 0-based. Returns 0 for an out-of-range index rather than // reading past the array. diff --git a/Firmware/HomeButtonsArduino/src/types.h b/Firmware/HomeButtonsArduino/src/types.h index 417a130..8ee97a4 100644 --- a/Firmware/HomeButtonsArduino/src/types.h +++ b/Firmware/HomeButtonsArduino/src/types.h @@ -18,6 +18,7 @@ using UserMessage = StaticString; using EndpointUrlType = StaticString; using AuthTokenType = StaticString; +using ResetSpecType = StaticString; using PayloadType = StaticString; using SSIDType = StaticString<32>; diff --git a/Firmware/HomeButtonsArduino/src/webhook.cpp b/Firmware/HomeButtonsArduino/src/webhook.cpp index 8173e65..277d4b5 100644 --- a/Firmware/HomeButtonsArduino/src/webhook.cpp +++ b/Firmware/HomeButtonsArduino/src/webhook.cpp @@ -1,6 +1,8 @@ #include "webhook.h" #include +#include +#include #include "config.h" @@ -27,16 +29,24 @@ bool Webhook::configured() const { } size_t Webhook::_build_body(char* out, size_t out_size, const Event& event, - bool heartbeat) { + const char* event_kind, const char* reset_mode) { StaticJsonDocument doc; doc["device"] = device_state_.factory().unique_id.c_str(); doc["seq"] = event.seq; - doc["event"] = heartbeat ? "heartbeat" : "press"; - if (!heartbeat) { + doc["event"] = event_kind; + if (strcmp(event_kind, "press") == 0) { doc["counter"] = COUNTER_NAMES[event.counter_idx]; doc["button"] = event.button_id; doc["delta"] = event.delta; doc["count"] = event.count; + } else if (strcmp(event_kind, "reset") == 0) { + doc["reset_mode"] = reset_mode; + // One event covers the whole reset; the receiver iterates the object + // rather than needing one request per counter. + JsonObject counts = doc.createNestedObject("counts"); + for (uint8_t i = 0; i < NUM_COUNTERS; i++) { + counts[COUNTER_NAMES[i]] = device_state_.counter(i); + } } // The device has no RTC, so the receiver stamps wall-clock time. age_ms // lets it back-date a press that is only now being delivered. @@ -66,10 +76,14 @@ bool Webhook::_post(char* body, size_t len) { } int code = http_.POST(reinterpret_cast(body), len); + // Read before end(): every response carries the clock, so any request + // is also a time sync. + String response = (code > 0) ? http_.getString() : String(); http_.end(); // with setReuse(true) this keeps the socket open if (code >= 200 && code < 300) { info("posted ok (%d) on attempt %u", code, attempt); + _apply_time(response); return true; } // 4xx other than 408/429 will not improve on retry. @@ -86,7 +100,7 @@ bool Webhook::_post(char* body, size_t len) { bool Webhook::send_press(const Event& event) { char body[HTTP_PAYLOAD_SIZE]; - size_t len = _build_body(body, sizeof(body), event, false); + size_t len = _build_body(body, sizeof(body), event, "press", nullptr); debug("press body: %s", body); return _post(body, len); } @@ -95,7 +109,51 @@ bool Webhook::send_heartbeat() { Event event{}; event.seq = device_state_.next_seq(); char body[HTTP_PAYLOAD_SIZE]; - size_t len = _build_body(body, sizeof(body), event, true); + size_t len = _build_body(body, sizeof(body), event, "heartbeat", nullptr); debug("heartbeat body: %s", body); return _post(body, len); } + +bool Webhook::send_reset(const char* mode) { + Event event{}; + event.seq = device_state_.next_seq(); + char body[HTTP_PAYLOAD_SIZE]; + size_t len = _build_body(body, sizeof(body), event, "reset", mode); + info("reset body: %s", body); + return _post(body, len); +} + +bool Webhook::sync_time() { + Event event{}; + event.seq = device_state_.seq(); // no side effect, so no new sequence + char body[HTTP_PAYLOAD_SIZE]; + size_t len = _build_body(body, sizeof(body), event, "time", nullptr); + debug("time sync body: %s", body); + return _post(body, len); +} + +void Webhook::_apply_time(const String& response) { + if (response.length() == 0) return; + StaticJsonDocument<256> doc; + DeserializationError err = deserializeJson(doc, response); + if (err) { + // A plain-text body is normal if the receiver was never configured to + // return one; the clock simply stays as it was. + debug("response not JSON (%s), no clock update", err.c_str()); + return; + } + if (!doc.containsKey("ts")) return; + + const uint32_t ts = doc["ts"].as(); + const int32_t offset = doc["tz_offset"] | device_state_.tz_offset(); + if (ts < 1700000000UL) { // sanity: anything before late 2023 is not a clock + warning("ignoring implausible ts %u", ts); + return; + } + + struct timeval tv = {}; + tv.tv_sec = static_cast(ts); + settimeofday(&tv, nullptr); + device_state_.set_clock_synced(ts, offset); + info("clock synced: ts=%u offset=%d", ts, offset); +} diff --git a/Firmware/HomeButtonsArduino/src/webhook.h b/Firmware/HomeButtonsArduino/src/webhook.h index 72eec24..4c74328 100644 --- a/Firmware/HomeButtonsArduino/src/webhook.h +++ b/Firmware/HomeButtonsArduino/src/webhook.h @@ -44,10 +44,22 @@ class Webhook : public Logger { // Posts battery level only, used by the timer wake. bool send_heartbeat(); + // Posts the cleared counters after a scheduled reset. + bool send_reset(const char* mode); + + // Asks for nothing and reports nothing - exists purely so the response + // can carry the clock. The receiver's press branch ignores it. Used at + // first connect, and whenever the clock is too old to trust near a reset + // boundary. + bool sync_time(); + private: bool _post(char* body, size_t len); size_t _build_body(char* out, size_t out_size, const Event& event, - bool heartbeat); + const char* event_kind, const char* reset_mode); + // Reads `ts` and `tz_offset` out of a response and sets the system clock. + // Every response carries them, so any request doubles as a clock sync. + void _apply_time(const String& response); DeviceState& device_state_; WiFiClientSecure client_; From 3198300cfa34eb241dd13e677aae6b7d5386b8be Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 15:17:05 +0200 Subject: [PATCH 16/41] Fix review findings: webhook task race, shared state, add tests Three fixes from the review of this branch. The blocker: two tasks shared one HTTPClient. _net_on_connect() is invoked from ConnectedState::entry() on the network task, and posted a heartbeat, time sync or reset from there - while _flush_pending() posts presses from the main task. Network sets W_CONNECTED in WifiConnectedState::entry(), one state earlier, so the main task could start a POST inside the window before the callback fired. Two tasks on one WiFiClientSecure is corruption, not a race you get away with. Upstream guarded the equivalent with a task affinity check in Network::publish that went away with MQTT. _net_on_connect() now only raises an atomic flag. All webhook access moved to _service_webhook() on the main task, driven from the main loop next to _service_display(). Counter state was mutated from three tasks - main at boot, UI on press, network on connect - while the main task ran save_all(). The network path is gone with the above; the remaining main/UI overlap is now guarded by a recursive mutex around _handle_counter_press(), _check_reset() and the save. reset_to_report_ became std::atomic, matching what inflight_ and send_failed_ already do. reset_schedule is now free of Arduino, Logger and config.h so it builds on the host, and has 19 unit tests behind `pio test -e native`. It is ~200 lines of calendar arithmetic whose bugs surface weeks later as "the counter cleared on the wrong day", which is exactly what should not need hardware to exercise. parse() reports failure through an out-param instead of logging, and format() writes into a caller buffer. Device settings moved from [env] into [esp32_base] so the native env does not inherit a board and framework it cannot use. Also: sync_time() now takes a sequence number rather than reusing the last one, and _sleep_or_restart() schedules the reset wake instead of falling back to the heartbeat interval. Verified: 19/19 tests pass, both firmware envs build (70.2% / 72.8%). Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/platformio.ini | 25 +- Firmware/HomeButtonsArduino/src/app.cpp | 80 ++++-- Firmware/HomeButtonsArduino/src/app.h | 30 ++- .../HomeButtonsArduino/src/reset_schedule.cpp | 56 +++-- .../HomeButtonsArduino/src/reset_schedule.h | 23 +- Firmware/HomeButtonsArduino/src/setup.cpp | 11 +- Firmware/HomeButtonsArduino/src/webhook.cpp | 5 +- .../test_reset_schedule.cpp | 238 ++++++++++++++++++ 8 files changed, 413 insertions(+), 55 deletions(-) create mode 100644 Firmware/HomeButtonsArduino/test/test_reset_schedule/test_reset_schedule.cpp diff --git a/Firmware/HomeButtonsArduino/platformio.ini b/Firmware/HomeButtonsArduino/platformio.ini index 807ac61..8d2d99f 100644 --- a/Firmware/HomeButtonsArduino/platformio.ini +++ b/Firmware/HomeButtonsArduino/platformio.ini @@ -10,8 +10,12 @@ ; ; This fork targets Home Buttons Original (model A1) only. The mini, pro and ; industrial environments from upstream have been removed. +; +; Device settings live in [esp32_base] rather than [env] so they are not +; inherited by the host-side [env:native] test environment, which has no +; board, framework or flash layout. -[env] +[esp32_base] build_unflags = -std=gnu++11 platform = espressif32@6.8.0 board = homebuttons_rev1.0 @@ -32,6 +36,7 @@ lib_deps = https://github.com/Neargye/semver.git#v0.3.0 [env:original_release] +extends = esp32_base build_flags = -std=gnu++17 -Wno-unknown-pragmas @@ -42,6 +47,7 @@ build_flags = board_build.cmake_extra_args = -DHOME_BUTTONS_ORIGINAL=ON [env:original_debug] +extends = esp32_base debug_tool = cmsis-dap build_flags = -std=gnu++17 @@ -52,3 +58,20 @@ build_flags = -DHOME_BUTTONS_ORIGINAL -DHOME_BUTTONS_DEBUG board_build.cmake_extra_args = -DHOME_BUTTONS_ORIGINAL=ON + +; Host-side unit tests for the pure-logic modules. reset_schedule is kept +; free of Arduino and ESP-IDF on purpose so its calendar arithmetic can be +; exercised without a device - those bugs would otherwise only surface weeks +; later as "the counter cleared on the wrong day". +; +; pio test -e native +[env:native] +platform = native +build_flags = + -std=gnu++17 + -I src +; pio test does not link project sources unless asked, and only this +; one translation unit is host-buildable. +test_build_src = true +build_src_filter = + +test_framework = unity diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index 08aafd3..31831d5 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -28,6 +28,8 @@ App::App() setup_(*this) { press_queue_ = xQueueCreate(PRESS_QUEUE_SIZE, sizeof(PressQueueElement)); if (press_queue_ == nullptr) error("failed to create press queue"); + state_mutex_ = xSemaphoreCreateRecursiveMutex(); + if (state_mutex_ == nullptr) error("failed to create state mutex"); } void App::setup() { @@ -47,6 +49,9 @@ void App::setup() { void App::_sleep_or_restart() { delay(3000); error("Going to sleep..."); + // Even a failure sleep should wake for the reset rather than falling back + // to the heartbeat interval. + _schedule_next_wake(); _start_esp_sleep(); } @@ -246,7 +251,15 @@ void App::_refresh_counter_labels() { } reset_schedule::Spec App::_reset_spec() { - return reset_schedule::parse(device_state_.reset_spec().c_str(), *this); + bool ok = false; + const reset_schedule::Spec spec = + reset_schedule::parse(device_state_.reset_spec().c_str(), &ok); + if (!ok) { + warning("reset spec '%s' not understood, using %s", + device_state_.reset_spec().c_str(), + reset_schedule::mode_name(spec.mode)); + } + return spec; } bool App::_clock_fresh() const { @@ -261,6 +274,7 @@ bool App::_clock_fresh() const { void App::_check_reset() { if (!_clock_fresh()) return; + StateLock lock(state_mutex_); const reset_schedule::Spec spec = _reset_spec(); if (spec.mode == reset_schedule::Mode::kOff) return; @@ -306,6 +320,7 @@ void App::_schedule_next_wake() { // Runs on the UI task. RAM only: no NVS write, no HTTP. void App::_handle_counter_press(uint8_t btn_id) { + StateLock lock(state_mutex_); // Before anything else: a press just after a boundary belongs to the new // period, not the one that ended. The clock survives deep sleep, so this // is knowable without the network. @@ -362,8 +377,11 @@ void App::_flush_pending() { if (uxQueueMessagesWaiting(press_queue_) == 0) return; if (network_.get_state() != Network::State::W_CONNECTED) return; - // One NVS write covers however many presses are waiting. - device_state_.save_all(); + { + // One NVS write covers however many presses are waiting. + StateLock lock(state_mutex_); + device_state_.save_all(); + } PressQueueElement element; while (xQueueReceive(press_queue_, &element, 0) == pdTRUE) { @@ -391,32 +409,49 @@ void App::_flush_pending() { } void App::_net_on_connect() { - webhook_.begin(); + // NETWORK task. Deliberately does nothing but raise a flag: webhook_ owns + // one HTTPClient and one WiFiClientSecure, and _flush_pending() drives + // them from the main task. Posting from here would put two tasks on the + // same TLS connection - reachable on any connect with a queued press, + // because Network reports W_CONNECTED one state before this fires. + net_connected_event_ = true; +} - const bool have_presses = - press_queue_ != nullptr && uxQueueMessagesWaiting(press_queue_) > 0; - const time_t now = time(nullptr); - const bool clock_old = - !device_state_.clock_valid() || - (now - static_cast(device_state_.last_time_sync())) > - static_cast(CLOCK_RESYNC_SECONDS); - - if (boot_cause_ == BootCause::TIMER) { - webhook_.send_heartbeat(); - } else if (!have_presses && clock_old) { - // Every response carries the clock, so a bare sync is only worth - // sending when nothing else is going out anyway. - webhook_.sync_time(); +// MAIN task. Sole owner of webhook_. +void App::_service_webhook() { + if (network_.get_state() != Network::State::W_CONNECTED) return; + + if (net_connected_event_.exchange(false)) { + webhook_.begin(); + + const bool have_presses = + press_queue_ != nullptr && uxQueueMessagesWaiting(press_queue_) > 0; + const time_t now = time(nullptr); + const bool clock_old = + !device_state_.clock_valid() || + (now - static_cast(device_state_.last_time_sync())) > + static_cast(CLOCK_RESYNC_SECONDS); + + if (boot_cause_ == BootCause::TIMER) { + webhook_.send_heartbeat(); + } else if (!have_presses && clock_old) { + // Every response carries the clock, so a bare sync is only worth + // sending when nothing else is going out anyway. + webhook_.sync_time(); + } + + // The clock may only just have become valid, so re-test the boundary + // now that it has. + _check_reset(); } - // The clock may only just have become valid, so re-test the boundary now - // that it has. - _check_reset(); - if (reset_to_report_) { + if (reset_to_report_.load()) { if (webhook_.send_reset(reset_schedule::mode_name(_reset_spec().mode))) { reset_to_report_ = false; } } + + _flush_pending(); } void App::_handle_ui_event_global(UserInput::Event event) { @@ -664,6 +699,7 @@ void App::_main_task() { debug("Starting main state machine loop"); while (true) { loop(); + _service_webhook(); _service_display(); esp_task_wdt_reset(); delay(10); diff --git a/Firmware/HomeButtonsArduino/src/app.h b/Firmware/HomeButtonsArduino/src/app.h index 9943537..10ee7b4 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -5,6 +5,7 @@ #include #include "freertos/FreeRTOS.h" // must precede queue.h #include "freertos/queue.h" +#include "freertos/semphr.h" #include "state.h" #include "network.h" #include "webhook.h" @@ -200,7 +201,14 @@ class App : public AppStateMachine, public Logger { void _start_tasks(); void _handle_ui_event_global(UserInput::Event event); + // Runs on the NETWORK task. Must not touch webhook_ - see + // _service_webhook(). void _net_on_connect(); + // Everything that talks to webhook_, on the main task only. Webhook owns + // a single HTTPClient and WiFiClientSecure; _net_on_connect() fires from + // the network task while _flush_pending() runs here, so doing the + // on-connect work there would put two tasks on one TLS connection. + void _service_webhook(); // Redraws the main screen when a press has changed it. Driven from the // main loop rather than from individual states, so the number on the // display follows the button press regardless of what the state machine @@ -220,6 +228,22 @@ class App : public AppStateMachine, public Logger { // this can run before the network is up - and must, so that a press just // after the boundary counts toward the new period rather than the old. void _check_reset(); + // Guards the counters, the reset period and the button labels, which the + // UI task mutates on a press and the main task reads, saves and resets. + // Recursive because _handle_counter_press() calls _check_reset(). + class StateLock { + public: + explicit StateLock(SemaphoreHandle_t m) : m_(m) { + if (m_ != nullptr) xSemaphoreTakeRecursive(m_, portMAX_DELAY); + } + ~StateLock() { + if (m_ != nullptr) xSemaphoreGiveRecursive(m_); + } + StateLock(const StateLock&) = delete; + + private: + SemaphoreHandle_t m_; + }; reset_schedule::Spec _reset_spec(); bool _clock_fresh() const; // Seconds until the next boundary, into flags().schedule_wakeup_time. @@ -275,7 +299,11 @@ class App : public AppStateMachine, public Logger { // Set when _check_reset() clears the counters, so the following connect // reports it. RAM only: if the report is lost, the next press carries // absolute counts and the receiver self-heals. - bool reset_to_report_ = false; + std::atomic reset_to_report_{false}; + // Set by the network task when the link comes up; consumed by the main + // task, which owns webhook_. + std::atomic net_connected_event_{false}; + SemaphoreHandle_t state_mutex_ = nullptr; BootCause boot_cause_; uint8_t wakeup_btn_id_ = 0; diff --git a/Firmware/HomeButtonsArduino/src/reset_schedule.cpp b/Firmware/HomeButtonsArduino/src/reset_schedule.cpp index a4f5459..ffd55ac 100644 --- a/Firmware/HomeButtonsArduino/src/reset_schedule.cpp +++ b/Firmware/HomeButtonsArduino/src/reset_schedule.cpp @@ -1,7 +1,9 @@ #include "reset_schedule.h" +#include #include #include +#include namespace reset_schedule { namespace { @@ -83,16 +85,23 @@ const char* mode_name(Mode mode) { } } -Spec parse(const char* text, const Logger& log) { +Spec parse(const char* text, bool* ok) { Spec spec; // defaults to daily 03:00 + if (ok != nullptr) *ok = true; + auto fail = [&]() { + if (ok != nullptr) *ok = false; + }; if (text == nullptr || *text == '\0') return spec; - char buf[RESET_SPEC_MAXLEN + 1] = {}; + char buf[kSpecMaxLen + 1] = {}; snprintf(buf, sizeof(buf), "%s", text); char* save = nullptr; const char* mode_tok = strtok_r(buf, " \t", &save); - if (mode_tok == nullptr) return spec; + if (mode_tok == nullptr) { + fail(); + return spec; + } if (strcasecmp(mode_tok, "off") == 0) { spec.mode = Mode::kOff; @@ -102,7 +111,7 @@ Spec parse(const char* text, const Logger& log) { if (strcasecmp(mode_tok, "daily") == 0) { spec.mode = Mode::kDaily; if (!parse_hhmm(strtok_r(nullptr, " \t", &save), spec.minute_of_day)) { - log.warning("reset spec '%s': bad time, using 03:00", text); + fail(); } return spec; } @@ -112,12 +121,12 @@ Spec parse(const char* text, const Logger& log) { const char* day_tok = strtok_r(nullptr, " \t", &save); int weekday = (day_tok != nullptr) ? weekday_from_name(day_tok) : -1; if (weekday < 0) { - log.warning("reset spec '%s': bad weekday, using mon", text); + fail(); weekday = 1; } spec.weekday = static_cast(weekday); if (!parse_hhmm(strtok_r(nullptr, " \t", &save), spec.minute_of_day)) { - log.warning("reset spec '%s': bad time, using 03:00", text); + fail(); } return spec; } @@ -129,35 +138,40 @@ Spec parse(const char* text, const Logger& log) { // Capped at 28 deliberately: 29-31 would skip short months, which is a // surprising way for a reset to quietly not happen. if (day < 1 || day > 28) { - log.warning("reset spec '%s': day must be 1-28, using 1", text); + fail(); day = 1; } spec.day_of_month = static_cast(day); if (!parse_hhmm(strtok_r(nullptr, " \t", &save), spec.minute_of_day)) { - log.warning("reset spec '%s': bad time, using 03:00", text); + fail(); } return spec; } - log.warning("reset spec '%s' not understood, using daily 03:00", text); + fail(); return Spec{}; } -StaticString format(const Spec& spec) { +void format(const Spec& spec, char* out, size_t out_size) { + if (out == nullptr || out_size == 0) return; const int hour = spec.minute_of_day / 60; const int minute = spec.minute_of_day % 60; switch (spec.mode) { case Mode::kOff: - return StaticString("off"); + snprintf(out, out_size, "off"); + break; case Mode::kWeekly: - return StaticString( - "weekly %s %02d:%02d", kWeekdayNames[spec.weekday % 7], hour, minute); + snprintf(out, out_size, "weekly %s %02d:%02d", + kWeekdayNames[spec.weekday % 7], hour, minute); + break; case Mode::kMonthly: - return StaticString("monthly %u %02d:%02d", - spec.day_of_month, hour, minute); + snprintf(out, out_size, "monthly %u %02d:%02d", spec.day_of_month, hour, + minute); + break; case Mode::kDaily: default: - return StaticString("daily %02d:%02d", hour, minute); + snprintf(out, out_size, "daily %02d:%02d", hour, minute); + break; } } @@ -238,14 +252,14 @@ uint32_t seconds_until_next(const Spec& spec, time_t local_time) { boundary_day * kSecondsPerDay + boundary_offset; int64_t delta = boundary - static_cast(local_time); - // Clamped into what the deep sleep timer accepts. SCHEDULE_WAKEUP_MAX is + // Clamped into what the deep sleep timer accepts. kWakeMaxSeconds is // 24h, so weekly and monthly simply wake once a day and re-evaluate - // which also keeps the clock resynced rather than drifting for a month. - if (delta < static_cast(SCHEDULE_WAKEUP_MIN)) { - delta = SCHEDULE_WAKEUP_MIN; + if (delta < static_cast(kWakeMinSeconds)) { + delta = kWakeMinSeconds; } - if (delta > static_cast(SCHEDULE_WAKEUP_MAX)) { - delta = SCHEDULE_WAKEUP_MAX; + if (delta > static_cast(kWakeMaxSeconds)) { + delta = kWakeMaxSeconds; } return static_cast(delta); } diff --git a/Firmware/HomeButtonsArduino/src/reset_schedule.h b/Firmware/HomeButtonsArduino/src/reset_schedule.h index e513596..71fde1c 100644 --- a/Firmware/HomeButtonsArduino/src/reset_schedule.h +++ b/Firmware/HomeButtonsArduino/src/reset_schedule.h @@ -1,12 +1,10 @@ #ifndef HOMEBUTTONS_RESET_SCHEDULE_H #define HOMEBUTTONS_RESET_SCHEDULE_H +#include +#include #include -#include "config.h" -#include "logger.h" -#include "types.h" - // When the counters clear themselves. // // The device has no clock of its own worth trusting and deliberately knows @@ -27,6 +25,14 @@ // reset silently. namespace reset_schedule { +// Deliberately free of Arduino, Logger and config.h so the whole of this +// unit can be compiled and unit-tested on the host. Every calendar bug in +// here is the kind that only shows up months later on a device, which is +// exactly the code that should not need hardware to exercise. +static constexpr size_t kSpecMaxLen = 24; +static constexpr uint32_t kWakeMinSeconds = 5; +static constexpr uint32_t kWakeMaxSeconds = 24UL * 60UL * 60UL; + enum class Mode : uint8_t { kOff, kDaily, kWeekly, kMonthly }; struct Spec { @@ -38,11 +44,14 @@ struct Spec { const char* mode_name(Mode mode); -// Never fails: an unrecognised spec logs and yields the default. -Spec parse(const char* text, const Logger& log); +// Never fails: an unrecognised spec yields the default. `ok` reports +// whether the input was understood exactly, so the caller can log without +// this unit needing a logger. +Spec parse(const char* text, bool* ok = nullptr); // Renders a spec back to its canonical text, for the portal field. -StaticString format(const Spec& spec); +// Writes at most out_size bytes including the terminator. +void format(const Spec& spec, char* out, size_t out_size); // The reset period a given local time falls in, as a plain integer that // only changes when a boundary is crossed. Comparing this against the diff --git a/Firmware/HomeButtonsArduino/src/setup.cpp b/Firmware/HomeButtonsArduino/src/setup.cpp index 0315bf8..51b0a25 100644 --- a/Firmware/HomeButtonsArduino/src/setup.cpp +++ b/Firmware/HomeButtonsArduino/src/setup.cpp @@ -189,9 +189,16 @@ void HBSetup::save_params_callback() { // Normalised through the parser so whatever lands in NVS is canonical // and a typo cannot silently disable the reset. const ResetSpecType entered{reset_spec_param.getValue()}; + bool ok = false; const auto spec = reset_schedule::parse( - entered.empty() ? RESET_SPEC_DFLT : entered.c_str(), app_); - app_.device_state_.set_reset_spec(reset_schedule::format(spec)); + entered.empty() ? RESET_SPEC_DFLT : entered.c_str(), &ok); + if (!ok) { + app_.warning("reset spec '%s' not understood, storing default", + entered.c_str()); + } + char canonical[reset_schedule::kSpecMaxLen + 1] = {}; + reset_schedule::format(spec, canonical, sizeof(canonical)); + app_.device_state_.set_reset_spec(ResetSpecType{canonical}); } { const char* v = awake_mode_param.getValue(); diff --git a/Firmware/HomeButtonsArduino/src/webhook.cpp b/Firmware/HomeButtonsArduino/src/webhook.cpp index 277d4b5..6909ede 100644 --- a/Firmware/HomeButtonsArduino/src/webhook.cpp +++ b/Firmware/HomeButtonsArduino/src/webhook.cpp @@ -125,7 +125,10 @@ bool Webhook::send_reset(const char* mode) { bool Webhook::sync_time() { Event event{}; - event.seq = device_state_.seq(); // no side effect, so no new sequence + // Still takes a sequence number: 'unique per request' is the invariant + // the field documents, and reusing one would break a receiver that ever + // dedupes outside the press branch. + event.seq = device_state_.next_seq(); char body[HTTP_PAYLOAD_SIZE]; size_t len = _build_body(body, sizeof(body), event, "time", nullptr); debug("time sync body: %s", body); diff --git a/Firmware/HomeButtonsArduino/test/test_reset_schedule/test_reset_schedule.cpp b/Firmware/HomeButtonsArduino/test/test_reset_schedule/test_reset_schedule.cpp new file mode 100644 index 0000000..652e6dc --- /dev/null +++ b/Firmware/HomeButtonsArduino/test/test_reset_schedule/test_reset_schedule.cpp @@ -0,0 +1,238 @@ +// Host-side tests for the counter reset schedule. +// +// This is the one part of the firmware that is pure calendar arithmetic with +// no hardware behind it, and the one part whose bugs would surface as "the +// counter cleared on the wrong day" weeks after a flash. Run with: +// +// pio test -e native +// +// All times below are "local seconds" - a UTC epoch already shifted by the +// offset the receiver reported - which is exactly what the firmware passes +// in, so the tests exercise the same units as production. + +#include + +#include +#include + +#include "reset_schedule.h" + +using reset_schedule::Mode; +using reset_schedule::Spec; + +namespace { + +// Builds a local-seconds value from a civil date/time, without depending on +// the host timezone: timegm-equivalent via a fixed reference. +time_t at(int year, int month, int day, int hour = 0, int minute = 0, + int second = 0) { + struct tm t = {}; + t.tm_year = year - 1900; + t.tm_mon = month - 1; + t.tm_mday = day; + t.tm_hour = hour; + t.tm_min = minute; + t.tm_sec = second; + return timegm(&t); +} + +Spec spec_from(const char* text, bool expect_ok = true) { + bool ok = false; + Spec s = reset_schedule::parse(text, &ok); + TEST_ASSERT_EQUAL_MESSAGE(expect_ok, ok, text); + return s; +} + +} // namespace + +// --- parsing --------------------------------------------------------------- + +void test_parse_daily() { + Spec s = spec_from("daily 03:00"); + TEST_ASSERT_TRUE(s.mode == Mode::kDaily); + TEST_ASSERT_EQUAL_UINT16(180, s.minute_of_day); +} + +void test_parse_off() { + Spec s = spec_from("off"); + TEST_ASSERT_TRUE(s.mode == Mode::kOff); +} + +void test_parse_weekly() { + Spec s = spec_from("weekly mon 04:30"); + TEST_ASSERT_TRUE(s.mode == Mode::kWeekly); + TEST_ASSERT_EQUAL_UINT8(1, s.weekday); + TEST_ASSERT_EQUAL_UINT16(4 * 60 + 30, s.minute_of_day); +} + +void test_parse_monthly() { + Spec s = spec_from("monthly 5 06:15"); + TEST_ASSERT_TRUE(s.mode == Mode::kMonthly); + TEST_ASSERT_EQUAL_UINT8(5, s.day_of_month); + TEST_ASSERT_EQUAL_UINT16(6 * 60 + 15, s.minute_of_day); +} + +void test_parse_is_case_insensitive() { + Spec s = spec_from("WEEKLY Fri 23:59"); + TEST_ASSERT_TRUE(s.mode == Mode::kWeekly); + TEST_ASSERT_EQUAL_UINT8(5, s.weekday); +} + +// A typo must not silently disable the reset - it falls back to the default +// and reports that it did not understand. +void test_parse_garbage_falls_back_to_daily() { + Spec s = spec_from("every other tuesday", /*expect_ok=*/false); + TEST_ASSERT_TRUE(s.mode == Mode::kDaily); + TEST_ASSERT_EQUAL_UINT16(180, s.minute_of_day); +} + +void test_parse_rejects_out_of_range_time() { + Spec s = spec_from("daily 25:00", /*expect_ok=*/false); + TEST_ASSERT_EQUAL_UINT16(180, s.minute_of_day); +} + +void test_parse_caps_monthly_day_at_28() { + Spec s = spec_from("monthly 31 03:00", /*expect_ok=*/false); + TEST_ASSERT_EQUAL_UINT8(1, s.day_of_month); +} + +void test_parse_empty_is_default_and_ok() { + Spec s = spec_from(""); + TEST_ASSERT_TRUE(s.mode == Mode::kDaily); +} + +void test_format_roundtrips() { + const char* cases[] = {"off", "daily 03:00", "weekly mon 04:30", + "monthly 5 06:15"}; + for (const char* c : cases) { + char out[reset_schedule::kSpecMaxLen + 1] = {}; + reset_schedule::format(spec_from(c), out, sizeof(out)); + TEST_ASSERT_EQUAL_STRING(c, out); + } +} + +// --- period boundaries ----------------------------------------------------- + +void test_daily_period_changes_at_boundary() { + Spec s = spec_from("daily 03:00"); + // One second before 03:00 is still the previous period. + TEST_ASSERT_EQUAL_INT32(period_of(s, at(2026, 7, 27, 2, 59, 59)), + period_of(s, at(2026, 7, 26, 12, 0, 0))); + // 03:00 exactly starts the new one. + TEST_ASSERT_NOT_EQUAL(period_of(s, at(2026, 7, 27, 2, 59, 59)), + period_of(s, at(2026, 7, 27, 3, 0, 0))); +} + +// Midnight must not be a boundary when the reset is at 03:00 - the whole +// point of shifting by minute_of_day. +void test_daily_midnight_is_not_a_boundary() { + Spec s = spec_from("daily 03:00"); + TEST_ASSERT_EQUAL_INT32(period_of(s, at(2026, 7, 26, 23, 59, 0)), + period_of(s, at(2026, 7, 27, 0, 1, 0))); +} + +void test_weekly_period_changes_on_configured_day() { + Spec s = spec_from("weekly mon 03:00"); + // 2026-07-27 is a Monday. + const int32_t before = period_of(s, at(2026, 7, 27, 2, 0, 0)); + const int32_t after = period_of(s, at(2026, 7, 27, 4, 0, 0)); + TEST_ASSERT_NOT_EQUAL(before, after); + // Tuesday through Sunday all sit in the same period as Monday afternoon. + TEST_ASSERT_EQUAL_INT32(after, period_of(s, at(2026, 7, 28, 12, 0, 0))); + TEST_ASSERT_EQUAL_INT32(after, period_of(s, at(2026, 8, 2, 23, 0, 0))); + // The following Monday starts a new one. + TEST_ASSERT_NOT_EQUAL(after, period_of(s, at(2026, 8, 3, 4, 0, 0))); +} + +void test_monthly_period_changes_on_configured_day() { + Spec s = spec_from("monthly 5 03:00"); + const int32_t before = period_of(s, at(2026, 7, 5, 2, 0, 0)); + const int32_t after = period_of(s, at(2026, 7, 5, 4, 0, 0)); + TEST_ASSERT_NOT_EQUAL(before, after); + TEST_ASSERT_EQUAL_INT32(after, period_of(s, at(2026, 7, 31, 12, 0, 0))); + TEST_ASSERT_EQUAL_INT32(after, period_of(s, at(2026, 8, 4, 12, 0, 0))); + TEST_ASSERT_NOT_EQUAL(after, period_of(s, at(2026, 8, 5, 4, 0, 0))); +} + +// 0 is the "never established" sentinel the firmware relies on to avoid +// clearing a count on first setup, so a real period must never be 0. +void test_period_is_never_zero_for_real_dates() { + const char* specs[] = {"daily 03:00", "weekly mon 03:00", "monthly 1 03:00"}; + for (const char* c : specs) { + Spec s = spec_from(c); + TEST_ASSERT_NOT_EQUAL(0, period_of(s, at(2026, 7, 27, 12, 0, 0))); + TEST_ASSERT_NOT_EQUAL(0, period_of(s, at(2035, 1, 1, 0, 0, 0))); + } +} + +void test_off_never_resets() { + Spec s = spec_from("off"); + TEST_ASSERT_EQUAL_INT32(0, period_of(s, at(2026, 7, 27, 12, 0, 0))); + TEST_ASSERT_EQUAL_UINT32(0, seconds_until_next(s, at(2026, 7, 27, 12, 0, 0))); +} + +// --- next-wake ------------------------------------------------------------- + +void test_seconds_until_next_daily() { + Spec s = spec_from("daily 03:00"); + // 23:00 -> 03:00 is four hours. + TEST_ASSERT_EQUAL_UINT32(4 * 3600, + seconds_until_next(s, at(2026, 7, 26, 23, 0, 0))); + // Just after the boundary, nearly a full day. + TEST_ASSERT_EQUAL_UINT32(24 * 3600 - 60, + seconds_until_next(s, at(2026, 7, 27, 3, 1, 0))); +} + +// The wake it computes must actually land in the next period, for every +// mode - this is the property that makes the schedule correct. +void test_next_wake_lands_in_the_next_period() { + const char* specs[] = {"daily 03:00", "weekly wed 07:45", "monthly 12 21:30"}; + const time_t probes[] = {at(2026, 1, 1, 0, 0, 0), at(2026, 2, 28, 23, 59, 0), + at(2026, 7, 27, 3, 0, 0), at(2026, 12, 31, 22, 0, 0), + at(2027, 3, 1, 12, 0, 0)}; + for (const char* c : specs) { + Spec s = spec_from(c); + for (time_t p : probes) { + const uint32_t secs = seconds_until_next(s, p); + // Clamped at 24h, so weekly/monthly wake daily and re-evaluate; only + // assert progress in that case. + if (secs >= reset_schedule::kWakeMaxSeconds) continue; + TEST_ASSERT_NOT_EQUAL_MESSAGE(period_of(s, p), + period_of(s, p + (time_t)secs), c); + // And not a second earlier. + TEST_ASSERT_EQUAL_INT32(period_of(s, p), + period_of(s, p + (time_t)secs - 1)); + } + } +} + +void test_next_wake_is_clamped() { + Spec s = spec_from("monthly 1 03:00"); + const uint32_t secs = seconds_until_next(s, at(2026, 7, 2, 3, 0, 0)); + TEST_ASSERT_EQUAL_UINT32(reset_schedule::kWakeMaxSeconds, secs); + TEST_ASSERT_TRUE(secs >= reset_schedule::kWakeMinSeconds); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_parse_daily); + RUN_TEST(test_parse_off); + RUN_TEST(test_parse_weekly); + RUN_TEST(test_parse_monthly); + RUN_TEST(test_parse_is_case_insensitive); + RUN_TEST(test_parse_garbage_falls_back_to_daily); + RUN_TEST(test_parse_rejects_out_of_range_time); + RUN_TEST(test_parse_caps_monthly_day_at_28); + RUN_TEST(test_parse_empty_is_default_and_ok); + RUN_TEST(test_format_roundtrips); + RUN_TEST(test_daily_period_changes_at_boundary); + RUN_TEST(test_daily_midnight_is_not_a_boundary); + RUN_TEST(test_weekly_period_changes_on_configured_day); + RUN_TEST(test_monthly_period_changes_on_configured_day); + RUN_TEST(test_period_is_never_zero_for_real_dates); + RUN_TEST(test_off_never_resets); + RUN_TEST(test_seconds_until_next_daily); + RUN_TEST(test_next_wake_lands_in_the_next_period); + RUN_TEST(test_next_wake_is_clamped); + return UNITY_END(); +} From 7c1e84e35aaed7dd4ff1bdc56ebd60719ef90e5f Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 15:17:23 +0200 Subject: [PATCH 17/41] CI: run the host unit tests before building firmware Seconds rather than minutes, and a calendar bug in reset_schedule would otherwise only surface on a device weeks later. Ordered first so a logic failure does not wait on a full ESP-IDF build. Co-Authored-By: Claude --- .github/workflows/firmware.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 779bf25..78b2fca 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -59,6 +59,13 @@ jobs: # interchangeable: original_debug routes the console to USB CDC and # uses QIO flash mode, so it has its own bootloader.bin as well as its # own firmware.bin. + # Host-side tests for the pure-logic modules. Runs before the firmware + # build because it is seconds rather than minutes, and a calendar bug + # here would otherwise only show up on a device weeks later. + - name: Unit tests + working-directory: Firmware/HomeButtonsArduino + run: pio test -e native + - name: Build firmware working-directory: Firmware/HomeButtonsArduino run: pio run -e original_release -e original_debug From aad92ecad9fce9a38d0b940b6a967b9030fa8b0f Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 20:50:38 +0200 Subject: [PATCH 18/41] Stamp the commit sha into the firmware, the filesystem and the artifacts Firmware and SPIFFS are flashed separately, so they can silently drift apart - and after a run of changes to both there was no way to answer "is this even the build I flashed" without diffing binaries. pre_script.py compiles the short sha in as BUILD_SHA; make_icons.py writes the same value to /build.txt inside the SPIFFS image. Both mark a tree with uncommitted changes as "+dirty", so a hand-built image is never mistaken for a tagged one. The device reports both: at boot in the log, on the Device Info screen as "fw " and "fs ", and in every webhook payload as `build`. A mismatch between the two is logged as a warning rather than treated as an error - it usually just means a forgotten uploadfs, and the device runs fine either way. __file__ is not reliably defined inside a PlatformIO extra script, which is why the first attempt stamped "nogit"; the project directory now comes from SCons instead. Artifacts are named homebuttons-original- and homebuttons-original-debug-, so successive downloads no longer pile up as "firmware-original (3)" with nothing to distinguish them, and build.txt ships in the bundle so an unzipped folder identifies itself. Checkout needs fetch-depth 0 for the sha to be available. Co-Authored-By: Claude --- .github/workflows/firmware.yml | 16 +++++++-- Firmware/HomeButtonsArduino/.gitignore | 3 ++ Firmware/HomeButtonsArduino/pre_script.py | 35 ++++++++++++++++++ Firmware/HomeButtonsArduino/src/app.cpp | 36 ++++++++++++++++++- Firmware/HomeButtonsArduino/src/app.h | 5 +++ Firmware/HomeButtonsArduino/src/config.h | 10 ++++++ .../src/display/display.cpp | 10 ++++++ .../HomeButtonsArduino/src/display/display.h | 6 ++++ Firmware/HomeButtonsArduino/src/types.h | 1 + Firmware/HomeButtonsArduino/src/webhook.cpp | 1 + .../HomeButtonsArduino/tools/make_icons.py | 26 ++++++++++++++ 11 files changed, 146 insertions(+), 3 deletions(-) diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 78b2fca..4a27deb 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -26,6 +26,17 @@ jobs: steps: - uses: actions/checkout@v4 + with: + # pre_script.py and make_icons.py stamp the short sha into the + # firmware and the SPIFFS image, so the checkout needs real git + # metadata rather than a detached blob. + fetch-depth: 0 + + # Artifact names carry the sha so successive downloads land as + # homebuttons-original-.zip instead of "firmware-original (3)". + - name: Build id + id: build_id + run: echo "sha=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT" - uses: actions/setup-python@v5 with: @@ -119,6 +130,7 @@ jobs: ".pio/build/${ENV}/firmware.bin" \ ".pio/build/${ENV}/spiffs.bin" \ partitions.csv \ + data/build.txt \ "dist/${ENV}/" echo "--- dist/${ENV} ---" ls -l "dist/${ENV}" @@ -126,7 +138,7 @@ jobs: - uses: actions/upload-artifact@v4 with: - name: firmware-original + name: homebuttons-original-${{ steps.build_id.outputs.sha }} path: Firmware/HomeButtonsArduino/dist/original_release/ if-no-files-found: error @@ -134,6 +146,6 @@ jobs: # USB-C cable instead of the UART pins on the debug header. - uses: actions/upload-artifact@v4 with: - name: firmware-original-debug + name: homebuttons-original-debug-${{ steps.build_id.outputs.sha }} path: Firmware/HomeButtonsArduino/dist/original_debug/ if-no-files-found: error diff --git a/Firmware/HomeButtonsArduino/.gitignore b/Firmware/HomeButtonsArduino/.gitignore index 0790365..ceb7017 100644 --- a/Firmware/HomeButtonsArduino/.gitignore +++ b/Firmware/HomeButtonsArduino/.gitignore @@ -17,3 +17,6 @@ sdkconfig.original_debug # CI artifact staging dir dist/ + +# Generated by tools/make_icons.py alongside the icons +data/build.txt diff --git a/Firmware/HomeButtonsArduino/pre_script.py b/Firmware/HomeButtonsArduino/pre_script.py index 833a3ca..fa58f8e 100644 --- a/Firmware/HomeButtonsArduino/pre_script.py +++ b/Firmware/HomeButtonsArduino/pre_script.py @@ -1,6 +1,9 @@ #!/usr/bin/env python import os +import subprocess + +Import("env") sdkconfig_files = ["sdkconfig.original_release", "sdkconfig.original_debug"] @@ -20,3 +23,35 @@ def delete_sdkconfig_files(): print("#### PRE SCRIPT ####") delete_sdkconfig_files() print("#### PRE SCRIPT DONE ####") + + +def build_id(): + """Short commit sha, marked when the tree has uncommitted changes. + + Flashed into the firmware and written into the SPIFFS image so a device + can say exactly what it is running - "is this even the build I flashed" + is otherwise unanswerable without a diff of the binaries. + """ + # __file__ is not reliably defined inside a PlatformIO extra script, so + # take the project directory from SCons rather than from this module. + root = env.subst("$PROJECT_DIR") + try: + sha = subprocess.check_output( + ["git", "rev-parse", "--short=8", "HEAD"], + stderr=subprocess.DEVNULL, cwd=root, + ).decode().strip() + except Exception: + return "nogit" + try: + dirty = subprocess.call( + ["git", "diff", "--quiet", "--ignore-submodules", "HEAD"], + stderr=subprocess.DEVNULL, cwd=root, + ) != 0 + except Exception: + dirty = False + return sha + ("+dirty" if dirty else "") + + +BUILD_ID = build_id() +print("#### BUILD ID: {} ####".format(BUILD_ID)) +env.Append(CPPDEFINES=[("BUILD_SHA", env.StringifyMacro(BUILD_ID))]) diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index 31831d5..c959ae2 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "esp_ota_ops.h" #include "config.h" @@ -303,6 +304,25 @@ void App::_check_reset() { had_counts ? "" : " (already zero)"); } +void App::_read_spiffs_build() { + spiffs_build_ = ""; + File f = SPIFFS.open("/build.txt", FILE_READ); + if (!f) { + warning("no /build.txt in SPIFFS - image predates build stamping"); + return; + } + char buf[BUILD_ID_MAXLEN + 1] = {}; + const size_t n = f.readBytes(buf, BUILD_ID_MAXLEN); + f.close(); + for (size_t i = 0; i < n; i++) { + if (buf[i] == '\n' || buf[i] == '\r') { + buf[i] = '\0'; + break; + } + } + spiffs_build_ = buf; +} + void App::_schedule_next_wake() { device_state_.flags().schedule_wakeup_time = 0; if (!device_state_.clock_valid()) return; @@ -474,7 +494,7 @@ void App::_service_display() { void App::_main_task() { info("woke up."); info("cpu freq: %d MHz", getCpuFrequencyMhz()); - info("SW version: %s", SW_VERSION); + info("SW version: %s, build %s", SW_VERSION, BUILD_ID); // ------ init hardware ------ bool hw_init_ok = hw_.init(); @@ -503,6 +523,20 @@ void App::_main_task() { _begin_hw(); + // Display::begin() mounts SPIFFS, so the stamp is readable from here on. + _read_spiffs_build(); + display_.set_spiffs_build(spiffs_build_.c_str()); + if (spiffs_build_.empty()) { + warning("SPIFFS build unknown - reflash the filesystem image"); + } else if (!(spiffs_build_ == BUILD_ID)) { + // Not fatal: the two images are flashed separately and a mismatch is + // usually just a forgotten uploadfs. Worth saying out loud though. + warning("build mismatch: firmware %s, SPIFFS %s", BUILD_ID, + spiffs_build_.c_str()); + } else { + info("build %s (firmware and SPIFFS match)", BUILD_ID); + } + // ------ after update handler ------ if (device_state_.persisted().last_sw_ver != SW_VERSION) { if (device_state_.persisted().last_sw_ver.length() > 0) { diff --git a/Firmware/HomeButtonsArduino/src/app.h b/Firmware/HomeButtonsArduino/src/app.h index 10ee7b4..c1df99f 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -248,6 +248,10 @@ class App : public AppStateMachine, public Logger { bool _clock_fresh() const; // Seconds until the next boundary, into flags().schedule_wakeup_time. void _schedule_next_wake(); + // Reads /build.txt out of the SPIFFS image. Empty when the file is + // missing, which means the filesystem predates build stamping or was + // never flashed. + void _read_spiffs_build(); void _refresh_counter_labels(); void _flush_pending(); @@ -304,6 +308,7 @@ class App : public AppStateMachine, public Logger { // task, which owns webhook_. std::atomic net_connected_event_{false}; SemaphoreHandle_t state_mutex_ = nullptr; + BuildIdType spiffs_build_; BootCause boot_cause_; uint8_t wakeup_btn_id_ = 0; diff --git a/Firmware/HomeButtonsArduino/src/config.h b/Firmware/HomeButtonsArduino/src/config.h index c26bf8c..4c0885b 100644 --- a/Firmware/HomeButtonsArduino/src/config.h +++ b/Firmware/HomeButtonsArduino/src/config.h @@ -24,6 +24,16 @@ // ------ device ------ static constexpr char MANUFACTURER[] = "PLab"; static constexpr char SW_VERSION[] = "v3.0.0-counter.1"; +// Short commit sha, injected by pre_script.py and suffixed "+dirty" when +// built from a modified tree. The SPIFFS image carries the same value in +// /build.txt, so a device can report whether its code and its filesystem +// came from the same commit - the two are flashed separately and drifting +// apart is otherwise invisible. +#ifndef BUILD_SHA +#define BUILD_SHA "unknown" +#endif +static constexpr char BUILD_ID[] = BUILD_SHA; +static constexpr size_t BUILD_ID_MAXLEN = 24; static constexpr char SW_MODEL_ID[] = "A1"; // must match the burnt eFuse // ------ URLs ------ diff --git a/Firmware/HomeButtonsArduino/src/display/display.cpp b/Firmware/HomeButtonsArduino/src/display/display.cpp index 1eff8c7..196bd57 100644 --- a/Firmware/HomeButtonsArduino/src/display/display.cpp +++ b/Firmware/HomeButtonsArduino/src/display/display.cpp @@ -591,6 +591,16 @@ void Display::draw_device_info() { u8g2.setCursor(0, 106); u8g2.print(device_state_.factory().unique_id.c_str()); + // firmware build / filesystem build. They are flashed separately, so + // showing both makes a stale uploadfs obvious at a glance. + u8g2.setCursor(0, 118); + u8g2.print(StaticString<48>("fw %s", BUILD_ID).c_str()); + u8g2.setCursor(0, 130); + u8g2.print(StaticString<48>( + "fs %s", + spiffs_build_.empty() ? "missing" : spiffs_build_.c_str()) + .c_str()); + UIState::MessageType ip_info = UIState::MessageType("IP: %s", device_state_.ip()); u8g2.setCursor(0, 140); diff --git a/Firmware/HomeButtonsArduino/src/display/display.h b/Firmware/HomeButtonsArduino/src/display/display.h index 6f79039..cb097e2 100644 --- a/Firmware/HomeButtonsArduino/src/display/display.h +++ b/Firmware/HomeButtonsArduino/src/display/display.h @@ -44,6 +44,11 @@ class DeviceState; class Display : public Logger { public: enum class State { IDLE, ACTIVE, CMD_END, ENDING }; + // Build stamp read out of the SPIFFS image. Shown on the Device Info + // screen next to the firmware's own, so a mismatch is visible on the + // device rather than only in the serial log. + void set_spiffs_build(const char* build) { spiffs_build_ = build; } + explicit Display(DeviceState& device_state) : Logger("Display"), device_state_(device_state) {} void begin(HardwareDefinition& HW); @@ -78,6 +83,7 @@ class Display : public Logger { bool new_ui_cmd = false; bool redraw_in_progress = false; bool spiffs_mounted_ = false; + StaticString spiffs_build_; uint16_t text_color = GxEPD_BLACK; uint16_t bg_color = GxEPD_WHITE; diff --git a/Firmware/HomeButtonsArduino/src/types.h b/Firmware/HomeButtonsArduino/src/types.h index 8ee97a4..d6c5e81 100644 --- a/Firmware/HomeButtonsArduino/src/types.h +++ b/Firmware/HomeButtonsArduino/src/types.h @@ -19,6 +19,7 @@ using UserMessage = StaticString; using EndpointUrlType = StaticString; using AuthTokenType = StaticString; using ResetSpecType = StaticString; +using BuildIdType = StaticString; using PayloadType = StaticString; using SSIDType = StaticString<32>; diff --git a/Firmware/HomeButtonsArduino/src/webhook.cpp b/Firmware/HomeButtonsArduino/src/webhook.cpp index 6909ede..67bf64a 100644 --- a/Firmware/HomeButtonsArduino/src/webhook.cpp +++ b/Firmware/HomeButtonsArduino/src/webhook.cpp @@ -54,6 +54,7 @@ size_t Webhook::_build_body(char* out, size_t out_size, const Event& event, doc["battery_pct"] = device_state_.sensors().battery_pct; doc["battery_v"] = device_state_.sensors().battery_voltage; doc["sw_version"] = SW_VERSION; + doc["build"] = BUILD_ID; return serializeJson(doc, out, out_size); } diff --git a/Firmware/HomeButtonsArduino/tools/make_icons.py b/Firmware/HomeButtonsArduino/tools/make_icons.py index b28a231..bd767aa 100644 --- a/Firmware/HomeButtonsArduino/tools/make_icons.py +++ b/Firmware/HomeButtonsArduino/tools/make_icons.py @@ -43,6 +43,7 @@ """ import os import shutil +import subprocess import struct import subprocess import sys @@ -226,6 +227,25 @@ def read_icon_list(): return names +def build_id(): + """Same value pre_script.py compiles into the firmware, so a device can + report whether its filesystem and its code came from one commit.""" + root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + try: + sha = subprocess.check_output( + ["git", "rev-parse", "--short=8", "HEAD"], + stderr=subprocess.DEVNULL, cwd=root).decode().strip() + except Exception: + return "nogit" + try: + dirty = subprocess.call( + ["git", "diff", "--quiet", "--ignore-submodules", "HEAD"], + stderr=subprocess.DEVNULL, cwd=root) != 0 + except Exception: + dirty = False + return sha + ("+dirty" if dirty else "") + + def main(): extra = read_icon_list() extra += [n.strip() for n in sys.argv[1:] if n.strip()] @@ -251,6 +271,12 @@ def main(): n = render_icon(name, size, out_dir, exe) print("rendered %s/%s.bmp (%d bytes)" % (out_dir, name, n)) + stamp = os.path.abspath(os.path.join(OUT_ROOT, "..", "build.txt")) + os.makedirs(os.path.dirname(stamp), exist_ok=True) + with open(stamp, "w") as fh: + fh.write(build_id() + "\n") + print("stamped %s with %s" % (stamp, build_id())) + if __name__ == "__main__": main() From 89e494983e2a73fbd7ebba33bead178310735833 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 20:56:52 +0200 Subject: [PATCH 19/41] CI: stamp the branch head, not the PR merge commit The first run named its artifacts homebuttons-original-cb26e6d2 while the branch head was aad92eca. On a pull_request event actions/checkout builds a synthetic merge commit, so git rev-parse HEAD returned a sha that exists nowhere in the branch - and that sha was being compiled into the firmware and written into the SPIFFS image, making the whole point of the stamp unlookupable. Check out the pull request head instead. CI now tests the branch as-is rather than merged with the base, which is the right trade for something whose job is to say which commit produced this binary. Co-Authored-By: Claude --- .github/workflows/firmware.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 4a27deb..ef0040a 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -31,6 +31,12 @@ jobs: # firmware and the SPIFFS image, so the checkout needs real git # metadata rather than a detached blob. fetch-depth: 0 + # On a pull_request event the default checkout is a merge commit + # that exists nowhere in the branch, so a device would report a + # sha nobody can look up. Build the branch head instead. The + # trade-off is that CI tests the branch as-is rather than merged + # with the base, which is what a build stamp should describe. + ref: ${{ github.event.pull_request.head.sha || github.sha }} # Artifact names carry the sha so successive downloads land as # homebuttons-original-.zip instead of "firmware-original (3)". From 76a7c04ed7edfd9c22744f0d4efa516ffd8dd85f Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 21:50:43 +0200 Subject: [PATCH 20/41] Serialise NVS access - fixes Wi-Fi failing intermittently DeviceState has one Preferences handle, and it is written from two tasks: the network task saves in WifiConnectedState::entry() and NormalConnectState::loop(), while the main task saves after a press and before sleep. Preferences keeps its NVS handle in the object, so interleaved begin()/put()/end() from two tasks corrupts it. The collision is not occasional. WifiConnectedState::entry() is also where state_ becomes W_CONNECTED, and _flush_pending() on the main task saves the moment it observes that - so both tasks enter the handle on essentially every connection. wifi_quick_connect and the saved BSSID live in that namespace, which is why this presents as Wi-Fi that works sometimes: a corrupted quick-connect record sends the device down the 5s quick path to nothing, and a corrupted persisted block can be worse. NVS writes also suspend flash-resident code while they run, which is not something to be doing on the network task at the instant the link comes up. The two-task split is upstream's, but _flush_pending() turned a rare overlap into one per connection, and the mutex added earlier covered main-vs-UI only. Guarded inside DeviceState rather than at the call sites: there are twelve of them across two tasks, and the next one added would have to remember. save_all()/load_all() hold the lock across both halves so no reader observes a half-written pair. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/src/state.cpp | 14 ++++++++++- Firmware/HomeButtonsArduino/src/state.h | 29 ++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/state.cpp b/Firmware/HomeButtonsArduino/src/state.cpp index 2e95b44..2907163 100644 --- a/Firmware/HomeButtonsArduino/src/state.cpp +++ b/Firmware/HomeButtonsArduino/src/state.cpp @@ -3,6 +3,7 @@ #include "config.h" void DeviceState::save_user() { + NvsLock lock(nvs_mutex_); preferences_.begin("user", false); preferences_.putString("device_name", user_preferences_.device_name.c_str()); for (int i = 0; i < NUM_BUTTONS; i++) { @@ -33,6 +34,7 @@ void DeviceState::save_user() { } void DeviceState::load_user() { + NvsLock lock(nvs_mutex_); preferences_.begin("user", true); _load_to_static_string( user_preferences_.device_name, "device_name", @@ -69,6 +71,7 @@ void DeviceState::load_user() { } void DeviceState::clear_user() { + NvsLock lock(nvs_mutex_); preferences_.begin("user", false); preferences_.clear(); preferences_.end(); @@ -84,6 +87,7 @@ void DeviceState::clear_static_ip_config() { } void DeviceState::save_persisted() { + NvsLock lock(nvs_mutex_); preferences_.begin("persisted", false); preferences_.putBool("lb_mode", persisted_.low_batt_mode); preferences_.putBool("wifi_done", persisted_.wifi_done); @@ -111,6 +115,7 @@ void DeviceState::save_persisted() { } void DeviceState::load_persisted() { + NvsLock lock(nvs_mutex_); // Read-only: upstream opened this namespace read-write on every boot. preferences_.begin("persisted", true); persisted_.low_batt_mode = preferences_.getBool("lb_mode", false); @@ -141,6 +146,7 @@ void DeviceState::load_persisted() { } void DeviceState::clear_persisted() { + NvsLock lock(nvs_mutex_); preferences_.begin("persisted", false); preferences_.clear(); preferences_.end(); @@ -174,12 +180,14 @@ void DeviceState::_load_factory(HardwareDefinition& hw) { } void DeviceState::save_all() { + NvsLock lock(nvs_mutex_); debug("state save all"); save_user(); save_persisted(); } void DeviceState::load_all(HardwareDefinition& hw) { + NvsLock lock(nvs_mutex_); debug("state load all"); _load_factory(hw); load_user(); @@ -189,12 +197,16 @@ void DeviceState::load_all(HardwareDefinition& hw) { } void DeviceState::clear_all() { + NvsLock lock(nvs_mutex_); debug("state clear all"); clear_user(); clear_persisted(); } -size_t DeviceState::get_free_entries() { return preferences_.freeEntries(); } +size_t DeviceState::get_free_entries() { + NvsLock lock(nvs_mutex_); + return preferences_.freeEntries(); +} const ButtonLabel& DeviceState::get_btn_label(uint8_t i) const { static ButtonLabel noLabel; diff --git a/Firmware/HomeButtonsArduino/src/state.h b/Firmware/HomeButtonsArduino/src/state.h index e895bc9..e568788 100644 --- a/Firmware/HomeButtonsArduino/src/state.h +++ b/Firmware/HomeButtonsArduino/src/state.h @@ -2,6 +2,8 @@ #define HOMEBUTTONS_STATE_H #include +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include "config.h" #include "types.h" @@ -99,7 +101,31 @@ class DeviceState : public Logger { } sensors_; public: - DeviceState() : Logger("State") {} + DeviceState() : Logger("State") { + // One Preferences handle serves every caller, and it is written from + // both the network task (on connect) and the main task (after a press + // or before sleep). Interleaved begin()/put()/end() on a single handle + // corrupts it, and the Wi-Fi quick-connect settings live in that + // namespace - so the failure shows up as Wi-Fi that works only + // sometimes. Guard every access at the source rather than expecting + // each call site to remember. + nvs_mutex_ = xSemaphoreCreateRecursiveMutex(); + } + + // Scoped hold of the NVS handle. + class NvsLock { + public: + explicit NvsLock(SemaphoreHandle_t m) : m_(m) { + if (m_ != nullptr) xSemaphoreTakeRecursive(m_, portMAX_DELAY); + } + ~NvsLock() { + if (m_ != nullptr) xSemaphoreGiveRecursive(m_); + } + NvsLock(const NvsLock&) = delete; + + private: + SemaphoreHandle_t m_; + }; DeviceState(const DeviceState&) = delete; // Factory @@ -278,6 +304,7 @@ class DeviceState : public Logger { const char* defaultValue); Preferences preferences_; + SemaphoreHandle_t nvs_mutex_ = nullptr; StaticString<15> ip_address_; APPasswordType ap_password_; }; From bd4f8e493bf604bb7ebdd03419d4e8c9a929e252 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 22:27:26 +0200 Subject: [PATCH 21/41] Add a Wi-Fi country setting so channels 12-13 are usable A router that auto-selected channel 13 stopped appearing in the setup portal's scan list at all, which reads as the network having disappeared rather than as a regulatory limit. Nothing in this build set a Wi-Fi country, so the ESP-IDF default applied: "CN" with 802.11d ENABLED. That policy takes the permitted channel range from the country the connected AP advertises, and reverts to the configured one on disconnect - so a device that cannot yet see the AP never gets to learn that the upper channels are allowed. Chicken and egg, and it silently costs you channels 12 and 13. New portal field "Wi-Fi Country" holding an ISO code, applied through esp_wifi_set_country_code(cc, false). Passing false disables 802.11d, so the configured range is used always rather than being inferred and reverted. Applied in all three places that bring the radio up: both connect paths in network.cpp, and - the one that actually matters here - before the setup portal scans. Blank keeps the current behaviour. UA is not among the codes ESP-IDF accepts; PL or another neighbouring EU code gives the same 1-13 range. Documented in the guide along with the symptom, since "my network is not in the list" is not an obvious search term for a regulatory domain setting. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/src/config.h | 18 ++++++++++++++++ Firmware/HomeButtonsArduino/src/network.cpp | 24 +++++++++++++++++++++ Firmware/HomeButtonsArduino/src/network.h | 11 ++++++++++ Firmware/HomeButtonsArduino/src/setup.cpp | 20 +++++++++++++++++ Firmware/HomeButtonsArduino/src/state.cpp | 3 +++ Firmware/HomeButtonsArduino/src/state.h | 9 ++++++++ Firmware/HomeButtonsArduino/src/types.h | 1 + docs/counter-guide.md | 2 ++ 8 files changed, 88 insertions(+) diff --git a/Firmware/HomeButtonsArduino/src/config.h b/Firmware/HomeButtonsArduino/src/config.h index 4c0885b..937fd71 100644 --- a/Firmware/HomeButtonsArduino/src/config.h +++ b/Firmware/HomeButtonsArduino/src/config.h @@ -71,6 +71,24 @@ static constexpr int32_t COUNTER_MIN = 0; static constexpr int32_t COUNTER_MAX = 999999; static constexpr char COUNTER_NAMES[NUM_COUNTERS][2] = {"a", "b"}; +// ------ wifi ------ +// ISO country code controlling which channels may be scanned and used. +// Empty leaves the ESP-IDF default, which is "CN" with 802.11d ENABLED - +// meaning the device defers to whatever country the AP advertises and +// reverts on disconnect. That is why a router that auto-hops to channel 12 +// or 13 can vanish from the scan list entirely: the device never gets to +// hear the beacon that would have told it those channels are allowed. +// +// Setting this applies the country with 802.11d disabled, so the range is +// fixed and channels 12-13 stay visible on European codes. +// +// Supported: 01 (world) AT AU BE BG BR CA CH CN CY CZ DE DK EE ES FI FR GB +// GR HK HR HU IE IN IS IT JP KR LI LT LU LV MT MX NL NO NZ PL PT RO SE SI +// SK TW US. Note UA is not supported by ESP-IDF - use PL or another +// neighbouring EU code, which give the same 1-13 range. +static constexpr size_t WIFI_COUNTRY_MAXLEN = 2; +static constexpr char WIFI_COUNTRY_DFLT[] = ""; + // ------ counter reset ------ // One free-text portal field covers every mode: // off | daily 03:00 | weekly mon 03:00 | monthly 1 03:00 diff --git a/Firmware/HomeButtonsArduino/src/network.cpp b/Firmware/HomeButtonsArduino/src/network.cpp index 43cafb3..32cec7f 100644 --- a/Firmware/HomeButtonsArduino/src/network.cpp +++ b/Firmware/HomeButtonsArduino/src/network.cpp @@ -1,5 +1,6 @@ #include "network.h" #include +#include #include "config.h" #include "state.h" #include "utils.h" @@ -29,6 +30,7 @@ void NetworkSMStates::QuickConnectState::entry() { sm()._pre_wifi_connect(); sm().info("connecting Wi-Fi (quick mode)..."); WiFi.mode(WIFI_STA); + apply_wifi_country(sm().device_state_.wifi_country().c_str(), sm()); WiFi.persistent(true); start_time_ = millis(); WiFi.begin(); @@ -54,6 +56,7 @@ void NetworkSMStates::QuickConnectState::loop() { void NetworkSMStates::NormalConnectState::entry() { sm()._pre_wifi_connect(); WiFi.mode(WIFI_STA); + apply_wifi_country(sm().device_state_.wifi_country().c_str(), sm()); WiFi.persistent(true); // get ssid from esp32 saved config @@ -201,6 +204,27 @@ void Network::_pre_wifi_connect() { } } +void apply_wifi_country(const char *country_code, const Logger &log) { + if (country_code == nullptr || country_code[0] == '\0') { + log.debug("no Wi-Fi country set, leaving the ESP-IDF default"); + return; + } + // ieee80211d_enabled = false: use the configured country always. With it + // enabled the device adopts the AP's country and reverts on disconnect, + // which is the default and is why channels 12-13 can stay invisible. + const esp_err_t err = esp_wifi_set_country_code(country_code, false); + if (err != ESP_OK) { + log.warning("Wi-Fi country '%s' rejected (%d) - check it is one of the " + "codes ESP-IDF supports", + country_code, static_cast(err)); + return; + } + char applied[4] = {}; + if (esp_wifi_get_country_code(applied) == ESP_OK) { + log.info("Wi-Fi country set to %s", applied); + } +} + StaticIPConfig validate_static_ip_config(StaticIPConfig config) { bool ip_ok = config.static_ip != IPAddress(0, 0, 0, 0); bool gw_ok = config.gateway != IPAddress(0, 0, 0, 0); diff --git a/Firmware/HomeButtonsArduino/src/network.h b/Firmware/HomeButtonsArduino/src/network.h index aacd545..b25ad4f 100644 --- a/Firmware/HomeButtonsArduino/src/network.h +++ b/Firmware/HomeButtonsArduino/src/network.h @@ -140,4 +140,15 @@ class Network : public NetworkStateMachine, public Logger { StaticIPConfig validate_static_ip_config(StaticIPConfig config); +// Pins the Wi-Fi regulatory domain, with 802.11d disabled so the configured +// range is used always rather than being taken from the AP and reverted on +// disconnect. Must be called after WiFi.mode() - the driver has to be +// initialised - and before any scan or connect. +// +// Both the setup portal's scan and the normal connect path need this: a +// router on channel 12 or 13 is invisible to a scan that does not know +// those channels are permitted, which looks exactly like the network having +// disappeared. +void apply_wifi_country(const char *country_code, const Logger &log); + #endif // HOMEBUTTONS_NETWORK_H diff --git a/Firmware/HomeButtonsArduino/src/setup.cpp b/Firmware/HomeButtonsArduino/src/setup.cpp index 51b0a25..193fea4 100644 --- a/Firmware/HomeButtonsArduino/src/setup.cpp +++ b/Firmware/HomeButtonsArduino/src/setup.cpp @@ -32,6 +32,13 @@ static WiFiManagerParameter awake_mode_param( static WiFiManagerParameter reset_spec_param( "reset_spec", "Counter Reset (e.g. daily 03:00, weekly mon 03:00, off)", "", RESET_SPEC_MAXLEN); +// Blank leaves the ESP-IDF default, which defers to the AP's advertised +// country and reverts on disconnect - the reason a router on channel 12 or +// 13 can be missing from the scan list entirely. UA is not among the codes +// ESP-IDF accepts; PL gives the same 1-13 range. +static WiFiManagerParameter wifi_country_param( + "wifi_cc", "Wi-Fi Country (e.g. PL, DE, GB, US; blank = default)", "", + WIFI_COUNTRY_MAXLEN); static WiFiManagerParameter static_ip_param("static_ip", "Static IP", "", 15); static WiFiManagerParameter gateway_param("gateway", "Gateway", "", 15); static WiFiManagerParameter subnet_param("subnet", "Subnet Mask", "", 15); @@ -89,6 +96,10 @@ void HBSetup::start_wifi_setup() { #endif WiFi.mode(WIFI_STA); + // Before the portal scans: without this the scan uses the ESP-IDF default + // regulatory domain and a network on channel 12 or 13 simply does not + // appear, which reads as the network being gone rather than unscannable. + apply_wifi_country(app_.device_state_.wifi_country().c_str(), app_); wifi_manager.setTitle(app_.device_state_.get_model_name_w_rand_id().c_str()); wifi_manager.setBreakAfterConfig(true); wifi_manager.setDarkMode(true); @@ -133,6 +144,7 @@ void HBSetup::start_wifi_setup() { bool wifi_connected = false; WiFi.mode(WIFI_STA); + apply_wifi_country(app_.device_state_.wifi_country().c_str(), app_); uint32_t wifi_start_time = millis(); WiFi.begin(); while (true) { @@ -185,6 +197,11 @@ void HBSetup::save_params_callback() { app_.device_state_.set_endpoint_url( EndpointUrlType{endpoint_url_param.getValue()}); app_.device_state_.set_auth_token(AuthTokenType{auth_token_param.getValue()}); + { + CountryCodeType cc{wifi_country_param.getValue()}; + cc.to_upper_case(); + app_.device_state_.set_wifi_country(cc); + } { // Normalised through the parser so whatever lands in NVS is canonical // and a typo cannot silently disable the reset. @@ -246,6 +263,8 @@ void HBSetup::start_setup() { app_.device_state_.persisted().user_awake_mode ? "1" : "0", 1); reset_spec_param.setValue(app_.device_state_.reset_spec().c_str(), RESET_SPEC_MAXLEN); + wifi_country_param.setValue(app_.device_state_.wifi_country().c_str(), + WIFI_COUNTRY_MAXLEN); static_ip_param.setValue(app_.device_state_.user_preferences() .network.static_ip.toString() .c_str(), @@ -272,6 +291,7 @@ void HBSetup::start_setup() { wifi_manager.addParameter(&auth_token_param); wifi_manager.addParameter(&awake_mode_param); wifi_manager.addParameter(&reset_spec_param); + wifi_manager.addParameter(&wifi_country_param); wifi_manager.addParameter(&static_ip_param); wifi_manager.addParameter(&gateway_param); wifi_manager.addParameter(&subnet_param); diff --git a/Firmware/HomeButtonsArduino/src/state.cpp b/Firmware/HomeButtonsArduino/src/state.cpp index 2907163..75bb442 100644 --- a/Firmware/HomeButtonsArduino/src/state.cpp +++ b/Firmware/HomeButtonsArduino/src/state.cpp @@ -30,6 +30,7 @@ void DeviceState::save_user() { preferences_.putString("endpoint", user_preferences_.endpoint_url.c_str()); preferences_.putString("auth_tok", user_preferences_.auth_token.c_str()); preferences_.putString("rst_spec", user_preferences_.reset_spec.c_str()); + preferences_.putString("wifi_cc", user_preferences_.wifi_country.c_str()); preferences_.end(); } @@ -66,6 +67,8 @@ void DeviceState::load_user() { _load_to_static_string(user_preferences_.auth_token, "auth_tok", ""); _load_to_static_string(user_preferences_.reset_spec, "rst_spec", RESET_SPEC_DFLT); + _load_to_static_string(user_preferences_.wifi_country, "wifi_cc", + WIFI_COUNTRY_DFLT); preferences_.end(); } diff --git a/Firmware/HomeButtonsArduino/src/state.h b/Firmware/HomeButtonsArduino/src/state.h index e568788..e6b2605 100644 --- a/Firmware/HomeButtonsArduino/src/state.h +++ b/Firmware/HomeButtonsArduino/src/state.h @@ -45,6 +45,8 @@ class DeviceState : public Logger { AuthTokenType auth_token; // When the counters clear themselves. See reset_schedule.h. ResetSpecType reset_spec; + // ISO country code for the Wi-Fi regulatory domain. See config.h. + CountryCodeType wifi_country; } user_preferences_; struct Persisted { @@ -184,6 +186,13 @@ class DeviceState : public Logger { user_preferences_.auth_token = token; } + const CountryCodeType& wifi_country() const { + return user_preferences_.wifi_country; + } + void set_wifi_country(const CountryCodeType& cc) { + user_preferences_.wifi_country = cc; + } + const ResetSpecType& reset_spec() const { return user_preferences_.reset_spec; } diff --git a/Firmware/HomeButtonsArduino/src/types.h b/Firmware/HomeButtonsArduino/src/types.h index d6c5e81..0ea7220 100644 --- a/Firmware/HomeButtonsArduino/src/types.h +++ b/Firmware/HomeButtonsArduino/src/types.h @@ -20,6 +20,7 @@ using EndpointUrlType = StaticString; using AuthTokenType = StaticString; using ResetSpecType = StaticString; using BuildIdType = StaticString; +using CountryCodeType = StaticString; using PayloadType = StaticString; using SSIDType = StaticString<32>; diff --git a/docs/counter-guide.md b/docs/counter-guide.md index 05df763..aad0098 100644 --- a/docs/counter-guide.md +++ b/docs/counter-guide.md @@ -77,6 +77,7 @@ The portal opens at `http://192.168.4.1` and closes after 10 minutes. | **Device Name** | Cosmetic; shown on the settings screen | | **Webhook URL** | Full HTTPS URL of the n8n **production** webhook. Max 128 chars | | **Auth Token** | Sent as `Authorization: Bearer `. Max 128 chars. Masked in the page | +| **Wi-Fi Country** | ISO code, e.g. `PL`, `DE`, `GB`, `US`. Blank uses the ESP-IDF default. **Set this if your router uses channel 12 or 13** — see below | | **Awake Mode** | `1` keeps the device from deep sleeping — needed to hold a USB serial console open. `0` for normal use. Drains the battery fast | | Static IP / Gateway / Subnet / DNS / DNS 2 | Optional — leave blank for DHCP. All three of IP, gateway and subnet must be set for static to apply | | Button 1-6 Label | See below | @@ -226,6 +227,7 @@ Serial at 115200 baud shows the whole flow (`pio device monitor`). | Two Telegram messages for one press | `seq` dedupe not implemented in the workflow | | `Check connection!` on screen | Five consecutive failed timer-wake connections | | Device never sleeps | It is on USB power, so it stays in awake mode | +| **Your network is missing from the setup scan list** | Router is on channel 12 or 13. The ESP-IDF default defers to the AP's advertised country and reverts on disconnect, so those channels are never scanned. Set **Wi-Fi Country** to a code whose range covers them (any EU code gives 1-13). `UA` is not supported by ESP-IDF — use `PL`. Also check the network is 2.4 GHz and not hidden | | Placeholder glyph instead of an icon | Icon not in the SPIFFS image — only `plus` and `minus` ship. Re-run `tools/make_icons.py` and `-t uploadfs` | ## 8. Reading logs From ab84db2435b1eb88bb69f96e5ea8cd5b3d9840f5 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 22:44:11 +0200 Subject: [PATCH 22/41] Put the Wi-Fi country field in the Wi-Fi-only portal too It was only registered in the full setup portal (settings button 1). The Wi-Fi-only portal (button 2) registers no parameters at all, so the field was invisible in exactly the flow where it is needed - you reach that portal because a network is missing from the scan list, and the setting that fixes the scan was on the other screen. That portal has no save-params callback, so the value is read back by hand after the portal closes and saved before the reconnect uses it. Note the scan inside the portal still uses the previously stored country: the value cannot be applied until it has been entered. So on the first visit type the SSID by hand, save, and the scan is correct from the next boot onwards. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/src/setup.cpp | 19 +++++++++++++++++++ docs/counter-guide.md | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Firmware/HomeButtonsArduino/src/setup.cpp b/Firmware/HomeButtonsArduino/src/setup.cpp index 193fea4..77c14e5 100644 --- a/Firmware/HomeButtonsArduino/src/setup.cpp +++ b/Firmware/HomeButtonsArduino/src/setup.cpp @@ -100,6 +100,13 @@ void HBSetup::start_wifi_setup() { // regulatory domain and a network on channel 12 or 13 simply does not // appear, which reads as the network being gone rather than unscannable. apply_wifi_country(app_.device_state_.wifi_country().c_str(), app_); + + // The country belongs in this portal, not only the full one: it is a + // Wi-Fi setting, and this is the flow you are in when you discover a + // network missing from the scan list. + wifi_country_param.setValue(app_.device_state_.wifi_country().c_str(), + WIFI_COUNTRY_MAXLEN); + wifi_manager.addParameter(&wifi_country_param); wifi_manager.setTitle(app_.device_state_.get_model_name_w_rand_id().c_str()); wifi_manager.setBreakAfterConfig(true); wifi_manager.setDarkMode(true); @@ -140,6 +147,18 @@ void HBSetup::start_wifi_setup() { ESP.restart(); } + // This portal has no save-params callback, so read the field back by hand + // before it is needed for the reconnect below. + { + CountryCodeType cc{wifi_country_param.getValue()}; + cc.to_upper_case(); + if (!(cc == app_.device_state_.wifi_country())) { + app_.device_state_.set_wifi_country(cc); + app_.device_state_.save_user(); + info("Wi-Fi country set to '%s'", cc.c_str()); + } + } + info("Wi-Fi config portal stopped, trying to connect to Wi-Fi..."); bool wifi_connected = false; diff --git a/docs/counter-guide.md b/docs/counter-guide.md index aad0098..de00f4f 100644 --- a/docs/counter-guide.md +++ b/docs/counter-guide.md @@ -45,7 +45,7 @@ fields are empty — the device boots, counts locally, and silently logs |---|---| | Hold **any two buttons, 5 s** | Settings menu | | Then press **button 1** | Setup portal (Wi-Fi + all settings) | -| Then press **button 2** | Wi-Fi only | +| Then press **button 2** | Wi-Fi only — also carries the **Wi-Fi Country** field | | Then press **button 3** | Restart | | Then press **button 4** | Cancel | | In settings, hold **button 1, 2 s** | Device Info screen | From d608eaed38ad5702d62465d92f469d7d4c42d4a9 Mon Sep 17 00:00:00 2001 From: Alex K Date: Sun, 26 Jul 2026 23:01:15 +0200 Subject: [PATCH 23/41] Restart when the Wi-Fi region changes, and log whether it persists Two things about setting the region were unclear on the device. First, changing it now restarts immediately instead of carrying on to the connect. The scan that just ran used the OLD region, so the network list you picked from was incomplete anyway, and changing the country switches the PHY init data - applying that to a live radio and connecting in the same breath is not dependable. Restarting applies it cleanly and the next visit scans correctly. The screen says so rather than just rebooting. Second, the setting was reported as not surviving a reboot and the save path reads correctly, so the device now logs it at each step: what the params page submitted, what putString wrote and how many bytes, and what came back on load. NVS putString fails silently when a namespace is full, and a setting that quietly does not persist is indistinguishable from one that was never entered. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/src/setup.cpp | 25 +++++++++++++++++++---- Firmware/HomeButtonsArduino/src/state.cpp | 11 +++++++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/setup.cpp b/Firmware/HomeButtonsArduino/src/setup.cpp index 77c14e5..c82f200 100644 --- a/Firmware/HomeButtonsArduino/src/setup.cpp +++ b/Firmware/HomeButtonsArduino/src/setup.cpp @@ -147,15 +147,31 @@ void HBSetup::start_wifi_setup() { ESP.restart(); } - // This portal has no save-params callback, so read the field back by hand - // before it is needed for the reconnect below. + // This portal has no save-params callback, so read the field back by hand. + // + // A changed country restarts immediately rather than carrying on to the + // connect below. Two reasons: the scan that just ran used the OLD region, + // so the network list you picked from was incomplete anyway, and changing + // the country switches the PHY init data - applying that to a live radio + // and connecting in the same breath is not dependable. Restarting makes + // the region take effect cleanly, and the next visit scans correctly. { CountryCodeType cc{wifi_country_param.getValue()}; cc.to_upper_case(); if (!(cc == app_.device_state_.wifi_country())) { app_.device_state_.set_wifi_country(cc); - app_.device_state_.save_user(); - info("Wi-Fi country set to '%s'", cc.c_str()); + app_.device_state_.persisted().restart_to_wifi_setup = true; + app_.device_state_.persisted().silent_restart = true; + app_.device_state_.save_all(); + info("Wi-Fi country set to '%s', restarting to apply", cc.c_str()); +#if defined(HAS_DISPLAY) + app_.display_.disp_message_large( + (UIState::MessageType("Region\nset to\n") + cc.c_str() + + "\n\nrestarting") + .c_str()); + delay(3000); +#endif + ESP.restart(); } } @@ -219,6 +235,7 @@ void HBSetup::save_params_callback() { { CountryCodeType cc{wifi_country_param.getValue()}; cc.to_upper_case(); + app_.info("params page submitted wifi_cc='%s'", cc.c_str()); app_.device_state_.set_wifi_country(cc); } { diff --git a/Firmware/HomeButtonsArduino/src/state.cpp b/Firmware/HomeButtonsArduino/src/state.cpp index 75bb442..27b3338 100644 --- a/Firmware/HomeButtonsArduino/src/state.cpp +++ b/Firmware/HomeButtonsArduino/src/state.cpp @@ -30,7 +30,15 @@ void DeviceState::save_user() { preferences_.putString("endpoint", user_preferences_.endpoint_url.c_str()); preferences_.putString("auth_tok", user_preferences_.auth_token.c_str()); preferences_.putString("rst_spec", user_preferences_.reset_spec.c_str()); - preferences_.putString("wifi_cc", user_preferences_.wifi_country.c_str()); + { + const size_t n = preferences_.putString( + "wifi_cc", user_preferences_.wifi_country.c_str()); + // Written and read back on every save. NVS putString fails silently + // when the namespace is full, and a setting that quietly does not + // persist is hard to tell from one that was never entered. + info("save wifi_cc='%s' (%u bytes)", user_preferences_.wifi_country.c_str(), + static_cast(n)); + } preferences_.end(); } @@ -69,6 +77,7 @@ void DeviceState::load_user() { RESET_SPEC_DFLT); _load_to_static_string(user_preferences_.wifi_country, "wifi_cc", WIFI_COUNTRY_DFLT); + info("load wifi_cc='%s'", user_preferences_.wifi_country.c_str()); preferences_.end(); } From 5ac1f6dc9ea946e859a2dfb786178846fe242d24 Mon Sep 17 00:00:00 2001 From: Alex K Date: Mon, 27 Jul 2026 01:20:11 +0200 Subject: [PATCH 24/41] Read the Wi-Fi region when its form is submitted, not after the portal Serial log showed load wifi_cc='' after a save that the browser confirmed, so the value was never reaching NVS. start_wifi_setup() read the field only after its portal loop exited, and that loop exits on a connection attempt, a button press, or the 600s timeout - never on a params save. Entering a region and saving it on its own therefore left the value sitting in the WiFiManagerParameter and read by nobody. Saving the Wi-Fi form did end the loop, which is why the AP disappeared, but WiFiManager had already tried to connect using the old region by then. The portal now has its own save-params callback, so the region is captured the moment the form is submitted. It sets a flag rather than restarting inside the request handler, so the browser still gets its "saved" page, and the loop then restarts to apply it. Also corrected the save log: putString returns strlen(), so the "(0 bytes)" on an empty value looked like a failed write when it was simply an empty string. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/src/setup.cpp | 58 +++++++++++++---------- Firmware/HomeButtonsArduino/src/setup.h | 6 +++ Firmware/HomeButtonsArduino/src/state.cpp | 4 +- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/setup.cpp b/Firmware/HomeButtonsArduino/src/setup.cpp index c82f200..b847793 100644 --- a/Firmware/HomeButtonsArduino/src/setup.cpp +++ b/Firmware/HomeButtonsArduino/src/setup.cpp @@ -107,6 +107,9 @@ void HBSetup::start_wifi_setup() { wifi_country_param.setValue(app_.device_state_.wifi_country().c_str(), WIFI_COUNTRY_MAXLEN); wifi_manager.addParameter(&wifi_country_param); + wifi_manager.setSaveParamsCallback( + std::bind(&HBSetup::save_wifi_params_callback, this)); + wifi_region_changed_ = false; wifi_manager.setTitle(app_.device_state_.get_model_name_w_rand_id().c_str()); wifi_manager.setBreakAfterConfig(true); wifi_manager.setDarkMode(true); @@ -127,6 +130,10 @@ void HBSetup::start_wifi_setup() { debug("Wi-Fi config portal stopped, connected"); break; } + if (wifi_region_changed_) { + debug("Wi-Fi config portal stopped, region changed"); + break; + } if (app_.hw_.any_button_pressed()) { user_stopped = true; debug("Wi-Fi config portal stopped by user"); @@ -147,32 +154,20 @@ void HBSetup::start_wifi_setup() { ESP.restart(); } - // This portal has no save-params callback, so read the field back by hand. - // - // A changed country restarts immediately rather than carrying on to the - // connect below. Two reasons: the scan that just ran used the OLD region, - // so the network list you picked from was incomplete anyway, and changing - // the country switches the PHY init data - applying that to a live radio - // and connecting in the same breath is not dependable. Restarting makes - // the region take effect cleanly, and the next visit scans correctly. - { - CountryCodeType cc{wifi_country_param.getValue()}; - cc.to_upper_case(); - if (!(cc == app_.device_state_.wifi_country())) { - app_.device_state_.set_wifi_country(cc); - app_.device_state_.persisted().restart_to_wifi_setup = true; - app_.device_state_.persisted().silent_restart = true; - app_.device_state_.save_all(); - info("Wi-Fi country set to '%s', restarting to apply", cc.c_str()); + // Applied by restarting rather than continuing: the scan that just ran + // used the OLD region, so the network list was incomplete anyway, and + // changing the country switches the PHY init data - applying that to a + // live radio and connecting in the same breath is not dependable. + if (wifi_region_changed_) { + const CountryCodeType cc = app_.device_state_.wifi_country(); + info("Wi-Fi region set to '%s', restarting to apply", cc.c_str()); #if defined(HAS_DISPLAY) - app_.display_.disp_message_large( - (UIState::MessageType("Region\nset to\n") + cc.c_str() + - "\n\nrestarting") - .c_str()); - delay(3000); + app_.display_.disp_message_large( + (UIState::MessageType("Region\n") + cc.c_str() + "\n\nrestarting") + .c_str()); + delay(3000); #endif - ESP.restart(); - } + ESP.restart(); } info("Wi-Fi config portal stopped, trying to connect to Wi-Fi..."); @@ -227,6 +222,21 @@ void HBSetup::start_wifi_setup() { } } +void HBSetup::save_wifi_params_callback() { + CountryCodeType cc{wifi_country_param.getValue()}; + cc.to_upper_case(); + info("wifi portal submitted wifi_cc='%s'", cc.c_str()); + if (cc == app_.device_state_.wifi_country()) return; + + app_.device_state_.set_wifi_country(cc); + app_.device_state_.persisted().restart_to_wifi_setup = true; + app_.device_state_.persisted().silent_restart = true; + app_.device_state_.save_all(); + // Handled by the portal loop rather than restarting inside the request + // handler, so the browser still gets its "saved" page. + wifi_region_changed_ = true; +} + void HBSetup::save_params_callback() { app_.device_state_.set_device_name(DeviceName{device_name_param.getValue()}); app_.device_state_.set_endpoint_url( diff --git a/Firmware/HomeButtonsArduino/src/setup.h b/Firmware/HomeButtonsArduino/src/setup.h index ebf8429..95a2bd1 100644 --- a/Firmware/HomeButtonsArduino/src/setup.h +++ b/Firmware/HomeButtonsArduino/src/setup.h @@ -17,6 +17,12 @@ class HBSetup : public Logger { bool web_portal_saved_ = false; void save_params_callback(); + // The Wi-Fi-only portal's own callback. Fires the moment its params form + // is submitted, rather than waiting for the portal loop to end - that + // loop only exits on a connection attempt, a button, or the 600s + // timeout, so a region entered and saved on its own was never read. + void save_wifi_params_callback(); + bool wifi_region_changed_ = false; }; #endif // HOMEBUTTONS_SETUP_H diff --git a/Firmware/HomeButtonsArduino/src/state.cpp b/Firmware/HomeButtonsArduino/src/state.cpp index 27b3338..c003cba 100644 --- a/Firmware/HomeButtonsArduino/src/state.cpp +++ b/Firmware/HomeButtonsArduino/src/state.cpp @@ -36,7 +36,9 @@ void DeviceState::save_user() { // Written and read back on every save. NVS putString fails silently // when the namespace is full, and a setting that quietly does not // persist is hard to tell from one that was never entered. - info("save wifi_cc='%s' (%u bytes)", user_preferences_.wifi_country.c_str(), + // putString returns strlen(), so 0 is normal for an empty value and + // says nothing about whether the write succeeded. + info("save wifi_cc='%s' (wrote %u)", user_preferences_.wifi_country.c_str(), static_cast(n)); } preferences_.end(); From 989e50336475d1c2a3e64da5b6092aca176041cd Mon Sep 17 00:00:00 2001 From: Alex K Date: Mon, 27 Jul 2026 01:37:35 +0200 Subject: [PATCH 25/41] Log the Wi-Fi disconnect reason code A failed association is currently just a 20s timeout, which looks identical whether the AP was never seen, refused us, or rejected the key. The driver knows which; nothing was asking it. Registers handlers for STA_DISCONNECTED and STA_CONNECTED. The disconnect handler logs the numeric reason with a decoded name, so the distinction that matters is in the release build's log rather than needing CORE_DEBUG_LEVEL=5. NO_AP_FOUND, AUTH_FAIL and 4WAY_HANDSHAKE_TIMEOUT point at three completely different problems. The connect handler logs the channel and RSSI actually used, which also confirms whether the region change took effect. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/src/network.cpp | 53 ++++++++++++++++++++- Firmware/HomeButtonsArduino/src/network.h | 1 + 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Firmware/HomeButtonsArduino/src/network.cpp b/Firmware/HomeButtonsArduino/src/network.cpp index 32cec7f..ca3d88b 100644 --- a/Firmware/HomeButtonsArduino/src/network.cpp +++ b/Firmware/HomeButtonsArduino/src/network.cpp @@ -175,7 +175,58 @@ void Network::disconnect(bool erase) { void Network::update() { loop(); } -void Network::setup() { network_task_handle_ = xTaskGetCurrentTaskHandle(); } +// The driver's own reason code is the one fact that separates "cannot see +// the AP" from "the AP refused us" from "wrong key". Without it a failed +// association is just a 20s timeout, which looks identical in every case. +static const char *disconnect_reason_name(uint8_t reason) { + switch (reason) { + case WIFI_REASON_AUTH_EXPIRE: return "AUTH_EXPIRE"; + case WIFI_REASON_AUTH_LEAVE: return "AUTH_LEAVE"; + case WIFI_REASON_ASSOC_EXPIRE: return "ASSOC_EXPIRE"; + case WIFI_REASON_ASSOC_TOOMANY: return "ASSOC_TOOMANY (AP full)"; + case WIFI_REASON_NOT_AUTHED: return "NOT_AUTHED"; + case WIFI_REASON_NOT_ASSOCED: return "NOT_ASSOCED"; + case WIFI_REASON_ASSOC_LEAVE: return "ASSOC_LEAVE"; + case WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT: + return "4WAY_HANDSHAKE_TIMEOUT (wrong key, or PMF mismatch)"; + case WIFI_REASON_IE_IN_4WAY_DIFFERS: return "IE_IN_4WAY_DIFFERS"; + case WIFI_REASON_GROUP_KEY_UPDATE_TIMEOUT: + return "GROUP_KEY_UPDATE_TIMEOUT"; + case WIFI_REASON_INVALID_RSN_IE_CAP: return "INVALID_RSN_IE_CAP"; + case WIFI_REASON_802_1X_AUTH_FAILED: return "802_1X_AUTH_FAILED"; + case WIFI_REASON_BEACON_TIMEOUT: return "BEACON_TIMEOUT (out of range)"; + case WIFI_REASON_NO_AP_FOUND: + return "NO_AP_FOUND (not seen in scan - channel, band or hidden)"; + case WIFI_REASON_AUTH_FAIL: return "AUTH_FAIL (wrong password)"; + case WIFI_REASON_ASSOC_FAIL: return "ASSOC_FAIL (AP refused)"; + case WIFI_REASON_HANDSHAKE_TIMEOUT: return "HANDSHAKE_TIMEOUT"; + case WIFI_REASON_CONNECTION_FAIL: return "CONNECTION_FAIL"; + default: return "see esp_wifi_types.h"; + } +} + +void Network::setup() { + network_task_handle_ = xTaskGetCurrentTaskHandle(); + + WiFi.onEvent( + [this](arduino_event_id_t, arduino_event_info_t info) { + const uint8_t reason = info.wifi_sta_disconnected.reason; + warning("Wi-Fi disconnected: reason %u (%s)", reason, + disconnect_reason_name(reason)); + }, + ARDUINO_EVENT_WIFI_STA_DISCONNECTED); + + WiFi.onEvent( + [this](arduino_event_id_t, arduino_event_info_t info) { + info_log_connected(info); + }, + ARDUINO_EVENT_WIFI_STA_CONNECTED); +} + +void Network::info_log_connected(const arduino_event_info_t &ev) { + info("associated: ch %u, RSSI %d", ev.wifi_sta_connected.channel, + WiFi.RSSI()); +} Network::State Network::get_state() { return state_; } diff --git a/Firmware/HomeButtonsArduino/src/network.h b/Firmware/HomeButtonsArduino/src/network.h index b25ad4f..1087d3e 100644 --- a/Firmware/HomeButtonsArduino/src/network.h +++ b/Firmware/HomeButtonsArduino/src/network.h @@ -129,6 +129,7 @@ class Network : public NetworkStateMachine, public Logger { std::function on_connect_callback_; void _pre_wifi_connect(); + void info_log_connected(const arduino_event_info_t &ev); friend class NetworkSMStates::IdleState; friend class NetworkSMStates::QuickConnectState; From 99f387e675ce7c6345606b568d17e7bab611c7c0 Mon Sep 17 00:00:00 2001 From: Alex K Date: Mon, 27 Jul 2026 02:08:02 +0200 Subject: [PATCH 26/41] Stop restarting when the Wi-Fi region changes Saving a region took down the setup hotspot mid-session: the callback saved, blocked three seconds on a display update, then rebooted, so the phone sitting in the portal lost the AP and had to find it again. The restart was my guess at what the PHY-init-data note in the esp_wifi_set_country_code docs implied. It is not needed. The region is picked up where it actually matters - apply_wifi_country() already runs before the connect attempt at the end of the portal, and on every connect after that. Applying it live inside the callback is not an improvement either: it rewrites the SoftAP's country IE and can move its channel, dropping the same client for the same reason. So the callback now just saves. One portal session: set the region, pick or type the network, save, and the connect uses the new region. No reboot, no AP disruption. Also logs when the region is unchanged - the previous behaviour returned silently, so submitting the form with an already-correct value looked identical to it being ignored. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/src/setup.cpp | 45 ++++++++--------------- Firmware/HomeButtonsArduino/src/setup.h | 1 - 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/setup.cpp b/Firmware/HomeButtonsArduino/src/setup.cpp index b847793..4722fa4 100644 --- a/Firmware/HomeButtonsArduino/src/setup.cpp +++ b/Firmware/HomeButtonsArduino/src/setup.cpp @@ -109,7 +109,6 @@ void HBSetup::start_wifi_setup() { wifi_manager.addParameter(&wifi_country_param); wifi_manager.setSaveParamsCallback( std::bind(&HBSetup::save_wifi_params_callback, this)); - wifi_region_changed_ = false; wifi_manager.setTitle(app_.device_state_.get_model_name_w_rand_id().c_str()); wifi_manager.setBreakAfterConfig(true); wifi_manager.setDarkMode(true); @@ -130,10 +129,6 @@ void HBSetup::start_wifi_setup() { debug("Wi-Fi config portal stopped, connected"); break; } - if (wifi_region_changed_) { - debug("Wi-Fi config portal stopped, region changed"); - break; - } if (app_.hw_.any_button_pressed()) { user_stopped = true; debug("Wi-Fi config portal stopped by user"); @@ -154,22 +149,6 @@ void HBSetup::start_wifi_setup() { ESP.restart(); } - // Applied by restarting rather than continuing: the scan that just ran - // used the OLD region, so the network list was incomplete anyway, and - // changing the country switches the PHY init data - applying that to a - // live radio and connecting in the same breath is not dependable. - if (wifi_region_changed_) { - const CountryCodeType cc = app_.device_state_.wifi_country(); - info("Wi-Fi region set to '%s', restarting to apply", cc.c_str()); -#if defined(HAS_DISPLAY) - app_.display_.disp_message_large( - (UIState::MessageType("Region\n") + cc.c_str() + "\n\nrestarting") - .c_str()); - delay(3000); -#endif - ESP.restart(); - } - info("Wi-Fi config portal stopped, trying to connect to Wi-Fi..."); bool wifi_connected = false; @@ -225,16 +204,24 @@ void HBSetup::start_wifi_setup() { void HBSetup::save_wifi_params_callback() { CountryCodeType cc{wifi_country_param.getValue()}; cc.to_upper_case(); - info("wifi portal submitted wifi_cc='%s'", cc.c_str()); - if (cc == app_.device_state_.wifi_country()) return; + if (cc == app_.device_state_.wifi_country()) { + info("wifi portal: region unchanged ('%s')", cc.c_str()); + return; + } app_.device_state_.set_wifi_country(cc); - app_.device_state_.persisted().restart_to_wifi_setup = true; - app_.device_state_.persisted().silent_restart = true; - app_.device_state_.save_all(); - // Handled by the portal loop rather than restarting inside the request - // handler, so the browser still gets its "saved" page. - wifi_region_changed_ = true; + app_.device_state_.save_user(); + // Deliberately NOT applied here and NOT restarting. + // + // esp_wifi_set_country_code() rewrites the SoftAP's country IE and can + // move its channel, which drops the phone that is currently sitting in + // this portal. Restarting to apply it does the same thing more bluntly - + // the AP disappears mid-session and the portal has to be found again. + // + // It is picked up where it is actually needed: apply_wifi_country() + // already runs before the connect attempt below, and on every connect + // thereafter. Nothing else in this portal needs it. + info("wifi portal: region set to '%s', applies on connect", cc.c_str()); } void HBSetup::save_params_callback() { diff --git a/Firmware/HomeButtonsArduino/src/setup.h b/Firmware/HomeButtonsArduino/src/setup.h index 95a2bd1..3acf13c 100644 --- a/Firmware/HomeButtonsArduino/src/setup.h +++ b/Firmware/HomeButtonsArduino/src/setup.h @@ -22,7 +22,6 @@ class HBSetup : public Logger { // loop only exits on a connection attempt, a button, or the 600s // timeout, so a region entered and saved on its own was never read. void save_wifi_params_callback(); - bool wifi_region_changed_ = false; }; #endif // HOMEBUTTONS_SETUP_H From 700aca33e69b598a026cf8890f4c59f43a83d016 Mon Sep 17 00:00:00 2001 From: Alex K Date: Mon, 27 Jul 2026 02:12:28 +0200 Subject: [PATCH 27/41] Present a USB CDC device while running, so USB-C can reset the board Flashing currently needs the BOOT+RST dance because the running firmware puts nothing on USB, so esptool has no line to pull. Arduino's USBCDC already implements the DTR/RTS state machine esptool drives - including usb_persist_restart(RESTART_BOOTLOADER) and the 1200-baud touch - it just has to be enabled. esptool --chip esp32s2 --after hard-reset chip-id pio run -t upload This is NOT the setting that bricked the board earlier. That was CONFIG_ESP_CONSOLE_USB_CDC, the ESP-IDF ROM CDC driver, whose Kconfig reads "depends on !TINY_USB" - it and TinyUSB were fighting over one peripheral. This is Arduino's own TinyUSB CDC, the single owner and the documented path, enabled by a build flag the core explicitly supports: main.cpp calls Serial.begin()/USB.begin() for it, and ARDUINO_USB_MODE is undefined on S2 so the guard passes. The ESP-IDF console stays on UART0 - verified in the generated sdkconfig - so the debug header keeps working unchanged and USB is purely for reset and upload. Costs 0.6% of flash. Deep sleep still tears the USB device down, so this only helps while the device is awake. Untested on hardware. Co-Authored-By: Claude --- Firmware/HomeButtonsArduino/platformio.ini | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Firmware/HomeButtonsArduino/platformio.ini b/Firmware/HomeButtonsArduino/platformio.ini index 8d2d99f..5e7c45f 100644 --- a/Firmware/HomeButtonsArduino/platformio.ini +++ b/Firmware/HomeButtonsArduino/platformio.ini @@ -35,12 +35,35 @@ lib_deps = olikraus/U8g2_for_Adafruit_GFX@1.8.0 https://github.com/Neargye/semver.git#v0.3.0 + +; Presents a USB CDC device while the application runs, so the USB-C port +; can reset and flash the board without the BOOT+RST dance: +; +; esptool --chip esp32s2 --after hard-reset chip-id +; pio run -t upload +; +; Arduino's USBCDC::_onLineState() implements the DTR/RTS state machine +; esptool drives, including usb_persist_restart(RESTART_BOOTLOADER) and the +; 1200-baud touch. +; +; NOT the setting that bricked this board earlier. That was +; CONFIG_ESP_CONSOLE_USB_CDC, the ESP-IDF ROM CDC driver, whose Kconfig +; says "depends on !TINY_USB" - two drivers fighting over one peripheral. +; This is Arduino's own TinyUSB CDC, which is the single owner and the +; supported path. +; +; The ESP-IDF console stays on UART0, so the debug header keeps working +; exactly as now; USB is purely for reset and upload. +; +; Caveat: deep sleep tears the USB device down, so this only helps while +; the device is awake. [env:original_release] extends = esp32_base build_flags = -std=gnu++17 -Wno-unknown-pragmas -DARDUINO_ESP32S2_DEV + -DARDUINO_USB_CDC_ON_BOOT=1 -DCORE_DEBUG_LEVEL=1 -DLOGGER_DEFAULT_LOG_LEVEL=ESP_LOG_INFO -DHOME_BUTTONS_ORIGINAL @@ -53,6 +76,7 @@ build_flags = -std=gnu++17 -Wno-unknown-pragmas -DARDUINO_ESP32S2_DEV + -DARDUINO_USB_CDC_ON_BOOT=1 -DCORE_DEBUG_LEVEL=5 -DLOGGER_DEFAULT_LOG_LEVEL=ESP_LOG_DEBUG -DHOME_BUTTONS_ORIGINAL From 02722e8fb17aaf14f2c0401a284492433f12065f Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 03:07:59 +0200 Subject: [PATCH 28/41] Add a serial command console for diagnostics and testing The interesting behaviour of this firmware is otherwise only reachable by standing at the device with a finger on a button, or by waiting until 03:00 tomorrow. `press` injects through the real handler and `time set` moves the clock, which together make the counter flow and the scheduled reset testable over the wire in seconds. Reading and executing are split. The main loop blocks for up to HTTP_TIMEOUT inside a POST, so a reader task buffers input meanwhile and typed characters are not lost to a UART FIFO nobody is draining. Execution then happens on the main loop, which already owns the webhook, the NVS writes and the counters - running commands anywhere else would put a second task on all three. Both ports are read: Serial is the native USB CDC and Serial0 is UART0 on the debug header. Which one is attached depends on how the device is powered, and neither is worth privileging. An injected press also nudges the state machine the way the UI callback would have. Without that, a press in sleep mode would sit in the queue until the idle timeout slept the device with it undelivered. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- Firmware/HomeButtonsArduino/src/app.cpp | 25 +- Firmware/HomeButtonsArduino/src/app.h | 11 + Firmware/HomeButtonsArduino/src/config.h | 5 + Firmware/HomeButtonsArduino/src/console.cpp | 438 ++++++++++++++++++ Firmware/HomeButtonsArduino/src/console.h | 111 +++++ .../HomeButtonsArduino/src/state_machine.h | 8 + 6 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 Firmware/HomeButtonsArduino/src/console.cpp create mode 100644 Firmware/HomeButtonsArduino/src/console.h diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index c959ae2..70f25ba 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -26,7 +26,8 @@ App::App() display_(device_state_), network_(device_state_), webhook_(device_state_), - setup_(*this) { + setup_(*this), + console_(*this) { press_queue_ = xQueueCreate(PRESS_QUEUE_SIZE, sizeof(PressQueueElement)); if (press_queue_ == nullptr) error("failed to create press queue"); state_mutex_ = xSemaphoreCreateRecursiveMutex(); @@ -478,6 +479,23 @@ void App::_handle_ui_event_global(UserInput::Event event) { device_state_.flags().last_user_input_time = millis(); } +void App::_service_console() { console_.service(); } + +void App::_console_press(uint8_t btn_id) { + _handle_counter_press(btn_id); + device_state_.flags().last_user_input_time = millis(); + // Keeps an open session open, mirroring SessionState::handle_ui_event(). + session_last_input_time_ = millis(); + + // In sleep mode the device parks in SleepModeHandleInput waiting for a + // real button, and nothing there connects the network. Boot cause is left + // alone: if the wake really was a timer, NetConnectingState will deliver + // the press and then sleep, which is the honest outcome. + if (is_current_state()) { + transition_to(); + } +} + void App::_service_display() { if (!device_state_.flags().display_redraw) return; // Coalesce a burst so a run of presses does not queue up a full e-paper @@ -521,6 +539,10 @@ void App::_main_task() { device_state_.load_all(hw_); + // Before the display and the network, so a device that fails either is + // still reachable - which is the situation the console is most use in. + console_.begin(); + _begin_hw(); // Display::begin() mounts SPIFFS, so the stamp is readable from here on. @@ -733,6 +755,7 @@ void App::_main_task() { debug("Starting main state machine loop"); while (true) { loop(); + _service_console(); _service_webhook(); _service_display(); esp_task_wdt_reset(); diff --git a/Firmware/HomeButtonsArduino/src/app.h b/Firmware/HomeButtonsArduino/src/app.h index c1df99f..94137b9 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -12,6 +12,7 @@ #include "logger.h" #include "hardware.h" #include "setup.h" +#include "console.h" #include "reset_schedule.h" #include "display/display.h" #include "button_ui/btn_sw_led.h" @@ -214,6 +215,14 @@ class App : public AppStateMachine, public Logger { // display follows the button press regardless of what the state machine // is doing - notably while the network is still connecting. void _service_display(); + // Drains the console's line queue. Same task as _service_webhook(), so a + // command may touch the webhook, NVS and the counters freely. + void _service_console(); + // A press injected from the console. Applies it exactly as a real one, + // then nudges the state machine the way the UI callback would have - + // without which an injected press in sleep mode would sit in the queue + // until the idle timeout slept the device with it undelivered. + void _console_press(uint8_t btn_id); // Counter plumbing --------------------------------------------------- // Returns true and fills idx/delta when btn_id is one of the four @@ -276,6 +285,7 @@ class App : public AppStateMachine, public Logger { Webhook webhook_; HardwareDefinition hw_; HBSetup setup_; + Console console_; // Button callbacks run on the UI task, so a press may not block on HTTP or // NVS there. handle_ui_event() only touches RAM and pushes onto this queue; @@ -322,6 +332,7 @@ class App : public AppStateMachine, public Logger { uint32_t shutdown_cmd_time_ = 0; friend class HBSetup; + friend class Console; friend class AppSMStates::InitState; friend class AppSMStates::AwakeModeIdleState; diff --git a/Firmware/HomeButtonsArduino/src/config.h b/Firmware/HomeButtonsArduino/src/config.h index 937fd71..708a445 100644 --- a/Firmware/HomeButtonsArduino/src/config.h +++ b/Firmware/HomeButtonsArduino/src/config.h @@ -44,6 +44,11 @@ static constexpr char DOCS_LINK[] = // The setup AP password is derived per-device from the eFuse random ID, so // there is no shared default. See DeviceState::get_ap_password(). +// ------ serial console ------ +// Both the log output and the command console run at this rate, on the USB +// CDC and on UART0 alike. +static constexpr uint32_t SERIAL_BAUD_RATE = 115200; + // ------ buttons ------ static constexpr uint8_t NUM_BUTTONS = 6; static constexpr uint8_t BTN_LABEL_MAXLEN = 56; diff --git a/Firmware/HomeButtonsArduino/src/console.cpp b/Firmware/HomeButtonsArduino/src/console.cpp new file mode 100644 index 0000000..a30a489 --- /dev/null +++ b/Firmware/HomeButtonsArduino/src/console.cpp @@ -0,0 +1,438 @@ +#include "console.h" + +#include +#include +#include +#include +#include +#include + +#include "app.h" +#include "config.h" +#include "reset_schedule.h" + +const Console::Command Console::kCommands[] = { + {"help", "", "this list", &Console::_cmd_help}, + {"status", "", "everything at a glance", &Console::_cmd_status}, + {"press", "<1-6>", "inject a button press", &Console::_cmd_press}, + {"counter", "[a|b [n]]", "show or set a counter", &Console::_cmd_counter}, + {"sched", "[spec]", "show or set the reset schedule", &Console::_cmd_sched}, + {"reset", "", "run the reset check now", &Console::_cmd_reset}, + {"time", "[set [off]]", "show or override the clock", + &Console::_cmd_time}, + {"sync", "", "post a time sync", &Console::_cmd_sync}, + {"post", "", "post a heartbeat", &Console::_cmd_post}, + {"endpoint", "[url]", "show or set the webhook URL", + &Console::_cmd_endpoint}, + {"token", "[tok]", "show or set the auth token", &Console::_cmd_token}, + {"wifi", "", "link detail", &Console::_cmd_wifi}, + {"save", "", "persist NVS now", &Console::_cmd_save}, + {"sleep", "", "sleep immediately", &Console::_cmd_sleep}, + {"restart", "", "reboot", &Console::_cmd_restart}, + {"setup", "", "reboot into the full setup portal", &Console::_cmd_setup}, + {"wifisetup", "", "reboot into the Wi-Fi portal", &Console::_cmd_wifisetup}, +}; +const size_t Console::kNumCommands = + sizeof(Console::kCommands) / sizeof(Console::kCommands[0]); + +void Console::begin() { + usb_.stream = &Serial; + uart_.stream = &Serial0; + Serial.begin(SERIAL_BAUD_RATE); + Serial0.begin(SERIAL_BAUD_RATE); + + line_queue_ = xQueueCreate(LINE_QUEUE_SIZE, sizeof(Line)); + if (line_queue_ == nullptr) { + error("failed to create the line queue, console unavailable"); + return; + } + + xTaskCreate(_reader_task, "console", READER_STACK, this, 1, + &reader_task_h_); + info("console ready on USB CDC and UART0 - type 'help'"); +} + +void Console::_reader_task(void* self) { + static_cast(self)->_reader(); +} + +void Console::_reader() { + while (true) { + // Bounded per pass so one port cannot monopolise the loop, and so a + // stuck stream cannot spin here without yielding. + for (Port* port : {&usb_, &uart_}) { + if (port->stream == nullptr) continue; + for (int i = 0; i < 64 && port->stream->available() > 0; ++i) { + _feed(*port, static_cast(port->stream->read())); + } + } + delay(20); + } +} + +void Console::_feed(Port& port, char c) { + if (c == '\r') return; // CRLF terminals send both; act on the LF + + if (c == '\b' || c == 0x7F) { + if (port.len > 0) { + port.len--; + port.stream->write("\b \b"); // erase on the sender's terminal only + } + return; + } + + if (c != '\n') { + if (port.len + 1 < LINE_MAXLEN) { + port.buf[port.len++] = c; + port.stream->write(c); // echo, so typing into a terminal is bearable + } + return; + } + + port.stream->write("\r\n"); + port.buf[port.len] = '\0'; + const size_t len = port.len; + port.len = 0; + if (len == 0) return; + + Line line{}; + memcpy(line.text, port.buf, len + 1); + if (line_queue_ == nullptr || + xQueueSend(line_queue_, &line, (TickType_t)0) != pdTRUE) { + // Only reachable by pasting a block of commands while a POST is in + // flight. Saying so beats silently dropping one. + _out("busy, command dropped\n"); + } +} + +void Console::service() { + if (line_queue_ == nullptr) return; + Line line; + while (xQueueReceive(line_queue_, &line, 0) == pdTRUE) { + _execute(line.text); + } +} + +void Console::_execute(char* line) { + char* argv[MAX_ARGS] = {}; + int argc = 0; + for (char* tok = strtok(line, " \t"); tok != nullptr && argc < MAX_ARGS; + tok = strtok(nullptr, " \t")) { + argv[argc++] = tok; + } + if (argc == 0) return; + + for (size_t i = 0; i < kNumCommands; ++i) { + if (strcasecmp(argv[0], kCommands[i].name) == 0) { + (this->*kCommands[i].fn)(argc, argv); + return; + } + } + _out("unknown command '%s' - try 'help'\n", argv[0]); +} + +void Console::_out(const char* fmt, ...) const { + char buf[256]; + va_list args; + va_start(args, fmt); + const int n = vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + if (n <= 0) return; + Serial.write(buf); + Serial0.write(buf); +} + +bool Console::_parse_counter_idx(const char* text, uint8_t& idx) { + for (uint8_t i = 0; i < NUM_COUNTERS; ++i) { + if (strcasecmp(text, COUNTER_NAMES[i]) == 0) { + idx = i; + return true; + } + } + _out("no counter '%s'\n", text); + return false; +} + +// --- commands ------------------------------------------------------------- + +void Console::_cmd_help(int, char**) { + _out("commands:\n"); + for (size_t i = 0; i < kNumCommands; ++i) { + _out(" %-9s %-19s %s\n", kCommands[i].name, kCommands[i].args, + kCommands[i].help); + } +} + +void Console::_cmd_status(int, char**) { + App::StateLock lock(app_.state_mutex_); + DeviceState& st = app_.device_state_; + + _out("build fw %s / spiffs %s / %s\n", BUILD_ID, + app_.spiffs_build_.empty() ? "?" : app_.spiffs_build_.c_str(), + SW_VERSION); + _out("device %s\n", st.factory().unique_id.c_str()); + _out("uptime %lu s, heap %u free / %u min\n", + static_cast(millis() / 1000), ESP.getFreeHeap(), + ESP.getMinFreeHeap()); + _out("state app %s / net %s\n", app_.current_state_name(), + app_.network_.get_state() == Network::State::W_CONNECTED + ? "connected" + : "disconnected"); + _out("boot cause %d, wake btn %u, awake mode %d\n", + static_cast(app_.boot_cause_), app_.wakeup_btn_id_, + st.flags().awake_mode); + _out("wifi %s, ip %s, rssi %d\n", + WiFi.status() == WL_CONNECTED ? WiFi.SSID().c_str() : "-", st.ip(), + static_cast(WiFi.RSSI())); + _out("battery %u%%, %.2f V, present %d, dc %d\n", st.sensors().battery_pct, + st.sensors().battery_voltage, st.sensors().battery_present, + st.sensors().dc_connected); + + for (uint8_t i = 0; i < NUM_COUNTERS; ++i) { + _out("counter %s %ld\n", COUNTER_NAMES[i], + static_cast(st.counter(i))); + } + _out("seq %u\n", st.seq()); + _out("endpoint %s\n", + st.endpoint_url().empty() ? "(unset)" : st.endpoint_url().c_str()); + _out("token %s\n", st.auth_token().empty() ? "(unset)" : "(set)"); + _out("queued %u press(es)\n", + app_.press_queue_ == nullptr + ? 0 + : uxQueueMessagesWaiting(app_.press_queue_)); + + _cmd_time(0, nullptr); + _cmd_sched(0, nullptr); +} + +void Console::_cmd_press(int argc, char** argv) { + if (argc < 2) { + _out("usage: press <1-%u>\n", NUM_BUTTONS); + return; + } + const long btn = strtol(argv[1], nullptr, 10); + if (btn < 1 || btn > NUM_BUTTONS) { + _out("button out of range (1-%u)\n", NUM_BUTTONS); + return; + } + // The same entry point a real press takes, so what this exercises is the + // production path and not a parallel one. Runs on the main task rather + // than the UI task; the counters are behind state_mutex_ and the LED + // calls match what _flush_pending() already does from here. + _out("press %ld\n", btn); + app_._console_press(static_cast(btn)); +} + +void Console::_cmd_counter(int argc, char** argv) { + App::StateLock lock(app_.state_mutex_); + DeviceState& st = app_.device_state_; + + if (argc < 2) { + for (uint8_t i = 0; i < NUM_COUNTERS; ++i) { + _out("counter %s %ld\n", COUNTER_NAMES[i], + static_cast(st.counter(i))); + } + return; + } + uint8_t idx = 0; + if (!_parse_counter_idx(argv[1], idx)) return; + + if (argc < 3) { + _out("counter %s %ld\n", COUNTER_NAMES[idx], + static_cast(st.counter(idx))); + return; + } + // Goes through adjust_counter() so the clamp applies here exactly as it + // does to a press. + const long target = strtol(argv[2], nullptr, 10); + const int32_t now = + st.adjust_counter(idx, static_cast(target) - st.counter(idx)); + app_._refresh_counter_labels(); + st.flags().display_redraw = true; + _out("counter %s %ld\n", COUNTER_NAMES[idx], static_cast(now)); +} + +void Console::_cmd_sched(int argc, char** argv) { + App::StateLock lock(app_.state_mutex_); + DeviceState& st = app_.device_state_; + + if (argc >= 2) { + // Rejoin the tokens: a spec is "daily 03:00", two words. + ResetSpecType spec; + for (int i = 1; i < argc; ++i) { + if (i > 1) spec += " "; + spec += argv[i]; + } + bool ok = false; + reset_schedule::parse(spec.c_str(), &ok); + if (!ok) { + _out("not understood: '%s'\n", spec.c_str()); + _out("try: off | daily 03:00 | weekly mon 03:00 | monthly 1 03:00\n"); + return; + } + st.set_reset_spec(spec); + st.save_all(); + // The stored period belongs to the old schedule; keeping it would make + // the next check compare across two different definitions of "period" + // and clear the counters for no reason. Re-adopt instead. + st.set_last_reset_period(0); + app_._check_reset(); + app_._schedule_next_wake(); + } + + const reset_schedule::Spec spec = app_._reset_spec(); + char canonical[reset_schedule::kSpecMaxLen] = {}; + reset_schedule::format(spec, canonical, sizeof(canonical)); + _out("sched %s (stored '%s')\n", canonical, st.reset_spec().c_str()); + _out("period last %ld\n", static_cast(st.last_reset_period())); + _out("nextwake %u s\n", st.flags().schedule_wakeup_time); +} + +void Console::_cmd_reset(int, char**) { + app_._check_reset(); + app_._schedule_next_wake(); + _cmd_counter(0, nullptr); +} + +void Console::_cmd_time(int argc, char** argv) { + DeviceState& st = app_.device_state_; + + if (argc >= 3 && strcasecmp(argv[1], "set") == 0) { + const uint32_t epoch = strtoul(argv[2], nullptr, 10); + const int32_t offset = (argc >= 4) + ? static_cast(strtol(argv[3], nullptr, 10)) + : st.tz_offset(); + if (epoch < 1700000000UL) { + _out("implausible epoch %u\n", epoch); + return; + } + struct timeval tv = {}; + tv.tv_sec = static_cast(epoch); + settimeofday(&tv, nullptr); + st.set_clock_synced(epoch, offset); + st.save_all(); + _out("clock set: ts=%u offset=%d\n", epoch, offset); + // A jump may well have crossed a boundary; test it rather than waiting + // for the next press to notice. + app_._check_reset(); + app_._schedule_next_wake(); + } + + const time_t now = time(nullptr); + const time_t local = now + static_cast(st.tz_offset()); + struct tm tm_utc = {}; + struct tm tm_loc = {}; + gmtime_r(&now, &tm_utc); + gmtime_r(&local, &tm_loc); + _out("utc %04d-%02d-%02d %02d:%02d:%02d\n", tm_utc.tm_year + 1900, + tm_utc.tm_mon + 1, tm_utc.tm_mday, tm_utc.tm_hour, tm_utc.tm_min, + tm_utc.tm_sec); + _out("local %04d-%02d-%02d %02d:%02d:%02d (offset %+ld s)\n", + tm_loc.tm_year + 1900, tm_loc.tm_mon + 1, tm_loc.tm_mday, + tm_loc.tm_hour, tm_loc.tm_min, tm_loc.tm_sec, + static_cast(st.tz_offset())); + _out("clock valid %d, fresh %d, synced %lu s ago\n", st.clock_valid(), + app_._clock_fresh(), + st.clock_valid() + ? static_cast(now - static_cast( + st.last_time_sync())) + : 0UL); +} + +void Console::_cmd_sync(int, char**) { + if (app_.network_.get_state() != Network::State::W_CONNECTED) { + _out("not connected\n"); + return; + } + _out("sync %s\n", app_.webhook_.sync_time() ? "ok" : "failed"); + // The clock may only just have become valid. _service_webhook() does the + // same after its own sync; without it here the period stays unadopted + // until the next connect. + app_._check_reset(); + app_._schedule_next_wake(); + _cmd_time(0, nullptr); +} + +void Console::_cmd_post(int, char**) { + if (app_.network_.get_state() != Network::State::W_CONNECTED) { + _out("not connected\n"); + return; + } + _out("heartbeat %s\n", app_.webhook_.send_heartbeat() ? "ok" : "failed"); +} + +void Console::_cmd_endpoint(int argc, char** argv) { + DeviceState& st = app_.device_state_; + if (argc >= 2) { + st.set_endpoint_url(EndpointUrlType(argv[1])); + st.save_all(); + // The HTTPClient holds the old host; force a rebuild on the next send. + app_.webhook_.begin(); + } + _out("endpoint %s\n", + st.endpoint_url().empty() ? "(unset)" : st.endpoint_url().c_str()); +} + +void Console::_cmd_token(int argc, char** argv) { + DeviceState& st = app_.device_state_; + if (argc >= 2) { + st.set_auth_token(AuthTokenType(argv[1])); + st.save_all(); + } + // Never echoed back. Length is enough to tell "saved" from "truncated", + // which is the only thing worth knowing here. + _out("token %s (%u chars)\n", st.auth_token().empty() ? "(unset)" : "(set)", + static_cast(st.auth_token().length())); +} + +void Console::_cmd_wifi(int, char**) { + char country[4] = {}; + esp_wifi_get_country_code(country); + _out("ssid %s\n", + WiFi.status() == WL_CONNECTED ? WiFi.SSID().c_str() : "(not connected)"); + _out("bssid %s\n", WiFi.BSSIDstr().c_str()); + _out("channel %d, rssi %d\n", WiFi.channel(), + static_cast(WiFi.RSSI())); + _out("ip %s\n", app_.device_state_.ip()); + _out("country applied '%s', configured '%s'\n", country, + app_.device_state_.wifi_country().c_str()); +} + +void Console::_cmd_save(int, char**) { + App::StateLock lock(app_.state_mutex_); + app_.device_state_.save_all(); + _out("saved\n"); +} + +void Console::_cmd_sleep(int, char**) { + _out("sleeping\n"); + app_.transition_to(); +} + +void Console::_cmd_restart(int, char**) { + _out("restarting\n"); + { + App::StateLock lock(app_.state_mutex_); + app_.device_state_.save_all(); + } + delay(100); + ESP.restart(); +} + +void Console::_cmd_setup(int, char**) { + App::StateLock lock(app_.state_mutex_); + app_.device_state_.persisted().restart_to_setup = true; + app_.device_state_.save_all(); + _out("restarting into setup\n"); + delay(100); + ESP.restart(); +} + +void Console::_cmd_wifisetup(int, char**) { + App::StateLock lock(app_.state_mutex_); + app_.device_state_.persisted().restart_to_wifi_setup = true; + app_.device_state_.save_all(); + _out("restarting into Wi-Fi setup\n"); + delay(100); + ESP.restart(); +} diff --git a/Firmware/HomeButtonsArduino/src/console.h b/Firmware/HomeButtonsArduino/src/console.h new file mode 100644 index 0000000..aa2dadd --- /dev/null +++ b/Firmware/HomeButtonsArduino/src/console.h @@ -0,0 +1,111 @@ +#ifndef HOMEBUTTONS_CONSOLE_H +#define HOMEBUTTONS_CONSOLE_H + +#include + +#include "freertos/FreeRTOS.h" // must precede queue.h +#include "freertos/queue.h" +#include "logger.h" + +class App; + +// A line-oriented command console on the serial ports. +// +// Exists because the interesting behaviour of this firmware - a press +// changing a counter, a scheduled reset firing at 03:00 - is otherwise only +// reachable by standing at the device with a finger on a button, or by +// waiting until tomorrow. `press` and `time set` make both testable in +// seconds, over the wire. +// +// Reading and executing are deliberately split across two contexts. The +// main loop blocks for up to HTTP_TIMEOUT inside a POST, so a reader task +// buffers input meanwhile and typed characters are not lost to a UART FIFO +// that nobody is draining. Execution then happens on the main loop, which +// is the task that already owns the webhook, NVS writes and the counters - +// running commands anywhere else would mean a second task on all three. +// +// Both ports are read. Serial is the native USB CDC and Serial0 is UART0 on +// the debug header; which one is attached varies by how the device is +// powered, and neither is worth privileging over the other. Output goes to +// both. +class Console : public Logger { + public: + explicit Console(App& app) : Logger("CON"), app_(app) {} + Console(const Console&) = delete; + + // Opens both ports and starts the reader task. + void begin(); + + // Executes whatever complete lines the reader has buffered. Call from the + // main loop. + void service(); + + private: + static constexpr size_t LINE_MAXLEN = 192; + static constexpr uint8_t LINE_QUEUE_SIZE = 4; + static constexpr uint8_t MAX_ARGS = 8; + static constexpr uint32_t READER_STACK = 3072; + + struct Line { + char text[LINE_MAXLEN]; + }; + + // Per-port assembly buffer. Bytes from the two ports interleave, so each + // needs its own partial line. + struct Port { + Stream* stream = nullptr; + char buf[LINE_MAXLEN] = {}; + size_t len = 0; + }; + + static void _reader_task(void* self); + void _reader(); + // Feeds one byte into a port's buffer; pushes to the queue on newline. + void _feed(Port& port, char c); + void _execute(char* line); + + // Writes to both ports. Console replies are raw rather than going through + // the logger: a reply is the answer to something typed, not a log event, + // and tagging plus timestamping it makes tabular output unreadable. + void __attribute__((format(printf, 2, 3))) _out(const char* fmt, ...) const; + + // --- command handlers --- + void _cmd_help(int argc, char** argv); + void _cmd_status(int argc, char** argv); + void _cmd_press(int argc, char** argv); + void _cmd_counter(int argc, char** argv); + void _cmd_sched(int argc, char** argv); + void _cmd_reset(int argc, char** argv); + void _cmd_time(int argc, char** argv); + void _cmd_sync(int argc, char** argv); + void _cmd_post(int argc, char** argv); + void _cmd_endpoint(int argc, char** argv); + void _cmd_token(int argc, char** argv); + void _cmd_wifi(int argc, char** argv); + void _cmd_save(int argc, char** argv); + void _cmd_sleep(int argc, char** argv); + void _cmd_restart(int argc, char** argv); + void _cmd_setup(int argc, char** argv); + void _cmd_wifisetup(int argc, char** argv); + + struct Command { + const char* name; + const char* args; + const char* help; + void (Console::*fn)(int, char**); + }; + static const Command kCommands[]; + static const size_t kNumCommands; + + // Resolves "a"/"b" or "0"/"1" to a counter index. Returns false and + // reports the valid set if it is neither. + bool _parse_counter_idx(const char* text, uint8_t& idx); + + App& app_; + Port usb_{}; + Port uart_{}; + QueueHandle_t line_queue_ = nullptr; + TaskHandle_t reader_task_h_ = nullptr; +}; + +#endif // HOMEBUTTONS_CONSOLE_H diff --git a/Firmware/HomeButtonsArduino/src/state_machine.h b/Firmware/HomeButtonsArduino/src/state_machine.h index 3e9c016..f39a7cf 100644 --- a/Firmware/HomeButtonsArduino/src/state_machine.h +++ b/Firmware/HomeButtonsArduino/src/state_machine.h @@ -71,6 +71,14 @@ class StateMachine { return std::holds_alternative(current_state_); } + // Which state the machine is sitting in, for diagnostics. The name comes + // straight from the state object, so it cannot drift from the transition + // log lines above. + const char *current_state_name() const { + return std::visit([](auto statePtr) { return statePtr->get_name(); }, + current_state_); + } + void _enter_state(std::variant state) { base_.debug( "Entering state %s::%s", name_, From 172737b60a889ea1af60f542cbb45505f7a207b4 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 03:12:32 +0200 Subject: [PATCH 29/41] Do not clear the counters when local time moves backwards _check_reset() fired on any change of period, which meant a backwards step counted as a boundary crossing. Found on hardware: switching the device to a receiver reporting a different tz_offset moved local time across 03:00 and wiped the counters. The case that matters in service is the autumn DST step. Handing back an hour at 03:00 local, against a "daily 03:00" schedule, would clear and notify on the way down and again on the way back up. A period being re-entered has already had its reset, so adopt it without clearing. Forward crossings are unaffected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- Firmware/HomeButtonsArduino/src/app.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index 70f25ba..5f449ee 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -295,6 +295,21 @@ void App::_check_reset() { } if (period == last) return; + if (period < last) { + // Local time moved backwards. Two ways that happens in practice: the + // receiver corrected a clock the device had run fast, or the autumn DST + // step handed back an hour - which for a "daily 03:00" schedule lands + // exactly on the boundary and would otherwise clear the counters a + // second and third time on the way through. + // + // The period being re-entered has already had its reset, so adopt it + // without clearing. A genuine forward crossing still fires below. + info("local time moved back (period %d -> %d), adopting without clearing", + last, period); + device_state_.set_last_reset_period(period); + return; + } + const bool had_counts = device_state_.clear_counters(); device_state_.set_last_reset_period(period); _refresh_counter_labels(); From 0395b95f53db5aaa347d2393f103a6ba4717a617 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 03:21:47 +0200 Subject: [PATCH 30/41] Test the reset boundary on a timer, not only on a press or a wake _check_reset() was reachable from a press, from the on-connect handler and from boot. In sleep mode that is enough - the scheduled wake is the trigger and the check at boot covers it. A device left awake has none of those, so 03:00 passed with the counters untouched until somebody pressed a button. That is not an edge case: anything on USB power with awake mode on runs this way, which is how the device sits on a desk. Verified on hardware before and after. With a boundary set two minutes out and no input, the counters stayed at 5 and 3 and last_reset_period never advanced. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- Firmware/HomeButtonsArduino/src/app.cpp | 12 ++++++++++++ Firmware/HomeButtonsArduino/src/app.h | 6 ++++++ Firmware/HomeButtonsArduino/src/config.h | 3 +++ 3 files changed, 21 insertions(+) diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index 5f449ee..30405c7 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -496,6 +496,17 @@ void App::_handle_ui_event_global(UserInput::Event event) { void App::_service_console() { console_.service(); } +void App::_service_reset() { + if (millis() - last_reset_check_ < RESET_CHECK_INTERVAL) return; + last_reset_check_ = millis(); + + const int32_t before = device_state_.last_reset_period(); + _check_reset(); + // Only on an actual crossing: _schedule_next_wake() logs, and this runs + // for as long as the device stays awake. + if (device_state_.last_reset_period() != before) _schedule_next_wake(); +} + void App::_console_press(uint8_t btn_id) { _handle_counter_press(btn_id); device_state_.flags().last_user_input_time = millis(); @@ -771,6 +782,7 @@ void App::_main_task() { while (true) { loop(); _service_console(); + _service_reset(); _service_webhook(); _service_display(); esp_task_wdt_reset(); diff --git a/Firmware/HomeButtonsArduino/src/app.h b/Firmware/HomeButtonsArduino/src/app.h index 94137b9..5c762ca 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -218,6 +218,11 @@ class App : public AppStateMachine, public Logger { // Drains the console's line queue. Same task as _service_webhook(), so a // command may touch the webhook, NVS and the counters freely. void _service_console(); + // Tests the reset boundary on a timer. In sleep mode the wake itself is + // the trigger and the check at boot covers it, but a device left awake - + // anything on USB power with awake mode on - would otherwise not notice + // 03:00 passing until the next press or reconnect. + void _service_reset(); // A press injected from the console. Applies it exactly as a real one, // then nudges the state machine the way the UI callback would have - // without which an injected press in sleep mode would sit in the queue @@ -323,6 +328,7 @@ class App : public AppStateMachine, public Logger { BootCause boot_cause_; uint8_t wakeup_btn_id_ = 0; + uint32_t last_reset_check_ = 0; uint32_t last_m_display_redraw_ = 0; uint32_t input_start_time_ = 0; uint32_t session_last_input_time_ = 0; diff --git a/Firmware/HomeButtonsArduino/src/config.h b/Firmware/HomeButtonsArduino/src/config.h index 708a445..307d757 100644 --- a/Firmware/HomeButtonsArduino/src/config.h +++ b/Firmware/HomeButtonsArduino/src/config.h @@ -99,6 +99,9 @@ static constexpr char WIFI_COUNTRY_DFLT[] = ""; // off | daily 03:00 | weekly mon 03:00 | monthly 1 03:00 static constexpr size_t RESET_SPEC_MAXLEN = 24; static constexpr char RESET_SPEC_DFLT[] = "daily 03:00"; +// How often a device that stays awake re-tests the reset boundary. Only +// bounds how late the clear can be, so seconds are plenty fine-grained. +static constexpr uint32_t RESET_CHECK_INTERVAL = 10000L; // The device has no trustworthy clock of its own; it is set from the `ts` // and `tz_offset` the webhook returns. If the last successful sync is // older than this, skip the reset rather than act on a drifting clock - From 06a9447540549f598444d2564146c87242a275ac Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 03:31:56 +0200 Subject: [PATCH 31/41] Add an awake-mode console command Toggling awake mode was portal-only, which made the deep sleep path - the one a battery device actually runs - awkward to exercise. Needed to validate that a scheduled reset wake fires. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- Firmware/HomeButtonsArduino/src/console.cpp | 15 +++++++++++++++ Firmware/HomeButtonsArduino/src/console.h | 1 + 2 files changed, 16 insertions(+) diff --git a/Firmware/HomeButtonsArduino/src/console.cpp b/Firmware/HomeButtonsArduino/src/console.cpp index a30a489..5b7ee63 100644 --- a/Firmware/HomeButtonsArduino/src/console.cpp +++ b/Firmware/HomeButtonsArduino/src/console.cpp @@ -26,6 +26,7 @@ const Console::Command Console::kCommands[] = { &Console::_cmd_endpoint}, {"token", "[tok]", "show or set the auth token", &Console::_cmd_token}, {"wifi", "", "link detail", &Console::_cmd_wifi}, + {"awake", "[0|1]", "show or set awake mode", &Console::_cmd_awake}, {"save", "", "persist NVS now", &Console::_cmd_save}, {"sleep", "", "sleep immediately", &Console::_cmd_sleep}, {"restart", "", "reboot", &Console::_cmd_restart}, @@ -398,6 +399,20 @@ void Console::_cmd_wifi(int, char**) { app_.device_state_.wifi_country().c_str()); } +void Console::_cmd_awake(int argc, char** argv) { + App::StateLock lock(app_.state_mutex_); + DeviceState& st = app_.device_state_; + if (argc >= 2) { + st.persisted().user_awake_mode = strtol(argv[1], nullptr, 10) != 0; + st.save_all(); + } + // Setting this to 0 lets the device deep sleep, which takes the USB CDC + // down with it. UART0 on the debug header survives, and the console is + // up early enough in boot to catch a command sent during a wake. + _out("awake user %d, effective %d\n", st.persisted().user_awake_mode, + st.flags().awake_mode); +} + void Console::_cmd_save(int, char**) { App::StateLock lock(app_.state_mutex_); app_.device_state_.save_all(); diff --git a/Firmware/HomeButtonsArduino/src/console.h b/Firmware/HomeButtonsArduino/src/console.h index aa2dadd..f0fd4b3 100644 --- a/Firmware/HomeButtonsArduino/src/console.h +++ b/Firmware/HomeButtonsArduino/src/console.h @@ -82,6 +82,7 @@ class Console : public Logger { void _cmd_endpoint(int argc, char** argv); void _cmd_token(int argc, char** argv); void _cmd_wifi(int argc, char** argv); + void _cmd_awake(int argc, char** argv); void _cmd_save(int argc, char** argv); void _cmd_sleep(int argc, char** argv); void _cmd_restart(int argc, char** argv); From ce72fab7544aabea825a7a1ce4195ad26e9edc57 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 09:15:38 +0200 Subject: [PATCH 32/41] Deliver the reset report and heartbeat before dropping the link CmdShutdownState::entry() flushed queued presses and then commanded a disconnect, but the scheduled-reset report and the heartbeat go out from _service_webhook(), which runs after loop(). By the time it started its POST the link was already going down. Observed on a timer wake: reset body at t+309ms, "disconnecting..." at t+319ms, then three DNS failures and "post failed after 3 attempts" at t+22540ms. The notification was lost and the device stayed awake 22s to lose it - on battery, that is the expensive part. Service the webhook from the shutdown entry instead, covering all three kinds of pending work, and latch a flag afterwards. The network state only flips to DISCONNECTED when the network task gets round to it, so W_CONNECTED lingers a few ms past the disconnect command - long enough to start a POST that cannot succeed. Nothing is lost by giving up at that point: every event carries absolute counts, so the next one repairs the receiver. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- Firmware/HomeButtonsArduino/src/app.cpp | 17 +++++++++++++++-- Firmware/HomeButtonsArduino/src/app.h | 3 +++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index 30405c7..88a0f2c 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -455,6 +455,11 @@ void App::_net_on_connect() { // MAIN task. Sole owner of webhook_. void App::_service_webhook() { + // The network state only flips to DISCONNECTED once the network task + // gets round to it, so W_CONNECTED stays true for a few ms after the + // shutdown path has commanded the link down - long enough to start a + // POST that cannot possibly succeed. + if (shutting_down_) return; if (network_.get_state() != Network::State::W_CONNECTED) return; if (net_connected_event_.exchange(false)) { @@ -1190,8 +1195,16 @@ void AppSMStates::DeviceInfoState::handle_ui_event(UserInput::Event event) { void AppSMStates::CmdShutdownState::entry() { sm().shutdown_cmd_time_ = millis(); - // Anything still queued has to go out before the link drops. - sm()._flush_pending(); + // Anything still queued has to go out before the link drops - presses, + // the scheduled-reset report and the heartbeat alike. Flushing only the + // presses here left the other two to _service_webhook(), which runs + // after loop() and so started its POST against a link this entry had + // already commanded down: three retries, ~22 s awake, notification lost. + sm()._service_webhook(); + // Past this point the link is going away, so a POST would only burn the + // retry budget on DNS failures. Anything undelivered is reported again + // by the next event, which carries absolute counts. + sm().shutting_down_ = true; sm().device_state_.save_all(); sm().network_.disconnect(); sm().bsl_input_.Stop(); diff --git a/Firmware/HomeButtonsArduino/src/app.h b/Firmware/HomeButtonsArduino/src/app.h index 5c762ca..7bc3792 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -322,6 +322,9 @@ class App : public AppStateMachine, public Logger { // Set by the network task when the link comes up; consumed by the main // task, which owns webhook_. std::atomic net_connected_event_{false}; + // Latched once the shutdown path has commanded the link down. One-way: + // every route through CmdShutdownState ends in sleep or a restart. + bool shutting_down_ = false; SemaphoreHandle_t state_mutex_ = nullptr; BuildIdType spiffs_build_; From 59f1212ec5a7b6141cb9e194e1d6fc8def2bd3d6 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 09:25:00 +0200 Subject: [PATCH 33/41] Recover the clock after a reset, and stop scheduling wakes from a bad one Two faults, both only reachable after a reset that is not a deep sleep wake. System time goes back to 1970 while last_time_sync keeps a real epoch in NVS. The resync test subtracted one from the other and asked whether the result exceeded the resync interval. With a 1970 clock that difference is around minus fifty-six years, which is not greater than six hours, so the device concluded its clock was current and never asked for the time again. The daily reset stays suspended from then on, because _clock_fresh() - correctly - refuses to trust it. _schedule_next_wake() then made it worse by guarding on clock_valid() rather than the same freshness test, so it scheduled from the 1970 clock anyway. Observed: a boundary 150 s away turned into a 7.3 hour sleep. Falling through to no scheduled wake uses the heartbeat interval, and the heartbeat is what re-syncs the clock. Also adds `sleep ` to the console. Testing the sleep path meant guessing how long the device would be gone; an explicit wake keeps it reachable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- Firmware/HomeButtonsArduino/src/app.cpp | 24 +++++++++++++++++++-- Firmware/HomeButtonsArduino/src/app.h | 4 ++++ Firmware/HomeButtonsArduino/src/console.cpp | 13 ++++++++--- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index 88a0f2c..13d7fa7 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -341,7 +341,22 @@ void App::_read_spiffs_build() { void App::_schedule_next_wake() { device_state_.flags().schedule_wakeup_time = 0; - if (!device_state_.clock_valid()) return; + + if (forced_wake_seconds_ > 0) { + device_state_.flags().schedule_wakeup_time = forced_wake_seconds_; + info("forced wake in %u s", forced_wake_seconds_); + return; + } + + // Same test _check_reset() uses, not the weaker clock_valid(). A reset + // that has been suspended for want of a trustworthy clock must not still + // schedule a wake off that clock: after a hard reset, system time is back + // at 1970 while last_time_sync is a real epoch, and the arithmetic below + // produced a plausible-looking seven hour sleep from it. + // + // Falling through to zero here means the heartbeat interval is used + // instead, and the heartbeat is what re-syncs the clock. + if (!_clock_fresh()) return; const reset_schedule::Spec spec = _reset_spec(); if (spec.mode == reset_schedule::Mode::kOff) return; @@ -468,8 +483,13 @@ void App::_service_webhook() { const bool have_presses = press_queue_ != nullptr && uxQueueMessagesWaiting(press_queue_) > 0; const time_t now = time(nullptr); + // !_clock_fresh() rather than !clock_valid(): after a hard reset system + // time is back at 1970 while last_time_sync still holds a real epoch, + // which makes the subtraction below a large negative number - never + // greater than the resync interval, so the device would decide its + // clock was current and never ask for the time again. const bool clock_old = - !device_state_.clock_valid() || + !_clock_fresh() || (now - static_cast(device_state_.last_time_sync())) > static_cast(CLOCK_RESYNC_SECONDS); diff --git a/Firmware/HomeButtonsArduino/src/app.h b/Firmware/HomeButtonsArduino/src/app.h index 7bc3792..737512c 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -325,6 +325,10 @@ class App : public AppStateMachine, public Logger { // Latched once the shutdown path has commanded the link down. One-way: // every route through CmdShutdownState ends in sleep or a restart. bool shutting_down_ = false; + // Console override for the next wake, in seconds. Exists so a sleep test + // cannot put the device beyond reach for hours when the schedule works + // out to a long sleep. 0 means use the schedule. + uint32_t forced_wake_seconds_ = 0; SemaphoreHandle_t state_mutex_ = nullptr; BuildIdType spiffs_build_; diff --git a/Firmware/HomeButtonsArduino/src/console.cpp b/Firmware/HomeButtonsArduino/src/console.cpp index 5b7ee63..45c3377 100644 --- a/Firmware/HomeButtonsArduino/src/console.cpp +++ b/Firmware/HomeButtonsArduino/src/console.cpp @@ -28,7 +28,8 @@ const Console::Command Console::kCommands[] = { {"wifi", "", "link detail", &Console::_cmd_wifi}, {"awake", "[0|1]", "show or set awake mode", &Console::_cmd_awake}, {"save", "", "persist NVS now", &Console::_cmd_save}, - {"sleep", "", "sleep immediately", &Console::_cmd_sleep}, + {"sleep", "[secs]", "sleep now, optionally forcing the wake", + &Console::_cmd_sleep}, {"restart", "", "reboot", &Console::_cmd_restart}, {"setup", "", "reboot into the full setup portal", &Console::_cmd_setup}, {"wifisetup", "", "reboot into the Wi-Fi portal", &Console::_cmd_wifisetup}, @@ -419,8 +420,14 @@ void Console::_cmd_save(int, char**) { _out("saved\n"); } -void Console::_cmd_sleep(int, char**) { - _out("sleeping\n"); +void Console::_cmd_sleep(int argc, char** argv) { + if (argc >= 2) { + app_.forced_wake_seconds_ = + static_cast(strtoul(argv[1], nullptr, 10)); + _out("sleeping, wake in %u s\n", app_.forced_wake_seconds_); + } else { + _out("sleeping\n"); + } app_.transition_to(); } From 04a4ea757c76b990b0d293499fa41efcaaec7f51 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 09:28:04 +0200 Subject: [PATCH 34/41] Feed the watchdog between webhook sends An unreachable receiver costs HTTP_MAX_ATTEMPTS * HTTP_TIMEOUT plus backoff per send, around 31 s. A heartbeat followed by a reset report is two of those against a 60 s watchdog, with nothing feeding it in between. _flush_pending() already resets per press; these two did not. Also corrects a comment: changing the endpoint needs no client teardown, because _post() reads the URL from state on every attempt. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- Firmware/HomeButtonsArduino/src/app.cpp | 5 +++++ Firmware/HomeButtonsArduino/src/console.cpp | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index 13d7fa7..d80dc7a 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -500,6 +500,10 @@ void App::_service_webhook() { // sending when nothing else is going out anyway. webhook_.sync_time(); } + // An unreachable receiver costs HTTP_MAX_ATTEMPTS * HTTP_TIMEOUT plus + // backoff per send, and a heartbeat followed by a reset report is two + // of those - enough to pass WDT_TIMEOUT without this. + esp_task_wdt_reset(); // The clock may only just have become valid, so re-test the boundary // now that it has. @@ -510,6 +514,7 @@ void App::_service_webhook() { if (webhook_.send_reset(reset_schedule::mode_name(_reset_spec().mode))) { reset_to_report_ = false; } + esp_task_wdt_reset(); } _flush_pending(); diff --git a/Firmware/HomeButtonsArduino/src/console.cpp b/Firmware/HomeButtonsArduino/src/console.cpp index 45c3377..2dc39ac 100644 --- a/Firmware/HomeButtonsArduino/src/console.cpp +++ b/Firmware/HomeButtonsArduino/src/console.cpp @@ -368,8 +368,9 @@ void Console::_cmd_endpoint(int argc, char** argv) { if (argc >= 2) { st.set_endpoint_url(EndpointUrlType(argv[1])); st.save_all(); - // The HTTPClient holds the old host; force a rebuild on the next send. - app_.webhook_.begin(); + // No client teardown needed: _post() calls http_.begin() with the URL + // read fresh from state on every attempt, so the next send picks this + // up - including a change of host, keep-alive socket notwithstanding. } _out("endpoint %s\n", st.endpoint_url().empty() ? "(unset)" : st.endpoint_url().c_str()); From 179a5029e4b027a32220e34aee432d913c481300 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 10:17:16 +0200 Subject: [PATCH 35/41] Document the console, and that the webhook response carries the clock The response contract was the least obvious thing about this firmware and the easiest to get wrong: the device has no RTC and no NTP, so a receiver that answers 200 with a plain-text body leaves it counting correctly and permanently unaware of the time. Both ways that happens in n8n - an unpublished draft, and $now.offset reporting the instance timezone rather than a named zone - are now written down, with a curl that checks it from outside the device. Also documents the console command set and the esptool first-attempt failure, which is expected rather than a fault. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- docs/counter-guide.md | 111 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 5 deletions(-) diff --git a/docs/counter-guide.md b/docs/counter-guide.md index de00f4f..d021329 100644 --- a/docs/counter-guide.md +++ b/docs/counter-guide.md @@ -204,6 +204,47 @@ receipt and subtract `age_ms` for a delayed delivery. A `"event": "heartbeat"` request arrives on the timer wake (default every 12 h) carrying battery level only — no `counter`, `delta` or `count`. +### The response is not optional + +The device has no RTC and no NTP. **Its only source of time is the webhook +response**, so every reply must be JSON carrying an absolute UTC epoch and +the local offset in seconds: + +```json +{ "ts": 1785114718, "tz_offset": 7200 } +``` + +In n8n, set the Webhook node's *Response Data* to an expression: + +``` +{{ JSON.stringify({ ts: Math.floor($now.toSeconds()), + tz_offset: $now.setZone("Europe/Warsaw").offset * 60 }) }} +``` + +Naming the zone explicitly is deliberate. `$now.offset` alone reports the +n8n **instance** timezone, which is UTC on a default install — the device +would then treat `daily 03:00` as 03:00 UTC. Pinning the zone in the +expression is DST-correct and does not depend on a setting nobody can see +from the device. + +Two failure modes worth recognising, because neither looks like an error: + +- **An unpublished draft.** n8n serves the last *published* version. Editing + the response expression and not publishing leaves the device receiving + `Workflow got started.` as plain text. It logs `response not JSON`, keeps + counting perfectly, and simply never learns the time — so the scheduled + reset never fires. +- **A missing `content-type`.** Add a `content-type: application/json` + response header alongside the expression. + +Check it from a shell rather than from the device: + +```bash +curl -s -X POST https://your-n8n/webhook/ \ + -H 'content-type: application/json' -d '{"event":"sync"}' +# {"ts":1785114718,"tz_offset":7200} +``` + ## 6. Verify 1. Press button 1. The LED blinks immediately — that is local, and confirms @@ -229,15 +270,17 @@ Serial at 115200 baud shows the whole flow (`pio device monitor`). | Device never sleeps | It is on USB power, so it stays in awake mode | | **Your network is missing from the setup scan list** | Router is on channel 12 or 13. The ESP-IDF default defers to the AP's advertised country and reverts on disconnect, so those channels are never scanned. Set **Wi-Fi Country** to a code whose range covers them (any EU code gives 1-13). `UA` is not supported by ESP-IDF — use `PL`. Also check the network is 2.4 GHz and not hidden | | Placeholder glyph instead of an icon | Icon not in the SPIFFS image — only `plus` and `minus` ship. Re-run `tools/make_icons.py` and `-t uploadfs` | +| **Counters never clear at the scheduled time** | The clock is not trusted. `time` on the console reports `fresh 0` — the device suspends scheduled resets rather than clearing on a guess. Check the receiver is returning `ts`; `sync` forces the attempt | +| Device returns `ts` but the reset fires at the wrong hour | `tz_offset` is wrong. The device applies whatever the receiver sends and knows nothing about zones — see the n8n note below | ## 8. Reading logs -Two routes, and they differ by build: +Two routes, both at 115200, and both live in either build: -| Build | Console | How to read it | +| Route | How to read it | Survives deep sleep | |---|---|---| -| `original_release` | **UART0**, 115200 | `TX` + `GND` on the CMSIS-DAP header, via a 3.3 V USB-serial adapter | -| `original_debug` | **USB CDC**, 115200 | Just the USB-C cable | +| **UART0** | `TX` + `GND` on the CMSIS-DAP header, via a 3.3 V USB-serial adapter | Yes — the port belongs to the adapter, not the device | +| **USB CDC** | Just the USB-C cable | No — the port vanishes and re-enumerates on every wake | ```bash pio run -e original_debug -t upload && pio device monitor @@ -265,7 +308,62 @@ A CMSIS-DAP probe on the same header gives gdb as well: `pio run -e original_debug -t upload` then `pio debug`, using the `esp32s2_cmsisdap.cfg` already in the repo. -## 9. Compile-time settings +### Flashing without touching the board + +The firmware presents a USB CDC device while the app runs, so esptool can +reset it into the bootloader over USB-C — no BOOT+RST needed: + +```bash +esptool --chip esp32s2 --port /dev/ttyACM0 --after hard-reset \ + write-flash -z 0x10000 firmware.bin 0x350000 spiffs.bin +``` + +**Expect the first attempt to fail.** Triggering the reset tears down the +CDC device, so esptool's handle goes stale mid-command and it exits with +`No such device`. The board is now sitting in the ROM bootloader; run the +same command again and it succeeds. Scripts should just retry: + +```bash +for i in 1 2 3; do esptool ... && break; sleep 3; done +``` + +## 9. Serial console + +Both serial routes accept commands as well as printing logs. Type `help` +for the list. It is present in both builds — reaching a misbehaving device +is exactly when you want it, and both ports need physical access anyway. + +| Command | What it does | +|---|---| +| `status` | Build stamps, uptime, heap, state machine, Wi-Fi, battery, counters, seq, endpoint, clock, schedule | +| `press <1-6>` | Injects a press through the real handler — counter, label, display, LED, POST | +| `counter [a\|b [n]]` | Shows or forces a counter. Clamped exactly as a press is | +| `sched [spec]` | Shows or sets the reset schedule (`off`, `daily 03:00`, `weekly mon 04:00`, `monthly 1 05:00`) | +| `reset` | Runs the boundary check now | +| `time` | UTC, local, offset, sync age, and whether the clock is trusted | +| `time set [offset]` | Overrides the clock. Lets a weekly or monthly boundary be tested without waiting for it | +| `sync` / `post` | Forces a time-sync or heartbeat POST | +| `endpoint [url]` / `token [tok]` | Shows or sets the webhook target. The token is never echoed back, only its length | +| `wifi` | SSID, BSSID, channel, RSSI, applied vs configured country | +| `awake [0\|1]` | Shows or sets awake mode | +| `sleep [secs]` | Sleeps now, optionally forcing the wake time | +| `save` / `restart` / `setup` / `wifisetup` | Persist NVS · reboot · reboot into either portal | + +`press` is the one that makes the rest testable: the whole counter flow can +be exercised without a finger on the device, and `time set` collapses a +day's wait into a second. + +Two things worth knowing: + +- **Commands are executed on the main task**, which blocks for the duration + of an HTTPS POST. A reader task keeps buffering meanwhile, but the queue + is four deep — paste a longer block than that while the network is slow + and you will see `busy, command dropped` rather than silent loss. +- **`sleep` with no argument uses the schedule.** If the next boundary is + 20 hours away, that is how long the device is gone. Pass an explicit + number of seconds when testing. + +## 10. Compile-time settings These have no portal field and need a rebuild — all in `Firmware/HomeButtonsArduino/src/config.h`: @@ -277,6 +375,9 @@ These have no portal field and need a rebuild — all in | `HTTP_TIMEOUT` | `10000` ms | Per-attempt timeout | | `HTTP_MAX_ATTEMPTS` | `3` | Retries per press, within the awake window | | `COUNTER_MIN` / `COUNTER_MAX` | `0` / `999999` | Clamp range | +| `RESET_CHECK_INTERVAL` | `10000` ms | How often a device left awake re-tests the reset boundary. Only bounds how late a clear can be | +| `CLOCK_STALE_SECONDS` | `48` h | Past this since the last sync, the clock is not trusted and scheduled resets suspend rather than fire on a guess | +| `CLOCK_RESYNC_SECONDS` | `6` h | How old the clock may get before a connect spends a request re-syncing it | | `BTN_COUNTER_TITLE` | `{1, 2}` | Title buttons, per counter | | `BTN_COUNTER_INC` | `{3, 4}` | Count / increment buttons | | `BTN_COUNTER_DEC` | `{5, 6}` | Decrement buttons | From 5651d0d3b5836884477fd1735f63ea150f3d1c55 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 10:44:10 +0200 Subject: [PATCH 36/41] Address code review: keep webhook work on the main task, fix reset bookkeeping The shutdown fix in ce72fab traded one bug for two worse ones. entry() runs on whichever task made the transition, and several handle_ui_event handlers reach CmdShutdownState from the UI task - so the blocking POST it added could attempt a TLS handshake on the UI task's smaller stack, and put a second task on the one HTTPClient the main task owns. Latching shutting_down_ there made it worse: the network task raises the connect event a moment after the link comes up, so a timer wake reaching shutdown first skipped its heartbeat and then had every later attempt suppressed by the latch. No heartbeat means no clock refresh, and after 48 h the daily reset stops running with nothing to show for it. Draining now happens in CmdShutdownState::loop(), which only ever runs on the main task, and holds the link until _webhook_pending() clears or the drain timeout expires. Reset bookkeeping, both from the same root cause: - Adopting the earlier period on a backwards clock re-armed a boundary that had already fired, so crossing it again cleared a second time and posted a second report, wiping every press in between. Hold the high-water mark instead. - That is only safe if a change of schedule zeroes the stored period, since period_of() counts days for daily but weeks or months otherwise. set_reset_spec() now does it, which also fixes the setup portal silently skipping the first reset after a schedule change - the console did it by hand and the portal did not. Also: _service_reset() persists after a crossing, so an overnight reset on an always-awake device is not lost on the next boot; the console feeds the watchdog between commands, since two blocking sends pasted together outlast it; the three console commands that wrote DeviceState without StateLock now take it; a console press hands the state transition to SleepModeHandleInput::loop() rather than driving the unsynchronised state machine from a second task; and `sleep ` arms the timer it reports even in the states the normal path excludes, which are the ones where being stranded costs the most. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- Firmware/HomeButtonsArduino/src/app.cpp | 125 ++++++++++++++------ Firmware/HomeButtonsArduino/src/app.h | 7 ++ Firmware/HomeButtonsArduino/src/config.h | 4 + Firmware/HomeButtonsArduino/src/console.cpp | 18 ++- Firmware/HomeButtonsArduino/src/state.h | 8 ++ 5 files changed, 125 insertions(+), 37 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index d80dc7a..12a359c 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -59,7 +59,16 @@ void App::_sleep_or_restart() { void App::_start_esp_sleep() { esp_sleep_enable_ext1_wakeup(hw_.WAKE_BITMASK, ESP_EXT1_WAKEUP_ANY_HIGH); - if (device_state_.persisted().wifi_done && + if (forced_wake_seconds_ > 0) { + // Console override, deliberately outside the conditions below. The + // states those exclude - low battery, check_connection - are exactly + // the ones worth debugging over serial, and the console reports a wake + // time it must therefore actually arm. Sleeping there with no timer + // leaves the device recoverable only by hand, which is what the + // override exists to avoid. + esp_sleep_enable_timer_wakeup(static_cast(forced_wake_seconds_) * + 1000000ULL); + } else if (device_state_.persisted().wifi_done && device_state_.persisted().setup_done && !device_state_.persisted().low_batt_mode && !device_state_.persisted().check_connection) { @@ -296,17 +305,23 @@ void App::_check_reset() { if (period == last) return; if (period < last) { - // Local time moved backwards. Two ways that happens in practice: the - // receiver corrected a clock the device had run fast, or the autumn DST - // step handed back an hour - which for a "daily 03:00" schedule lands - // exactly on the boundary and would otherwise clear the counters a - // second and third time on the way through. + // Local time moved backwards: the receiver corrected a clock that had + // run fast, or the autumn DST step handed back an hour - which against + // a "daily 03:00" schedule lands exactly on the boundary. // - // The period being re-entered has already had its reset, so adopt it - // without clearing. A genuine forward crossing still fires below. - info("local time moved back (period %d -> %d), adopting without clearing", - last, period); - device_state_.set_last_reset_period(period); + // Leave last_reset_period alone rather than adopting the earlier value. + // Adopting would re-arm a boundary that has already fired, so crossing + // it again on the way forward would clear a second time and post a + // second report for one scheduled reset, wiping every press counted in + // between. Holding the high-water mark keeps the invariant that a + // period is cleared at most once. + // + // This is only safe because a change of schedule zeroes the stored + // period - see DeviceState::set_reset_spec(). Without that, a switch + // from daily to monthly would look like time running backwards and + // suppress resets indefinitely. + info("local time moved back (period %d -> %d), keeping %d", last, period, + last); return; } @@ -526,15 +541,34 @@ void App::_handle_ui_event_global(UserInput::Event event) { void App::_service_console() { console_.service(); } +bool App::_webhook_pending() const { + // The connect event counts as outstanding work: it is what triggers the + // heartbeat and the time sync, and the network task raises it a little + // after the link comes up. + if (net_connected_event_.load()) return true; + if (reset_to_report_.load()) return true; + return press_queue_ != nullptr && uxQueueMessagesWaiting(press_queue_) > 0; +} + void App::_service_reset() { if (millis() - last_reset_check_ < RESET_CHECK_INTERVAL) return; last_reset_check_ = millis(); const int32_t before = device_state_.last_reset_period(); _check_reset(); + if (device_state_.last_reset_period() == before) return; + // Only on an actual crossing: _schedule_next_wake() logs, and this runs // for as long as the device stays awake. - if (device_state_.last_reset_period() != before) _schedule_next_wake(); + _schedule_next_wake(); + // And persist. Every other caller of _check_reset() is followed by a save + // - _flush_pending() after a press, _go_to_sleep() on the way down - but + // a device left awake crosses the boundary with nobody around to press + // anything. Without this, the receiver is told the counters are zero + // while NVS still holds yesterday's values, and the next boot clears and + // reports a second time for the same boundary. + StateLock lock(state_mutex_); + device_state_.save_all(); } void App::_console_press(uint8_t btn_id) { @@ -543,13 +577,14 @@ void App::_console_press(uint8_t btn_id) { // Keeps an open session open, mirroring SessionState::handle_ui_event(). session_last_input_time_ = millis(); - // In sleep mode the device parks in SleepModeHandleInput waiting for a - // real button, and nothing there connects the network. Boot cause is left - // alone: if the wake really was a timer, NetConnectingState will deliver - // the press and then sleep, which is the honest outcome. - if (is_current_state()) { - transition_to(); - } + // Deliberately not transitioning from here. The state machine is + // unsynchronised and the UI task drives it too, so a second concurrent + // transition source would let a console press racing a real one run + // exit()/entry() twice - re-binding the button callback and restarting a + // connect already in flight. SleepModeHandleInput::loop() picks this up + // instead, on the main task, where its own timeout transition already + // happens. + console_press_pending_ = true; } void App::_service_display() { @@ -929,6 +964,12 @@ void AppSMStates::SleepModeHandleInput::exit() { } void AppSMStates::SleepModeHandleInput::loop() { + // A press injected from the console. Nothing in this state connects the + // network, so without this it would sit in the queue until the timeout + // below slept the device with it undelivered. + if (sm().console_press_pending_.exchange(false)) { + return transition_to(); + } if (millis() - sm().input_start_time_ > SLEEP_MODE_INPUT_TIMEOUT) { return transition_to(); } @@ -1220,25 +1261,43 @@ void AppSMStates::DeviceInfoState::handle_ui_event(UserInput::Event event) { void AppSMStates::CmdShutdownState::entry() { sm().shutdown_cmd_time_ = millis(); - // Anything still queued has to go out before the link drops - presses, - // the scheduled-reset report and the heartbeat alike. Flushing only the - // presses here left the other two to _service_webhook(), which runs - // after loop() and so started its POST against a link this entry had - // already commanded down: three retries, ~22 s awake, notification lost. - sm()._service_webhook(); - // Past this point the link is going away, so a POST would only burn the - // retry budget on DNS failures. Anything undelivered is reported again - // by the next event, which carries absolute counts. - sm().shutting_down_ = true; - sm().device_state_.save_all(); - sm().network_.disconnect(); + // Nothing here may touch the network. entry() runs on whichever task + // made the transition, and several of the handle_ui_event() handlers + // that reach this state run on the UI task: a TLS handshake would be + // attempted on its far smaller stack, and it would put a second task on + // the one HTTPClient the main task owns. Draining happens in loop(), + // which only ever runs on the main task. sm().bsl_input_.Stop(); } void AppSMStates::CmdShutdownState::loop() { - if (millis() - sm().shutdown_cmd_time_ > SHUTDOWN_DELAY) { - return transition_to(); + // Main task, so the webhook is safe to touch here. + sm()._service_webhook(); + + const bool connected = + sm().network_.get_state() == Network::State::W_CONNECTED; + const bool waited_min = millis() - sm().shutdown_cmd_time_ > SHUTDOWN_DELAY; + const bool gave_up = + millis() - sm().shutdown_cmd_time_ > SHUTDOWN_DRAIN_TIMEOUT; + + // Hold the link until everything queued has gone out. The connect event + // is raised by the network task a moment after the link itself comes up, + // so a timer wake that reaches this state first would otherwise sleep + // without ever sending its heartbeat - and the heartbeat response is + // what keeps the clock fresh enough for the scheduled reset to run. + if (connected && sm()._webhook_pending() && !gave_up) return; + if (!waited_min) return; + + if (sm()._webhook_pending()) { + sm().warning("shutting down with work still queued"); } + // Past this point the link is going away, so a POST would only burn its + // retry budget on DNS failures. Anything undelivered is carried by the + // next event, which reports absolute counts. + sm().shutting_down_ = true; + sm().device_state_.save_all(); + sm().network_.disconnect(); + return transition_to(); } void AppSMStates::NetDisconnectingState::loop() { diff --git a/Firmware/HomeButtonsArduino/src/app.h b/Firmware/HomeButtonsArduino/src/app.h index 737512c..25cb533 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -223,6 +223,9 @@ class App : public AppStateMachine, public Logger { // anything on USB power with awake mode on - would otherwise not notice // 03:00 passing until the next press or reconnect. void _service_reset(); + // True while anything is still waiting on the link: an unserviced connect + // event, a scheduled-reset report, or queued presses. + bool _webhook_pending() const; // A press injected from the console. Applies it exactly as a real one, // then nudges the state machine the way the UI callback would have - // without which an injected press in sleep mode would sit in the queue @@ -325,6 +328,10 @@ class App : public AppStateMachine, public Logger { // Latched once the shutdown path has commanded the link down. One-way: // every route through CmdShutdownState ends in sleep or a restart. bool shutting_down_ = false; + // Set by a console press, consumed by SleepModeHandleInput::loop() so the + // state transition happens on the main task rather than adding a second + // concurrent writer to the unsynchronised state machine. + std::atomic console_press_pending_{false}; // Console override for the next wake, in seconds. Exists so a sleep test // cannot put the device beyond reach for hours when the schedule works // out to a long sleep. 0 means use the schedule. diff --git a/Firmware/HomeButtonsArduino/src/config.h b/Firmware/HomeButtonsArduino/src/config.h index 307d757..ebaca54 100644 --- a/Firmware/HomeButtonsArduino/src/config.h +++ b/Firmware/HomeButtonsArduino/src/config.h @@ -138,6 +138,10 @@ static constexpr uint32_t AWAKE_REDRAW_INTERVAL = 1000L; // ms static constexpr uint32_t SETTINGS_MENU_TIMEOUT = 30000L; // ms static constexpr uint32_t DEVICE_INFO_TIMEOUT = 30000L; // ms static constexpr uint32_t SHUTDOWN_DELAY = 500L; // ms +// How long the shutdown path will hold the link open waiting for queued +// work to go out. Long enough for one send to exhaust its retries, short +// enough not to dominate a wake on battery. +static constexpr uint32_t SHUTDOWN_DRAIN_TIMEOUT = 35000L; // ms static constexpr uint32_t SLEEP_MODE_INPUT_TIMEOUT = 10000L; // ms // How long the device stays awake with the connection open after a press, diff --git a/Firmware/HomeButtonsArduino/src/console.cpp b/Firmware/HomeButtonsArduino/src/console.cpp index 2dc39ac..c7f7b60 100644 --- a/Firmware/HomeButtonsArduino/src/console.cpp +++ b/Firmware/HomeButtonsArduino/src/console.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -112,6 +113,11 @@ void Console::service() { Line line; while (xQueueReceive(line_queue_, &line, 0) == pdTRUE) { _execute(line.text); + // `post` and `sync` each block for HTTP_MAX_ATTEMPTS * HTTP_TIMEOUT + // against an unreachable receiver. Two of them pasted together outlast + // WDT_TIMEOUT_AWAKE, and the main loop's own feed is not reached until + // the whole queue has drained. + esp_task_wdt_reset(); } } @@ -272,12 +278,10 @@ void Console::_cmd_sched(int argc, char** argv) { _out("try: off | daily 03:00 | weekly mon 03:00 | monthly 1 03:00\n"); return; } + // set_reset_spec() zeroes the stored period itself, so the next check + // re-adopts rather than comparing across two definitions of "period". st.set_reset_spec(spec); st.save_all(); - // The stored period belongs to the old schedule; keeping it would make - // the next check compare across two different definitions of "period" - // and clear the counters for no reason. Re-adopt instead. - st.set_last_reset_period(0); app_._check_reset(); app_._schedule_next_wake(); } @@ -297,6 +301,10 @@ void Console::_cmd_reset(int, char**) { } void Console::_cmd_time(int argc, char** argv) { + // Guards the NVS write below against the UI task's press handler, which + // mutates the same persisted blob. `time set` is the command most likely + // to be typed while someone is pressing buttons to test a boundary. + App::StateLock lock(app_.state_mutex_); DeviceState& st = app_.device_state_; if (argc >= 3 && strcasecmp(argv[1], "set") == 0) { @@ -364,6 +372,7 @@ void Console::_cmd_post(int, char**) { } void Console::_cmd_endpoint(int argc, char** argv) { + App::StateLock lock(app_.state_mutex_); DeviceState& st = app_.device_state_; if (argc >= 2) { st.set_endpoint_url(EndpointUrlType(argv[1])); @@ -377,6 +386,7 @@ void Console::_cmd_endpoint(int argc, char** argv) { } void Console::_cmd_token(int argc, char** argv) { + App::StateLock lock(app_.state_mutex_); DeviceState& st = app_.device_state_; if (argc >= 2) { st.set_auth_token(AuthTokenType(argv[1])); diff --git a/Firmware/HomeButtonsArduino/src/state.h b/Firmware/HomeButtonsArduino/src/state.h index e6b2605..ad24f12 100644 --- a/Firmware/HomeButtonsArduino/src/state.h +++ b/Firmware/HomeButtonsArduino/src/state.h @@ -197,7 +197,15 @@ class DeviceState : public Logger { return user_preferences_.reset_spec; } void set_reset_spec(const ResetSpecType& spec) { + if (user_preferences_.reset_spec == spec) return; user_preferences_.reset_spec = spec; + // A stored period only means anything under the schedule that produced + // it: period_of() counts days for daily but weeks for weekly and months + // for monthly, so the same instant maps to ~20600, ~2943 or ~678. Left + // alone across a mode change, the comparison in _check_reset() is + // between two different units. Zeroing here re-adopts on the next check + // rather than leaving it to each caller to remember. + persisted_.last_reset_period = 0; } // Clock ------------------------------------------------------------- From 3d33168b826e270e6bda0bb64f318b1add6d12b9 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 11:39:28 +0200 Subject: [PATCH 37/41] Make the reset-period rule a pure function, and test it Two of the review findings were mistakes in three lines of comparison logic, and neither was reachable from a test: the rule lived inside App::_check_reset(), behind a FreeRTOS task, a mutex and a clock. The module it belongs to already had 19 host tests and none of them could touch it. reset_schedule::decide() now takes the stored and current period and returns what to do. _check_reset() only carries it out. Seven cases added, each either a defect that shipped or the invariant it broke. Confirmed they bite: reintroducing the adopt-on-backwards bug fails three of them, including the DST walk asserting one clear per boundary - "Expected 1 Was 2" is the double-clear exactly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- Firmware/HomeButtonsArduino/src/app.cpp | 42 ++++------ .../HomeButtonsArduino/src/reset_schedule.cpp | 25 ++++++ .../HomeButtonsArduino/src/reset_schedule.h | 16 ++++ .../test_reset_schedule.cpp | 79 +++++++++++++++++++ 4 files changed, 134 insertions(+), 28 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index 12a359c..a5ada5b 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -295,34 +295,20 @@ void App::_check_reset() { const int32_t period = reset_schedule::period_of(spec, local); const int32_t last = device_state_.last_reset_period(); - if (last == 0) { - // First time the date has ever been known. Adopt the period without - // clearing, so setting up a device does not wipe a count it was just - // given. - device_state_.set_last_reset_period(period); - return; - } - if (period == last) return; - - if (period < last) { - // Local time moved backwards: the receiver corrected a clock that had - // run fast, or the autumn DST step handed back an hour - which against - // a "daily 03:00" schedule lands exactly on the boundary. - // - // Leave last_reset_period alone rather than adopting the earlier value. - // Adopting would re-arm a boundary that has already fired, so crossing - // it again on the way forward would clear a second time and post a - // second report for one scheduled reset, wiping every press counted in - // between. Holding the high-water mark keeps the invariant that a - // period is cleared at most once. - // - // This is only safe because a change of schedule zeroes the stored - // period - see DeviceState::set_reset_spec(). Without that, a switch - // from daily to monthly would look like time running backwards and - // suppress resets indefinitely. - info("local time moved back (period %d -> %d), keeping %d", last, period, - last); - return; + // The rule itself lives in reset_schedule so it can be unit-tested; this + // function only carries it out. + switch (reset_schedule::decide(last, period)) { + case reset_schedule::Action::kNone: + return; + case reset_schedule::Action::kAdopt: + device_state_.set_last_reset_period(period); + return; + case reset_schedule::Action::kHold: + info("local time moved back (period %d -> %d), keeping %d", last, period, + last); + return; + case reset_schedule::Action::kClear: + break; } const bool had_counts = device_state_.clear_counters(); diff --git a/Firmware/HomeButtonsArduino/src/reset_schedule.cpp b/Firmware/HomeButtonsArduino/src/reset_schedule.cpp index ffd55ac..98bd871 100644 --- a/Firmware/HomeButtonsArduino/src/reset_schedule.cpp +++ b/Firmware/HomeButtonsArduino/src/reset_schedule.cpp @@ -264,4 +264,29 @@ uint32_t seconds_until_next(const Spec& spec, time_t local_time) { return static_cast(delta); } +Action decide(int32_t stored_period, int32_t current_period) { + // Schedule off: period_of() yields 0, which must never match a stored + // period and must never clear. + if (current_period == 0) return Action::kNone; + + // Nothing usable stored. Either the date has never been known, or the + // schedule was just changed and the stored value counts a different unit + // - days for daily, weeks for weekly, months for monthly. Adopt rather + // than clear, so setting a device up does not wipe a count it was just + // given. + if (stored_period == 0) return Action::kAdopt; + + if (current_period == stored_period) return Action::kNone; + + // Local time moved backwards: a clock correction, or the autumn DST step + // handing back an hour. Hold the stored period rather than adopting the + // earlier one. Adopting would re-arm a boundary that has already fired, + // so crossing it again on the way forward would clear a second time and + // post a second report for one scheduled reset, losing every count taken + // in between. + if (current_period < stored_period) return Action::kHold; + + return Action::kClear; +} + } // namespace reset_schedule diff --git a/Firmware/HomeButtonsArduino/src/reset_schedule.h b/Firmware/HomeButtonsArduino/src/reset_schedule.h index 71fde1c..c68a111 100644 --- a/Firmware/HomeButtonsArduino/src/reset_schedule.h +++ b/Firmware/HomeButtonsArduino/src/reset_schedule.h @@ -61,6 +61,22 @@ void format(const Spec& spec, char* out, size_t out_size); // Returns 0 for Mode::kOff, which never matches a stored non-zero period. int32_t period_of(const Spec& spec, time_t local_time); +// What a period comparison means. Pulled out of App::_check_reset() so the +// rule can be exercised on the host: the three cases below are subtle +// enough that two of them shipped wrong, and neither was reachable from a +// unit test while the decision lived inside a FreeRTOS task. +enum class Action : uint8_t { + kNone, // same period, nothing to do + kAdopt, // no usable stored period - take this one without clearing + kHold, // local time moved backwards - keep the stored period + kClear, // boundary genuinely crossed +}; + +// stored_period is what the device last acted on, 0 if none (which is also +// what a change of schedule leaves behind - see DeviceState::set_reset_spec). +// current_period comes from period_of(); 0 means the schedule is off. +Action decide(int32_t stored_period, int32_t current_period); + // Seconds from local_time until the next boundary, clamped into the range // the deep sleep timer accepts. Returns 0 when the mode is off, meaning // "no scheduled wake, fall back to the heartbeat interval". diff --git a/Firmware/HomeButtonsArduino/test/test_reset_schedule/test_reset_schedule.cpp b/Firmware/HomeButtonsArduino/test/test_reset_schedule/test_reset_schedule.cpp index 652e6dc..7bb027f 100644 --- a/Firmware/HomeButtonsArduino/test/test_reset_schedule/test_reset_schedule.cpp +++ b/Firmware/HomeButtonsArduino/test/test_reset_schedule/test_reset_schedule.cpp @@ -213,6 +213,78 @@ void test_next_wake_is_clamped() { TEST_ASSERT_TRUE(secs >= reset_schedule::kWakeMinSeconds); } +// --- decide() ------------------------------------------------------------ +// Every case below is a defect that shipped, or the invariant that defect +// broke. The rule used to live inside App::_check_reset(), where none of it +// could be reached without hardware. + +void test_decide_same_period_does_nothing() { + TEST_ASSERT_TRUE(reset_schedule::decide(20661, 20661) == + reset_schedule::Action::kNone); +} + +void test_decide_adopts_when_nothing_stored() { + // First boot with a known date, and the state left by a schedule change. + // Adopting rather than clearing keeps a count the device was just given. + TEST_ASSERT_TRUE(reset_schedule::decide(0, 20661) == + reset_schedule::Action::kAdopt); +} + +void test_decide_clears_on_forward_crossing() { + TEST_ASSERT_TRUE(reset_schedule::decide(20661, 20662) == + reset_schedule::Action::kClear); +} + +void test_decide_holds_when_time_moves_back() { + // Autumn DST, or a clock correction. Must not clear. + TEST_ASSERT_TRUE(reset_schedule::decide(20662, 20661) == + reset_schedule::Action::kHold); +} + +void test_decide_never_clears_twice_for_one_boundary() { + // The defect: holding is worthless if the earlier period gets adopted, + // because the boundary is then re-armed and fires again on the way + // forward. Walk the DST sequence and count the clears. + int32_t stored = 20661; + int clears = 0; + const int32_t walk[] = {20662, 20661, 20662}; // 03:00, back to 02:00, 03:00 + for (int32_t period : walk) { + switch (reset_schedule::decide(stored, period)) { + case reset_schedule::Action::kClear: + clears++; + stored = period; + break; + case reset_schedule::Action::kAdopt: + stored = period; + break; + default: + break; + } + } + TEST_ASSERT_EQUAL_INT(1, clears); + TEST_ASSERT_EQUAL_INT32(20662, stored); +} + +void test_decide_off_never_clears() { + // period_of() returns 0 for a disabled schedule, and 0 must not read as + // a boundary crossing however large the stored period is. + TEST_ASSERT_TRUE(reset_schedule::decide(20661, 0) == + reset_schedule::Action::kNone); + TEST_ASSERT_TRUE(reset_schedule::decide(0, 0) == + reset_schedule::Action::kNone); +} + +void test_decide_handles_a_change_of_schedule_unit() { + // daily counts days (~20661), monthly counts months (~678). Compared + // directly, the new period looks like time running backwards and resets + // would be suppressed indefinitely - so set_reset_spec() zeroes the + // stored period, and this is what decide() must then see. + TEST_ASSERT_TRUE(reset_schedule::decide(20661, 678) == + reset_schedule::Action::kHold); + TEST_ASSERT_TRUE(reset_schedule::decide(0, 678) == + reset_schedule::Action::kAdopt); +} + int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_parse_daily); @@ -234,5 +306,12 @@ int main(int, char**) { RUN_TEST(test_seconds_until_next_daily); RUN_TEST(test_next_wake_lands_in_the_next_period); RUN_TEST(test_next_wake_is_clamped); + RUN_TEST(test_decide_same_period_does_nothing); + RUN_TEST(test_decide_adopts_when_nothing_stored); + RUN_TEST(test_decide_clears_on_forward_crossing); + RUN_TEST(test_decide_holds_when_time_moves_back); + RUN_TEST(test_decide_never_clears_twice_for_one_boundary); + RUN_TEST(test_decide_off_never_clears); + RUN_TEST(test_decide_handles_a_change_of_schedule_unit); return UNITY_END(); } From 072f7b0bf741c95a58f9b053d6b9a1f8e1813205 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 15:31:32 +0200 Subject: [PATCH 38/41] Build the serial console into debug images only It shipped in release with no gate and no authentication. Anyone with physical USB-C or debug-header access could point the device at another receiver with `endpoint` and `token`, or reopen the provisioning portal with `setup`. platformio.ini already defines HOME_BUTTONS_DEBUG for env:original_debug and setup.cpp uses it for exactly this purpose. Guarding the whole translation unit rather than the call sites keeps the command table and its handlers out of the image entirely: no Console symbols and no command strings survive in original_release, confirmed with nm and strings. Release drops 26 KB of flash and 232 bytes of static RAM, plus the 3 KB reader stack and the queues that begin() would have allocated. The trade is real - the console is what made the counter and reset flows testable at all - so it is one build flag away, and the guide says so. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- Firmware/HomeButtonsArduino/src/app.cpp | 16 +++++++++++++--- Firmware/HomeButtonsArduino/src/app.h | 8 ++++++++ Firmware/HomeButtonsArduino/src/console.cpp | 7 +++++++ docs/counter-guide.md | 9 +++++++-- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/Firmware/HomeButtonsArduino/src/app.cpp b/Firmware/HomeButtonsArduino/src/app.cpp index a5ada5b..9965f60 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -26,8 +26,12 @@ App::App() display_(device_state_), network_(device_state_), webhook_(device_state_), - setup_(*this), - console_(*this) { + setup_(*this) +#ifdef HOME_BUTTONS_DEBUG + , + console_(*this) +#endif +{ press_queue_ = xQueueCreate(PRESS_QUEUE_SIZE, sizeof(PressQueueElement)); if (press_queue_ == nullptr) error("failed to create press queue"); state_mutex_ = xSemaphoreCreateRecursiveMutex(); @@ -525,7 +529,11 @@ void App::_handle_ui_event_global(UserInput::Event event) { device_state_.flags().last_user_input_time = millis(); } -void App::_service_console() { console_.service(); } +void App::_service_console() { +#ifdef HOME_BUTTONS_DEBUG + console_.service(); +#endif +} bool App::_webhook_pending() const { // The connect event counts as outstanding work: it is what triggers the @@ -618,7 +626,9 @@ void App::_main_task() { // Before the display and the network, so a device that fails either is // still reachable - which is the situation the console is most use in. +#ifdef HOME_BUTTONS_DEBUG console_.begin(); +#endif _begin_hw(); diff --git a/Firmware/HomeButtonsArduino/src/app.h b/Firmware/HomeButtonsArduino/src/app.h index 25cb533..4d18f8e 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -12,7 +12,9 @@ #include "logger.h" #include "hardware.h" #include "setup.h" +#ifdef HOME_BUTTONS_DEBUG #include "console.h" +#endif #include "reset_schedule.h" #include "display/display.h" #include "button_ui/btn_sw_led.h" @@ -293,7 +295,13 @@ class App : public AppStateMachine, public Logger { Webhook webhook_; HardwareDefinition hw_; HBSetup setup_; + // Debug builds only. The console can rewrite the endpoint and the auth + // token, and reopen the setup portal, with no authentication beyond + // physical access - and it costs ~4 KB of RAM that a release build on + // this part would rather keep. +#ifdef HOME_BUTTONS_DEBUG Console console_; +#endif // Button callbacks run on the UI task, so a press may not block on HTTP or // NVS there. handle_ui_event() only touches RAM and pushes onto this queue; diff --git a/Firmware/HomeButtonsArduino/src/console.cpp b/Firmware/HomeButtonsArduino/src/console.cpp index c7f7b60..0c416fb 100644 --- a/Firmware/HomeButtonsArduino/src/console.cpp +++ b/Firmware/HomeButtonsArduino/src/console.cpp @@ -1,3 +1,8 @@ +// Debug builds only - see the console_ member in app.h. Guarding the whole +// translation unit rather than just the call sites keeps the command table +// and its handlers out of a release image entirely. +#ifdef HOME_BUTTONS_DEBUG + #include "console.h" #include @@ -469,3 +474,5 @@ void Console::_cmd_wifisetup(int, char**) { delay(100); ESP.restart(); } + +#endif // HOME_BUTTONS_DEBUG diff --git a/docs/counter-guide.md b/docs/counter-guide.md index d021329..2ce9d94 100644 --- a/docs/counter-guide.md +++ b/docs/counter-guide.md @@ -330,8 +330,13 @@ for i in 1 2 3; do esptool ... && break; sleep 3; done ## 9. Serial console Both serial routes accept commands as well as printing logs. Type `help` -for the list. It is present in both builds — reaching a misbehaving device -is exactly when you want it, and both ports need physical access anyway. +for the list. + +**Debug builds only** (`original_debug`). The console can rewrite the +webhook URL and auth token and reopen the setup portal, with no +authentication beyond physical access, so it is compiled out of +`original_release` entirely - 26 KB of flash and ~4 KB of RAM with it. +Build and flash `original_debug` when you need it. | Command | What it does | |---|---| From 9d34881ce1a478c4219280c5c9429082d447ac15 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 15:48:44 +0200 Subject: [PATCH 39/41] Bring the README and guide back in line with the firmware Both predated the scheduled reset and the console, and a few claims had drifted from what the code does. Corrections that would have misled someone following along: - The guide's verification steps opened with "press button 1", which is a title button and does nothing. Now button 3, with the decrement and the do-nothing case as their own steps. - The LED was described as blinking on a press. It lights and stays lit until the receiver answers, which is the whole point of it: solid means pending, dark means delivered, three blinks means it failed. - Session timing said ~200 ms from the original estimate. Measured on hardware it is about 130 ms, with the first POST at 4.2 s. - Flash budget said 69.3%, now 71.0%. Undocumented until now: the Counter Reset portal field and the schedule behind it, the reset event payload, the `build` key, and the fact that the webhook response is the device's only clock. That last one is the expensive gap, because nothing looks wrong when it is missing. esptool commands use the v5 hyphenated spelling, in the guide and in the two tools under tools/, with a note for anyone still on v4. Also drops a line in the compile-time table that referred to a private conversation rather than saying what the setting does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- README.md | 54 ++++++++++++++++++++++---- docs/counter-guide.md | 89 ++++++++++++++++++++++++++++++++++++------- tools/flash_tool.py | 4 +- tools/merge_fw.py | 2 +- 4 files changed, 125 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 09d1bae..7473027 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ MQTT and Home Assistant integration have been removed entirely. ## How it works ``` -press + ──▶ LED blinks immediately (tactile confirmation) +press + ──▶ LED lights immediately (tactile confirmation) ──▶ counter increments locally, display redraws - ──▶ HTTPS POST to the webhook + ──▶ HTTPS POST to the webhook, LED clears once delivered ──▶ stay awake ~30 s for more presses, then deep sleep ``` @@ -40,7 +40,8 @@ waits on the network. After the first press the Wi-Fi association and TLS session stay open for `SESSION_IDLE_TIMEOUT`, so a burst of presses costs one handshake instead of -one per press. Presses 2..N land in roughly 200 ms rather than several seconds. +one per press. Measured on hardware, the first POST takes 4.2 s and later ones about +130 ms. ### Request format @@ -61,7 +62,8 @@ Content-Type: application/json "age_ms": 0, // >0 if this is a delayed retry "battery_pct": 87, "battery_v": 3.92, - "sw_version": "v3.0.0-counter.1" + "sw_version": "v3.0.0-counter.1", + "build": "3d33168b" // git short SHA } ``` @@ -72,6 +74,31 @@ retried. wall-clock time and back-date by `age_ms` so a delayed press does not land with the wrong timestamp. +### The response has to carry a clock + +Every reply must be JSON with an absolute UTC epoch and the local offset in +seconds: + +```json +{"ts": 1785114718, "tz_offset": 7200} +``` + +That is the device's only source of time, and the scheduled reset below is the +only thing that needs it. Without it presses and notifications carry on working +normally and the counters simply never clear, which is not an obvious symptom. + +## Scheduled reset + +The counters can clear themselves on a schedule: `off`, `daily 03:00`, +`weekly mon 03:00` or `monthly 1 03:00`, set in the portal. The device +schedules its deep sleep wake for the boundary, clears, and POSTs an +`"event": "reset"` with the cleared totals. + +Local time is whatever the receiver reports, DST included. If the clock has +not been refreshed for 48 hours the reset suspends instead of clearing on a +guess, and a period is only ever cleared once, so the autumn DST step does not +wipe the counters twice. + ### Notes for an n8n receiver - Set the Webhook node to respond **immediately**, or put a *Respond to @@ -92,8 +119,13 @@ the n8n receiver, troubleshooting, and the compile-time tunables. The short version: hold any two buttons for 5 s → settings → button 1. The device starts an access point `HB-` with password `HB-`, both unique per device and taken from the burnt eFuse. -Portal fields are device name, **webhook URL**, **auth token**, static IP -settings, and the six button labels. +Portal fields are device name, **webhook URL**, **auth token**, **counter +reset** schedule, **Wi-Fi country**, awake mode, static IP settings, and the +six button labels. + +Set the Wi-Fi country if your router sits on channel 12 or 13. The ESP-IDF +default defers to whatever the access point advertises and reverts on +disconnect, so those channels can be missing from the scan list entirely. > The remaining pages under `docs/` are inherited from upstream and describe > the MQTT firmware. They do not apply to this fork. @@ -130,7 +162,13 @@ industrial targets are gone. - HTTPS webhook transport (`src/webhook.{h,cpp}`) - Persistent counters and a monotonic sequence number in NVS - `SessionState` — the post-press awake window -- A CI job that actually builds the firmware, with a flash-headroom check +- A scheduled counter reset (`src/reset_schedule.{h,cpp}`) with the clock taken + from the webhook response +- A serial command console in debug builds (`src/console.{h,cpp}`) so a press, + a clock jump or a sleep can be driven over the wire +- Host unit tests for the schedule arithmetic and the reset decision +- A CI job that actually builds the firmware, runs the tests, and checks flash + headroom **Bugs fixed along the way** (all present upstream at `v2.6.1`) @@ -155,7 +193,7 @@ without warning. **Flash budget.** Upstream's baseline build used **95.8%** of its 0x140000 app partition. After the strip, and with SPIFFS shrunk to make room, this build uses -**69.3%** of 0x1A0000. CI fails if it crosses 90%. +**71.0%** of 0x1A0000. CI fails if it crosses 90%. --- diff --git a/docs/counter-guide.md b/docs/counter-guide.md index 2ce9d94..9fa278a 100644 --- a/docs/counter-guide.md +++ b/docs/counter-guide.md @@ -20,11 +20,11 @@ Grab the `firmware-original` artifact from a ```bash # Recommended: start clean. eFuse identity (serial, model, HW rev) is burnt # and survives this — only settings and Wi-Fi credentials are cleared. -esptool.py --chip esp32s2 --port /dev/ttyACM0 erase_flash +esptool --chip esp32s2 --port /dev/ttyACM0 erase-flash -esptool.py --chip esp32s2 --port /dev/ttyACM0 --baud 921600 \ - --before default_reset --after hard_reset \ - write_flash -z --flash_mode dio --flash_freq 80m --flash_size 4MB \ +esptool --chip esp32s2 --port /dev/ttyACM0 --baud 921600 \ + --before default-reset --after hard-reset \ + write-flash -z --flash-mode dio --flash-freq 80m --flash-size 4MB \ 0x1000 bootloader.bin \ 0x8000 partitions.bin \ 0xe000 ota_data_initial.bin \ @@ -32,6 +32,11 @@ esptool.py --chip esp32s2 --port /dev/ttyACM0 --baud 921600 \ 0x350000 spiffs.bin ``` +Command and option names are the esptool v5 spelling, with hyphens rather +than underscores (`write-flash`, not `write_flash`). v4 accepts only the +underscore form, so on an older install either upgrade with +`pip install -U esptool` or substitute underscores throughout. + Or from a checkout: `pio run -e original_release -t upload -t uploadfs`. **Erase first if the device previously ran stock firmware.** NVS survives a @@ -78,7 +83,8 @@ The portal opens at `http://192.168.4.1` and closes after 10 minutes. | **Webhook URL** | Full HTTPS URL of the n8n **production** webhook. Max 128 chars | | **Auth Token** | Sent as `Authorization: Bearer `. Max 128 chars. Masked in the page | | **Wi-Fi Country** | ISO code, e.g. `PL`, `DE`, `GB`, `US`. Blank uses the ESP-IDF default. **Set this if your router uses channel 12 or 13** — see below | -| **Awake Mode** | `1` keeps the device from deep sleeping — needed to hold a USB serial console open. `0` for normal use. Drains the battery fast | +| **Awake Mode** | `1` keeps the device from deep sleeping, which is what you want while watching a serial log. `0` for normal use. Drains the battery fast | +| **Counter Reset** | When the counters clear themselves. `off`, `daily 03:00`, `weekly mon 03:00`, `monthly 1 03:00`. See below | | Static IP / Gateway / Subnet / DNS / DNS 2 | Optional — leave blank for DHCP. All three of IP, gateway and subnet must be set for static to apply | | Button 1-6 Label | See below | @@ -86,6 +92,38 @@ The portal opens at `http://192.168.4.1` and closes after 10 minutes. verifies the chain; a plain `http://` URL or an untrusted certificate will fail the POST. +### Counter reset schedule + +One free-text field, parsed into four modes: + +| Value | Clears | +|---|---| +| `off` | Never | +| `daily 03:00` | Every day at 03:00 local | +| `weekly mon 03:00` | Mondays at 03:00 local. Day names are the first three letters, `sun` to `sat` | +| `monthly 1 03:00` | The 1st at 03:00 local. Day is capped at 28 so every month has one | + +Anything unparseable falls back to `daily 03:00` rather than silently +disabling the reset. Times are 24-hour. + +At the boundary the device clears both counters, redraws, and POSTs an +`"event": "reset"` carrying the cleared totals. On battery it schedules its +deep sleep wake for the boundary, so the clear happens on time rather than +at the next press. + +**Local time comes from the receiver, not the device.** There is no RTC and +no NTP, so the offset is whatever the webhook response says it is, DST +included. If the response does not carry a clock the reset never fires at +all, and nothing else misbehaves to hint at it. See section 5. + +Two behaviours worth knowing: + +- If the clock has not been refreshed for `CLOCK_STALE_SECONDS` (48 h) the + reset suspends rather than clearing on a guess. +- A period is cleared at most once. Local time moving backwards over the + boundary, which happens at the autumn DST step and after a clock + correction, does not clear again on the way back through. + ### Button labels The buttons are two columns of three, one counter per column: @@ -185,7 +223,26 @@ Request body: "age_ms": 0, // >0 if this is a delayed retry "battery_pct": 87, "battery_v": 3.92, - "sw_version": "v3.0.0-counter.1" + "sw_version": "v3.0.0-counter.1", + "build": "3d33168b" // git short SHA of the firmware +} +``` + +A scheduled reset sends `"event": "reset"` instead, with the cleared totals +as an object rather than one request per counter: + +```jsonc +{ + "device": "HBTNS-24011234-123XYZ", + "seq": 838, + "event": "reset", + "reset_mode": "daily", + "counts": { "a": 0, "b": 0 }, + "age_ms": 0, + "battery_pct": 87, + "battery_v": 3.92, + "sw_version": "v3.0.0-counter.1", + "build": "3d33168b" } ``` @@ -247,13 +304,19 @@ curl -s -X POST https://your-n8n/webhook/ \ ## 6. Verify -1. Press button 1. The LED blinks immediately — that is local, and confirms - nothing about the network. -2. The display updates within a second or two. +1. Press **button 3** (the count button for counter A). Its LED lights and + stays lit while the request is in flight, then goes out once the + receiver has answered. Solid means pending, dark means delivered, three + quick blinks means it failed. +2. The display updates within a second or two, without waiting on the + network. 3. n8n shows an execution; Telegram gets a message. -4. Press three more times in quick succession. Only the first should be - slow — presses 2-4 reuse the open TLS session and land in ~200 ms. -5. After ~30 s of no input the device goes back to deep sleep. +4. Press three more times in quick succession. Only the first is slow. + Presses 2-4 reuse the open TLS session and land in roughly 130 ms. +5. Press **button 5** to decrement, and confirm the count goes back down. +6. Press **button 1**. It is a title button, so it blinks twice and does + nothing else. No POST, no change to the count. +7. After ~30 s of no input the device goes back to deep sleep. Serial at 115200 baud shows the whole flow (`pio device monitor`). @@ -375,7 +438,7 @@ These have no portal field and need a rebuild — all in | Constant | Default | What it does | |---|---|---| -| `SESSION_IDLE_TIMEOUT` | `30000` ms | How long to stay awake after a press. Your original ask was ~2 min; raise if a burst spans longer gaps | +| `SESSION_IDLE_TIMEOUT` | `30000` ms | How long to stay awake after a press. Raise it if presses in a burst are spaced further apart than this, at the cost of battery | | `HEARTBEAT_INTERVAL_DFLT` | `720` min | Battery-only timer wake | | `HTTP_TIMEOUT` | `10000` ms | Per-attempt timeout | | `HTTP_MAX_ATTEMPTS` | `3` | Retries per press, within the awake window | diff --git a/tools/flash_tool.py b/tools/flash_tool.py index 4159889..717f462 100644 --- a/tools/flash_tool.py +++ b/tools/flash_tool.py @@ -129,8 +129,8 @@ def flash_firmware(port: str, baud: int, fw_zip_path: str, test_setup: FactoryTe generate_nvs(nvs_path, partitions["nvs"] ["Size"], test_setup) - tokens = ["esptool.py", "--port", port, "--baud", str(baud), - "--after", "no_reset", "write_flash", + tokens = ["esptool", "--port", port, "--baud", str(baud), + "--after", "no-reset", "write-flash", "0x1000", os.path.join(tmp_dir, "bootloader.bin"), "0x8000", os.path.join(tmp_dir, "partitions.bin"), partitions["nvs"]["Offset"], nvs_path, diff --git a/tools/merge_fw.py b/tools/merge_fw.py index 7c9cf6f..0a63819 100644 --- a/tools/merge_fw.py +++ b/tools/merge_fw.py @@ -26,7 +26,7 @@ def merge_fw(fw_zip_path: str, out_path: str, spiffs_img_path: str = None): partitions = load_partition_table( os.path.join(tmp_dir, "partitions.csv")) - tokens = ["esptool.py", "--chip", "ESP32-S2", "merge_bin", "-o", out_path, + tokens = ["esptool", "--chip", "ESP32-S2", "merge-bin", "-o", out_path, "0x1000", os.path.join(tmp_dir, "bootloader.bin"), "0x8000", os.path.join(tmp_dir, "partitions.bin"), partitions["otadata"]["Offset"], os.path.join( From a85cd6dd4facac47352711bcf292a304ec175f8c Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 16:01:48 +0200 Subject: [PATCH 40/41] Say which serial port to flash, and why the obvious one is wrong The guide used /dev/ttyACM0 as the port and said nothing about picking it. With a USB-to-serial adapter also attached, the numbering is whatever order the kernel enumerated things, and the adapter is an inviting wrong answer: it reaches the ROM bootloader over UART0, so esptool connects and reports the chip correctly before failing later for reasons that look unrelated. What actually happens on an adapter is that the baud change succeeds and the next read times out with "Unable to verify flash chip connection", and that --before default-reset cannot work at all because DTR and RTS are not wired to EN and IO0. Both are now written down, with a udevadm one-liner that names the ports. Also drops --baud from the flash command. On the Espressif port it is a native USB CDC and the rate is ignored, and asking for 921600 is exactly what breaks the adapter. Notes that all five images are needed. Omitting 0x10000 firmware.bin flashes cleanly and boots, and leaves the previous application running. The reflash section no longer repeats the retry explanation; it points at section 1 so the two cannot drift. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- docs/counter-guide.md | 76 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/docs/counter-guide.md b/docs/counter-guide.md index 9fa278a..04be448 100644 --- a/docs/counter-guide.md +++ b/docs/counter-guide.md @@ -22,7 +22,7 @@ Grab the `firmware-original` artifact from a # and survives this — only settings and Wi-Fi credentials are cleared. esptool --chip esp32s2 --port /dev/ttyACM0 erase-flash -esptool --chip esp32s2 --port /dev/ttyACM0 --baud 921600 \ +esptool --chip esp32s2 --port /dev/ttyACM0 \ --before default-reset --after hard-reset \ write-flash -z --flash-mode dio --flash-freq 80m --flash-size 4MB \ 0x1000 bootloader.bin \ @@ -37,6 +37,58 @@ than underscores (`write-flash`, not `write_flash`). v4 accepts only the underscore form, so on an older install either upgrade with `pip install -U esptool` or substitute underscores throughout. +All five images matter. Leaving `0x10000 firmware.bin` out is an easy +mistake to make and a confusing one, because the flash succeeds, the +device boots, and it carries on running whatever application was there +before. + +### Picking the right port + +`/dev/ttyACM0` above is only an example. If anything else USB-serial is +plugged in, the numbering is whatever order the kernel enumerated things, +so check rather than guess: + +```bash +for d in /dev/ttyACM*; do + printf '%s\t' "$d" + udevadm info -q property -n "$d" | grep -E '^ID_VENDOR=|^ID_MODEL=' | tr '\n' ' ' + echo +done +``` + +The device is the one reporting `ID_VENDOR=Espressif`: + +``` +/dev/ttyACM1 ID_VENDOR=Flipper_Devices_Inc. ID_MODEL=Lagums +/dev/ttyACM2 ID_VENDOR=Espressif ID_MODEL=ESP32-S2 +``` + +**Use the Espressif port, not a USB-to-serial adapter wired to the debug +header.** A serial adapter does reach the ROM bootloader, since it listens +on UART0 as well, and esptool will connect and report the chip correctly, +which makes it look like the right choice. Two things then go wrong: + +- Anything much above the default baud rate fails partway through. The + handshake succeeds, `Changing baud rate to 921600` succeeds, and the next + read times out with `Unable to verify flash chip connection`. Drop + `--baud` entirely on the Espressif port, where it is a native USB CDC and + the number is ignored. +- `--before default-reset` cannot work, because DTR and RTS on the adapter + are not wired to the chip's EN and IO0. It only appears to work if the + device already happens to be in download mode. On the Espressif port + esptool resets the running application into the bootloader over USB by + itself, with no buttons. + +**Expect the first attempt to fail** on the Espressif port with +`No such device` or `Failed to connect`. Triggering that reset tears down +the USB device esptool is talking through, invalidating its own handle. +The board is now in the bootloader, so run the same command again and it +succeeds. In a script, retry: + +```bash +for i in 1 2 3; do esptool ... && break; sleep 3; done +``` + Or from a checkout: `pio run -e original_release -t upload -t uploadfs`. **Erase first if the device previously ran stock firmware.** NVS survives a @@ -371,24 +423,20 @@ A CMSIS-DAP probe on the same header gives gdb as well: `pio run -e original_debug -t upload` then `pio debug`, using the `esp32s2_cmsisdap.cfg` already in the repo. -### Flashing without touching the board +### Reflashing while iterating -The firmware presents a USB CDC device while the app runs, so esptool can -reset it into the bootloader over USB-C — no BOOT+RST needed: +The firmware presents a USB CDC device while the application runs, so +esptool resets it into the bootloader over USB-C on its own. You never need +BOOT+RST. For a code change with the icons untouched, only the application +image is worth rewriting: ```bash -esptool --chip esp32s2 --port /dev/ttyACM0 --after hard-reset \ - write-flash -z 0x10000 firmware.bin 0x350000 spiffs.bin +esptool --chip esp32s2 --port /dev/ttyACM2 --after hard-reset \ + write-flash -z 0x10000 firmware.bin ``` -**Expect the first attempt to fail.** Triggering the reset tears down the -CDC device, so esptool's handle goes stale mid-command and it exits with -`No such device`. The board is now sitting in the ROM bootloader; run the -same command again and it succeeds. Scripts should just retry: - -```bash -for i in 1 2 3; do esptool ... && break; sleep 3; done -``` +Section 1 covers picking the port, why the first attempt fails, and why +`--baud` is best left off. ## 9. Serial console From 9ae01693d827a4de7edc325c1bfd1422d1284e1f Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Mon, 27 Jul 2026 16:26:35 +0200 Subject: [PATCH 41/41] Tell the three esptool failures apart The guide said the first attempt fails and to retry, which is true of one failure and misleading for the others. Retrying a wedged chip four times achieves nothing, and the fix for each is different. Write timeout means a stub flasher is still loaded from an earlier command that died partway, and only a chip reset clears it. A failure immediately after "Changing baud rate" means the link cannot carry the rate, which is what a serial adapter does. Also records that --before no-reset is only safe when the chip is known to be freshly in download mode. The USB product string does not distinguish a clean ROM bootloader from a chip running an abandoned stub, so default-reset is the better default. And that a chip reset means the cable or BOOT+RST. Resetting the USB device from the host does not reset the chip, and deauthorising the port can drop it off the bus with no way back except the cable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SFniCJ94a3sciMP9MtbPfs --- docs/counter-guide.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/counter-guide.md b/docs/counter-guide.md index 04be448..d16e442 100644 --- a/docs/counter-guide.md +++ b/docs/counter-guide.md @@ -89,6 +89,26 @@ succeeds. In a script, retry: for i in 1 2 3; do esptool ... && break; sleep 3; done ``` +Not every esptool failure is that one, and retrying the wrong one gets you +nowhere: + +| Error | Means | Do | +|---|---|---| +| `No such device`, `Failed to connect` | The CDC device re-enumerated underneath esptool during its own reset | Run it again | +| `Write timeout` | The chip is not accepting writes at all, usually a stub flasher left running by an earlier command that died partway | Reset the chip. Retrying will not help | +| `Unable to verify flash chip connection` right after `Changing baud rate` | The link cannot carry the requested rate. Almost always a serial adapter | Drop `--baud`, and use the Espressif port | + +`--before no-reset` is only safe when you know the chip is freshly in +download mode. The USB product string does not tell you that: a chip +running an abandoned stub looks the same from the host as one sitting in a +clean ROM bootloader. `--before default-reset` costs nothing and recovers +both, so prefer it unless you have a reason not to. + +A chip reset means unplugging the USB-C cable, or holding BOOT while +tapping RST. Resetting the USB device from the host does not do it, and +deauthorising the port can drop the device off the bus entirely, leaving +the cable as the only way back. + Or from a checkout: `pio run -e original_release -t upload -t uploadfs`. **Erase first if the device previously ran stock firmware.** NVS survives a