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..ef0040a --- /dev/null +++ b/.github/workflows/firmware.yml @@ -0,0 +1,157 @@ +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 + 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 + # 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)". + - name: Build id + id: build_id + run: echo "sha=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT" + + - 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 + + # 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 + # 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 + + - name: Build SPIFFS image + working-directory: Firmware/HomeButtonsArduino + run: | + python tools/make_icons.py + 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 + # time that the image no longer fits. + - name: Check flash headroom + working-directory: Firmware/HomeButtonsArduino + run: | + set -euo pipefail + LIMIT=$((0x1A0000)) + 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// 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 + 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 \ + data/build.txt \ + "dist/${ENV}/" + echo "--- dist/${ENV} ---" + ls -l "dist/${ENV}" + done + + - uses: actions/upload-artifact@v4 + with: + name: homebuttons-original-${{ steps.build_id.outputs.sha }} + 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: homebuttons-original-debug-${{ steps.build_id.outputs.sha }} + path: Firmware/HomeButtonsArduino/dist/original_debug/ + 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..ceb7017 100644 --- a/Firmware/HomeButtonsArduino/.gitignore +++ b/Firmware/HomeButtonsArduino/.gitignore @@ -10,3 +10,13 @@ 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 + +# CI artifact staging dir +dist/ + +# Generated by tools/make_icons.py alongside the icons +data/build.txt 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/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..5e7c45f 100644 --- a/Firmware/HomeButtonsArduino/platformio.ini +++ b/Firmware/HomeButtonsArduino/platformio.ini @@ -7,8 +7,15 @@ ; ; 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. +; +; 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 @@ -17,104 +24,78 @@ 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 + +; 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] -build_flags = +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 board_build.cmake_extra_args = -DHOME_BUTTONS_ORIGINAL=ON - + [env:original_debug] +extends = esp32_base debug_tool = cmsis-dap -build_flags = +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 -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 = +; 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 - -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 + -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/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) diff --git a/Firmware/HomeButtonsArduino/pre_script.py b/Firmware/HomeButtonsArduino/pre_script.py index 57015b5..fa58f8e 100644 --- a/Firmware/HomeButtonsArduino/pre_script.py +++ b/Firmware/HomeButtonsArduino/pre_script.py @@ -1,11 +1,11 @@ #!/usr/bin/env python import os +import subprocess -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"] +Import("env") + +sdkconfig_files = ["sdkconfig.original_release", "sdkconfig.original_debug"] def delete_sdkconfig_files(): print("Deleting sdkconfig files...") @@ -23,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/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.original_debug.defaults b/Firmware/HomeButtonsArduino/sdkconfig.original_debug.defaults index a00bf59..020d442 100644 --- a/Firmware/HomeButtonsArduino/sdkconfig.original_debug.defaults +++ b/Firmware/HomeButtonsArduino/sdkconfig.original_debug.defaults @@ -9,3 +9,30 @@ 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 + +# DO NOT set CONFIG_ESP_CONSOLE_USB_CDC=y here. It hangs the device. +# +# 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. +# +# 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. 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..9965f60 100644 --- a/Firmware/HomeButtonsArduino/src/app.cpp +++ b/Firmware/HomeButtonsArduino/src/app.cpp @@ -2,12 +2,11 @@ #include #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 +14,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 +23,19 @@ 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_), - setup_(*this) { + display_(device_state_), + network_(device_state_), + webhook_(device_state_), + 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(); + if (state_mutex_ == nullptr) error("failed to create state mutex"); } void App::setup() { @@ -71,76 +54,74 @@ void App::setup() { void App::_sleep_or_restart() { delay(3000); -#if defined(HAS_SLEEP_MODE) 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(); -#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 && + 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) { 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() { + _schedule_next_wake(); 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 +130,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 +166,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 +185,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 +194,6 @@ void App::_start_display_task() { &display_task_h_ // Task handle ); } -#endif void App::_network_task(void* param) { App* app = static_cast(param); @@ -283,49 +217,387 @@ 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(); +} + +// --------------------------------------------------------------------------- +// Counter plumbing +// --------------------------------------------------------------------------- + +bool App::_btn_to_counter(uint8_t btn_id, uint8_t& idx, int32_t& delta) { + for (uint8_t i = 0; i < NUM_COUNTERS; i++) { + if (btn_id == BTN_COUNTER_INC[i]) { + idx = i; + delta = 1; + return true; + } + if (btn_id == BTN_COUNTER_DEC[i]) { + idx = i; + delta = -1; + return true; + } + } + return false; // title buttons and anything unmapped +} + +void App::_refresh_counter_labels() { + // 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()); + } +} + +reset_schedule::Spec App::_reset_spec() { + 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 { + 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; + StateLock lock(state_mutex_); + + 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(); + + // 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(); + 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::_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 (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; + + 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) { + 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. + _check_reset(); + + 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); + // Two quick blinks: registered, but nothing is bound to this button. + bsl_input_.LEDBlink(btn_id, 2, 0, 0, 0, false); + 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; + + // 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; + 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]); + 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); + } + } +} + +// 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. + StateLock lock(state_mutex_); + device_state_.save_all(); + } + + 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(); + } +} + +void App::_net_on_connect() { + // 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; +} + +// 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)) { + webhook_.begin(); + + 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 = + !_clock_fresh() || + (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(); + } + // 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. + _check_reset(); + } + + if (reset_to_report_.load()) { + if (webhook_.send_reset(reset_schedule::mode_name(_reset_spec().mode))) { + reset_to_report_ = false; + } + esp_task_wdt_reset(); + } + + _flush_pending(); +} + +void App::_handle_ui_event_global(UserInput::Event event) { + device_state_.flags().last_user_input_time = millis(); +} + +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 + // 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. + _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) { + _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(); + + // 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() { + 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()); - info("SW version: %s", SW_VERSION); + info("SW version: %s, build %s", SW_VERSION, BUILD_ID); // ------ init hardware ------ bool hw_init_ok = hw_.init(); @@ -352,48 +624,27 @@ 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); + // 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 - _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); - // } + // 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) { @@ -401,22 +652,18 @@ void App::_main_task() { 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 +717,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 +727,17 @@ 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 + + // 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(); // ------ start tasks ------ _start_tasks(); @@ -541,32 +751,12 @@ 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) { device_state_.clear_persisted_flags(); @@ -582,44 +772,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,364 +817,48 @@ 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(); + _service_console(); + _service_reset(); + _service_webhook(); + _service_display(); esp_task_wdt_reset(); delay(10); } } -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 +884,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 +900,9 @@ 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 +919,25 @@ 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); 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 +951,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() { @@ -1178,6 +960,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(); } @@ -1188,10 +976,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 +990,18 @@ 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); 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 +1012,6 @@ void AppSMStates::SleepModeHandleInput::handle_ui_event( return transition_to(); } break; -#endif default: break; } @@ -1239,13 +1019,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 +1029,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 +1052,54 @@ 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); + } +} + +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().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); + } + } 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 +1108,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 +1130,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 +1167,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 +1246,87 @@ 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(); + // 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() { - // 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(); + // 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() { - 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..4d18f8e 100644 --- a/Firmware/HomeButtonsArduino/src/app.h +++ b/Firmware/HomeButtonsArduino/src/app.h @@ -2,26 +2,22 @@ #define HOMEBUTTONS_APP_H #include +#include +#include "freertos/FreeRTOS.h" // must precede queue.h +#include "freertos/queue.h" +#include "freertos/semphr.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" +#ifdef HOME_BUTTONS_DEBUG +#include "console.h" #endif - -#if defined(HAS_BUTTON_UI) +#include "reset_schedule.h" +#include "display/display.h" #include "button_ui/btn_sw_led.h" -#endif - -#if defined(HAS_TOUCH_UI) -#include "touch/touch.h" -#endif class App; @@ -72,6 +68,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 +168,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 +180,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 +204,75 @@ 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); + // Runs on the NETWORK task. Must not touch webhook_ - see + // _service_webhook(). 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 + // 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 + // 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(); + // 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(); + // 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 + // 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 + // 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); + // 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(); + // 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. + 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(); DeviceState device_state_; TaskHandle_t ui_task_h_ = nullptr; @@ -217,7 +280,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 +287,86 @@ 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_; + // 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; + // 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; + + // 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_{}; + + // 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. + 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}; + // 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. + uint32_t forced_wake_seconds_ = 0; + SemaphoreHandle_t state_mutex_ = nullptr; + BuildIdType spiffs_build_; BootCause boot_cause_; uint8_t wakeup_btn_id_ = 0; - uint32_t last_sensor_publish_ = 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; 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 Console; 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..ebaca54 100644 --- a/Firmware/HomeButtonsArduino/src/config.h +++ b/Firmware/HomeButtonsArduino/src/config.h @@ -1,154 +1,172 @@ #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"; +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 ------ -#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(). + +// ------ 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 ------ -#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 ------ +// 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_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"}; + +// ------ 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 +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 - +// 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; +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 +// 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, +// 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/console.cpp b/Firmware/HomeButtonsArduino/src/console.cpp new file mode 100644 index 0000000..0c416fb --- /dev/null +++ b/Firmware/HomeButtonsArduino/src/console.cpp @@ -0,0 +1,478 @@ +// 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 +#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}, + {"awake", "[0|1]", "show or set awake mode", &Console::_cmd_awake}, + {"save", "", "persist NVS now", &Console::_cmd_save}, + {"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}, +}; +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); + // `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(); + } +} + +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; + } + // 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(); + 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) { + // 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) { + 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) { + App::StateLock lock(app_.state_mutex_); + DeviceState& st = app_.device_state_; + if (argc >= 2) { + st.set_endpoint_url(EndpointUrlType(argv[1])); + st.save_all(); + // 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()); +} + +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])); + 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_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(); + _out("saved\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(); +} + +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(); +} + +#endif // HOME_BUTTONS_DEBUG diff --git a/Firmware/HomeButtonsArduino/src/console.h b/Firmware/HomeButtonsArduino/src/console.h new file mode 100644 index 0000000..f0fd4b3 --- /dev/null +++ b/Firmware/HomeButtonsArduino/src/console.h @@ -0,0 +1,112 @@ +#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_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); + 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/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..196bd57 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,40 @@ 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, +// 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 +315,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 +334,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 +348,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 +356,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,53 +375,40 @@ 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; } } // 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) { 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 +426,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; @@ -493,7 +440,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); @@ -502,74 +463,40 @@ void Display::draw_main() { } uint16_t w, h; w = u8g2.getUTF8Width(label.c_str()); - h = u8g2.getFontAscent(); - 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); 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) { + 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()); } } - 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 +512,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.setCursor(WIDTH / 2 - w / 2, 60); 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.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 +530,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.setCursor(WIDTH / 2 - w / 2 - 2, 190); 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.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 +570,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); @@ -738,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); @@ -753,71 +616,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 +629,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 +682,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 +695,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 +721,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 +739,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 +787,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 +804,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 +847,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 +865,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 +1046,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 +1073,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..cb097e2 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,31 @@ #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; + +// 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; // 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 +42,15 @@ 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) {} + // 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); void end(); void update(); @@ -74,12 +82,13 @@ 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; - const DeviceState& device_state_; - MDIHelper& mdi_; + DeviceState& device_state_; GxEPD2_DISPLAY_CLASS* disp; @@ -117,17 +126,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..ca3d88b 100644 --- a/Firmware/HomeButtonsArduino/src/network.cpp +++ b/Firmware/HomeButtonsArduino/src/network.cpp @@ -1,18 +1,15 @@ #include "network.h" #include +#include #include "config.h" #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 += ':'; } @@ -33,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(); @@ -58,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 @@ -108,47 +107,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 +118,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 +136,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,61 +173,62 @@ void Network::disconnect(bool erase) { debug("cmd disconnect"); } -void Network::update() { - mqtt_client_.loop(); - 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::update() { loop(); } + +// 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::publish(const TopicType &topic, const char *payload, - bool retained) { - publish(topic, PayloadType{payload}, retained); +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); } -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::info_log_connected(const arduino_event_info_t &ev) { + info("associated: ch %u, RSSI %d", ev.wifi_sta_connected.channel, + WiFi.RSSI()); } -void Network::set_mqtt_callback( - std::function callback) { - usr_callback_ = callback; -} +Network::State Network::get_state() { return state_; } void Network::set_on_connect(std::function on_connect) { this->on_connect_callback_ = on_connect; @@ -319,48 +255,24 @@ 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) { +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; } - 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); + // 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; } - if (ret) { - debug("pub to: %s SUCCESS.", topic.c_str()); - debug("content: %s", payload); - } else { - error("pub to: %s FAIL.", topic.c_str()); + char applied[4] = {}; + if (esp_wifi_get_country_code(applied) == ESP_OK) { + log.info("Wi-Fi country set to %s", applied); } } @@ -383,4 +295,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..1087d3e 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,36 +124,32 @@ 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); + void info_log_connected(const arduino_event_info_t &ev); 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); +// 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/reset_schedule.cpp b/Firmware/HomeButtonsArduino/src/reset_schedule.cpp new file mode 100644 index 0000000..98bd871 --- /dev/null +++ b/Firmware/HomeButtonsArduino/src/reset_schedule.cpp @@ -0,0 +1,292 @@ +#include "reset_schedule.h" + +#include +#include +#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, 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[kSpecMaxLen + 1] = {}; + snprintf(buf, sizeof(buf), "%s", text); + + char* save = nullptr; + const char* mode_tok = strtok_r(buf, " \t", &save); + if (mode_tok == nullptr) { + fail(); + 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)) { + fail(); + } + 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) { + fail(); + weekday = 1; + } + spec.weekday = static_cast(weekday); + if (!parse_hhmm(strtok_r(nullptr, " \t", &save), spec.minute_of_day)) { + fail(); + } + 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) { + fail(); + day = 1; + } + spec.day_of_month = static_cast(day); + if (!parse_hhmm(strtok_r(nullptr, " \t", &save), spec.minute_of_day)) { + fail(); + } + return spec; + } + + fail(); + return 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: + snprintf(out, out_size, "off"); + break; + case Mode::kWeekly: + snprintf(out, out_size, "weekly %s %02d:%02d", + kWeekdayNames[spec.weekday % 7], hour, minute); + break; + case Mode::kMonthly: + snprintf(out, out_size, "monthly %u %02d:%02d", spec.day_of_month, hour, + minute); + break; + case Mode::kDaily: + default: + snprintf(out, out_size, "daily %02d:%02d", hour, minute); + break; + } +} + +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. 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(kWakeMinSeconds)) { + delta = kWakeMinSeconds; + } + if (delta > static_cast(kWakeMaxSeconds)) { + delta = kWakeMaxSeconds; + } + 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 new file mode 100644 index 0000000..c68a111 --- /dev/null +++ b/Firmware/HomeButtonsArduino/src/reset_schedule.h @@ -0,0 +1,87 @@ +#ifndef HOMEBUTTONS_RESET_SCHEDULE_H +#define HOMEBUTTONS_RESET_SCHEDULE_H + +#include +#include +#include + +// 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 { + +// 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 { + 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 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. +// 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 +// 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); + +// 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". +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 f3da9b0..4722fa4 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,52 @@ 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\""); +// 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); +// 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); +// 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); 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 +65,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 +84,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 @@ -87,6 +96,19 @@ 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_); + + // 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.setSaveParamsCallback( + std::bind(&HBSetup::save_wifi_params_callback, this)); wifi_manager.setTitle(app_.device_state_.get_model_name_w_rand_id().c_str()); wifi_manager.setBreakAfterConfig(true); wifi_manager.setDarkMode(true); @@ -131,6 +153,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) { @@ -178,26 +201,63 @@ void HBSetup::start_wifi_setup() { } } +void HBSetup::save_wifi_params_callback() { + CountryCodeType cc{wifi_country_param.getValue()}; + cc.to_upper_case(); + 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_.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() { 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()}); + { + 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); + } + { + // 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(), &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(); + 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_); - 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 +274,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 +288,16 @@ 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); + 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); + 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(), @@ -261,27 +317,14 @@ 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(&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); @@ -292,15 +335,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 +363,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 +429,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/setup.h b/Firmware/HomeButtonsArduino/src/setup.h index ebf8429..3acf13c 100644 --- a/Firmware/HomeButtonsArduino/src/setup.h +++ b/Firmware/HomeButtonsArduino/src/setup.h @@ -17,6 +17,11 @@ 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(); }; #endif // HOMEBUTTONS_SETUP_H diff --git a/Firmware/HomeButtonsArduino/src/state.cpp b/Firmware/HomeButtonsArduino/src/state.cpp index 859cc56..c003cba 100644 --- a/Firmware/HomeButtonsArduino/src/state.cpp +++ b/Firmware/HomeButtonsArduino/src/state.cpp @@ -3,23 +3,14 @@ #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()); - 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,39 +27,44 @@ 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_.putString("rst_spec", user_preferences_.reset_spec.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. + // 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(); } void DeviceState::load_user() { + NvsLock lock(nvs_mutex_); preferences_.begin("user", true); _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); + // 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(), - 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,13 +73,19 @@ 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", ""); + _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); + info("load wifi_cc='%s'", user_preferences_.wifi_country.c_str()); preferences_.end(); } void DeviceState::clear_user() { + NvsLock lock(nvs_mutex_); preferences_.begin("user", false); preferences_.clear(); preferences_.end(); @@ -99,12 +101,21 @@ 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); 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_.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); @@ -112,20 +123,28 @@ 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); + 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); 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_.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); @@ -135,14 +154,13 @@ 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(); } void DeviceState::clear_persisted() { + NvsLock lock(nvs_mutex_); preferences_.begin("persisted", false); preferences_.clear(); preferences_.end(); @@ -156,7 +174,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,15 +185,23 @@ 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(); + // 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() { + 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(); @@ -186,12 +211,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; @@ -210,10 +239,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..ad24f12 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" @@ -33,23 +35,18 @@ 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; + // 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 { @@ -60,6 +57,23 @@ 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; + + // 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; @@ -68,9 +82,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 +91,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; @@ -94,7 +103,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 @@ -102,13 +135,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 +161,101 @@ 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; - } + const CountryCodeType& wifi_country() const { + return user_preferences_.wifi_country; + } + void set_wifi_country(const CountryCodeType& cc) { + user_preferences_.wifi_country = cc; } - 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; + const ResetSpecType& reset_spec() const { + 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 ------------------------------------------------------------- + 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. + int32_t counter(uint8_t idx) const { + if (idx >= NUM_COUNTERS) return 0; + return persisted_.counters[idx]; } + // 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 +282,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 +309,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); @@ -235,7 +321,9 @@ class DeviceState : public Logger { const char* defaultValue); Preferences preferences_; + SemaphoreHandle_t nvs_mutex_ = nullptr; StaticString<15> ip_address_; + APPasswordType ap_password_; }; #endif // HOMEBUTTONS_STATE_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_, 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..0ea7220 100644 --- a/Firmware/HomeButtonsArduino/src/types.h +++ b/Firmware/HomeButtonsArduino/src/types.h @@ -15,18 +15,20 @@ 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 ResetSpecType = StaticString; +using BuildIdType = StaticString; +using CountryCodeType = 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 +60,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..67bf64a --- /dev/null +++ b/Firmware/HomeButtonsArduino/src/webhook.cpp @@ -0,0 +1,163 @@ +#include "webhook.h" + +#include +#include +#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, + 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"] = 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. + 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; + doc["build"] = BUILD_ID; + 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); + // 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. + 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, "press", nullptr); + 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, "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{}; + // 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); + 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 new file mode 100644 index 0000000..4c74328 --- /dev/null +++ b/Firmware/HomeButtonsArduino/src/webhook.h @@ -0,0 +1,70 @@ +#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(); + + // 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, + 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_; + HTTPClient http_; + bool begun_ = false; +}; + +#endif // HOMEBUTTONS_WEBHOOK_H 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..7bb027f --- /dev/null +++ b/Firmware/HomeButtonsArduino/test/test_reset_schedule/test_reset_schedule.cpp @@ -0,0 +1,317 @@ +// 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); +} + +// --- 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); + 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); + 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(); +} diff --git a/Firmware/HomeButtonsArduino/tools/make_icons.py b/Firmware/HomeButtonsArduino/tools/make_icons.py new file mode 100644 index 0000000..bd767aa --- /dev/null +++ b/Firmware/HomeButtonsArduino/tools/make_icons.py @@ -0,0 +1,282 @@ +#!/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 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 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. + +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. + +""" +import os +import shutil +import subprocess +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") + +# 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) + + +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): + """1-bit uncompressed BMP, bottom-up: palette [black, white], a set + bit meaning white. + + 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): + row = bytearray(row_bytes) + for x in range(size): + if px[y][x] == WHITE: + row[x // 8] |= 0x80 >> (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(" 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) + 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 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()] + 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))) + os.makedirs(out_dir, exist_ok=True) + + px = blank(size) + hbar(px, size) + vbar(px, size) + write_bmp(os.path.join(out_dir, "plus.bmp"), px, size) + + px = blank(size) + hbar(px, size) + write_bmp(os.path.join(out_dir, "minus.bmp"), px, size) + + print("wrote %s/{plus,minus}.bmp" % out_dir) + + for name in extra: + 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() diff --git a/README.md b/README.md index 1fd96ef..7473027 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,206 @@ -# Home Buttons +# Home Buttons — Counter -**Open source devices simplifying your smart home.** +A fork of [**nplan/HomeButtons**](https://github.com/nplan/HomeButtons) that turns +a *Home Buttons Original* (model A1) into a **two-channel tally counter** which +reports each press to an HTTPS webhook. -*Designed for Home Assistant.* +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: ---- - -
- - - -***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. +``` +┌─────────┬─────────┐ +│ 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. -## 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). +## How it works + +``` +press + ──▶ LED lights immediately (tactile confirmation) + ──▶ counter increments locally, display redraws + ──▶ HTTPS POST to the webhook, LED clears once delivered + ──▶ 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. Measured on hardware, the first POST takes 4.2 s and later ones about +130 ms. + +### 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", + "build": "3d33168b" // git short SHA +} +``` + +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. + +### 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 + 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 + +**→ [Configuration guide](docs/counter-guide.md)** — flashing, the setup portal, +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**, **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. + +## 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. -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 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`) + +| 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 +**71.0%** 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. diff --git a/docs/counter-guide.md b/docs/counter-guide.md new file mode 100644 index 0000000..d16e442 --- /dev/null +++ b/docs/counter-guide.md @@ -0,0 +1,520 @@ +# 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 --chip esp32s2 --port /dev/ttyACM0 erase-flash + +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 \ + 0x8000 partitions.bin \ + 0xe000 ota_data_initial.bin \ + 0x10000 firmware.bin \ + 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. + +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 +``` + +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 +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 — 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 | +| 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 | +| **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, 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 | + +**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. + +### 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: + +``` + 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, 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. + +### 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 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 +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: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. + +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", + "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" +} +``` + +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`. + +### 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 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 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`). + +## 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 | +| **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, both at 115200, and both live in either build: + +| Route | How to read it | Survives deep sleep | +|---|---|---| +| **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 +``` + +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. + +### Reflashing while iterating + +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/ttyACM2 --after hard-reset \ + write-flash -z 0x10000 firmware.bin +``` + +Section 1 covers picking the port, why the first attempt fails, and why +`--baud` is best left off. + +## 9. Serial console + +Both serial routes accept commands as well as printing logs. Type `help` +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 | +|---|---| +| `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`: + +| Constant | Default | What it does | +|---|---|---| +| `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 | +| `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 | +| `COUNTER_NAMES` | `"a"`, `"b"` | The `counter` field in the payload | 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(