From 443f0305ef73bad5af49d6f9ab675b0904bc0379 Mon Sep 17 00:00:00 2001 From: Fritiof Hedman Date: Tue, 9 Jun 2026 14:45:39 +0200 Subject: [PATCH] Initial frame device --- frame.yaml | 682 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 682 insertions(+) create mode 100644 frame.yaml diff --git a/frame.yaml b/frame.yaml new file mode 100644 index 0000000..560fee2 --- /dev/null +++ b/frame.yaml @@ -0,0 +1,682 @@ +# ============================================================================= +# frame — beacon-driven sleep mode. +# +# WiFi is OFF except briefly during a refresh. Every 15 seconds the device +# wakes, does a passive BLE scan, and only powers up WiFi if it +# sees its own iBeacon (UUID = 10-byte prefix + this device's WiFi STA MAC). +# +# Beacon command codes (iBeacon Major+Minor): +# 0x0000 / 0x0000 → refresh image now +# 0x0001 / 0x0001 → stay awake (skip sleep, connect to HA, wait) +# +# Hardware override: press D2 to wake into stay-awake mode (for USB OTA, debug). +# +# Every 24h without HA contact the device still wakes, connects, pushes battery +# state, and sleeps — without fetching the image. +# +# HA SETUP: create input_boolean.bigink_stay_awake_request as a Toggle helper. +# HA-side automation flips it on → beacon broadcaster emits the stay-awake code +# → bigink stays awake. Flip off → bigink returns to its sleep cycle. +# ============================================================================= + +substitutions: + # Compile-time constants. Edit + flash to change. + wake_interval_s: "30" # seconds between BLE-scan wakes + scan_duration_ms: "1200" # BLE scan window per wake + debounce_s: "60" # min seconds between two refreshes + heartbeat_interval_s: "86400" # 24h status-only HA contact + + # 10-byte (20 hex chars) prefix of the iBeacon UUID this device responds to. + # Full UUID = <6-byte WiFi STA MAC of this device>. + ble_uuid_prefix: !secret ble_uuid_prefix + + # Image source prefix. Full URL fetched per refresh is: + # ${image_url_prefix}/.png + image_url_prefix: !secret image_url_prefix + + # DEBUG: when "true", do_sleep does NOT enter deep sleep — it logs, delays + # wake_interval_s, resets per-cycle state, and re-runs the scan loop. Keeps + # UART<->USB alive so you can watch the device cycle in the serial console. + # Set to "false" to use real deep sleep (production). + debug_no_sleep: "false" + + # DEBUG: when "true", do_ble_scan flashes GPIO21 with state-encoded blink + # patterns each wake (entered → BLE active? → matched?). Useful because + # USB-CDC drops on deep sleep so logger.log is invisible. Set to "false" + # in production to keep the wake silent and save a few mA. + debug_led_breadcrumbs: "false" + +globals: + # Persisted across deep sleep via flash NVS. + - id: elapsed_since_ha_s + type: uint32_t + restore_value: yes + initial_value: '0' + - id: beacon_seen_count + type: uint32_t + restore_value: yes + initial_value: '0' + + # Transient (per-boot). + - id: refresh_in_progress + type: bool + initial_value: 'false' + - id: image_loaded + type: bool + initial_value: 'false' + - id: beacon_matched + type: bool + initial_value: 'false' + - id: beacon_command + type: uint32_t + initial_value: '0' # (Major << 16) | Minor when matched + - id: cold_boot + type: bool + initial_value: 'false' + - id: heartbeat_due + type: bool + initial_value: 'false' + - id: stay_awake_mode + type: bool + initial_value: 'false' + +esphome: + name: frame + libraries: + - SPI + platformio_options: + board_build.arduino.memory_type: qio_opi + board_build.f_flash: 80000000L + board_build.flash_mode: qio + build_flags: + - "-DBOARD_HAS_PSRAM" + - "-mfix-esp32-psram-cache-issue" + lib_ignore: + - esp_insights + - esp_rainmaker + + on_boot: + # WiFi is left disabled at boot (see wifi.enable_on_boot below). Each path + # that needs the radio — do_refresh / do_heartbeat / do_stay_awake — flips + # it on explicitly. Timer-wake / scan path never enables it. + - priority: -100 + then: + - lambda: |- + // Approximate elapsed-since-last-HA-contact: each timer cycle adds + // wake_interval_s. Refresh and heartbeat paths reset to 0. + id(elapsed_since_ha_s) += ${wake_interval_s}; + id(heartbeat_due) = id(elapsed_since_ha_s) >= ${heartbeat_interval_s}; + + auto cause = esp_sleep_get_wakeup_cause(); + switch (cause) { + case ESP_SLEEP_WAKEUP_UNDEFINED: + ESP_LOGI("frame", "Cold boot — forcing full refresh"); + id(cold_boot) = true; + break; + case ESP_SLEEP_WAKEUP_EXT0: + ESP_LOGI("frame", "D2 button wake — stay-awake"); + id(stay_awake_mode) = true; + break; + case ESP_SLEEP_WAKEUP_TIMER: + ESP_LOGD("frame", "Timer wake (elapsed=%us, heartbeat_due=%d)", + id(elapsed_since_ha_s), (int)id(heartbeat_due)); + break; + default: + ESP_LOGW("frame", "Unknown wake cause %d — treating as cold boot", (int)cause); + id(cold_boot) = true; + break; + } + + uint8_t mac[6]; + esphome::get_mac_address_raw(mac); + ESP_LOGD("frame", "WiFi STA MAC %02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + + - if: + condition: + lambda: 'return id(cold_boot);' + then: + - script.execute: do_refresh + else: + - if: + condition: + lambda: 'return id(stay_awake_mode);' + then: + - script.execute: do_stay_awake + else: + - script.execute: do_ble_scan + +esp32: + board: seeed_xiao_esp32s3 + variant: esp32s3 + framework: + type: arduino + +psram: + mode: octal + speed: 80MHz + +external_components: + - source: + type: git + url: "https://github.com/acegallagher/esphome-bigink" + path: bigink_component + +# D2 (GPIO3) is the hardware wake pin. Timer wakes come from sleep_duration +# passed to deep_sleep.enter calls in scripts. +deep_sleep: + id: sleep_control + wakeup_pin: + number: GPIO3 + inverted: true + mode: + input: true + pullup: true + +logger: + level: DEBUG + +api: + +ota: + - platform: esphome + +wifi: + # Never auto-connect at boot. The scripts (do_refresh / do_heartbeat / + # do_stay_awake) call wifi.enable when they need the radio, and disable + # it again on exit. Keeps the WiFi modem off during BLE-scan wakes. + enable_on_boot: false + ssid: !secret wifi_ssid + password: !secret wifi_password + fast_connect: true + power_save_mode: NONE + manual_ip: + static_ip: !secret static_ip + gateway: !secret gateway + subnet: !secret subnet + dns1: !secret dns1 + +http_request: + useragent: esphome/device + timeout: 30s + +# Onboard user LED — lit while BLE is actively scanning (debug aid). +# GPIO21 is the user LED on the XIAO ESP32-S3 family; active-low. +output: + - platform: gpio + id: status_led + pin: + number: GPIO21 + inverted: true + +# ============================================================================= +# Bluetooth — passive iBeacon scanner. Started on demand from do_ble_scan, +# not continuously, to keep the radio off most of the time. +# +# An explicit esp32_ble block (with id) lets do_ble_scan wait for the +# controller to reach ACTIVE before issuing start_scan — its init runs in +# loop(), not setup(), so it isn't ready when called from on_boot. +# ============================================================================= +esp32_ble: + id: ble_id + +esp32_ble_tracker: + scan_parameters: + interval: 50ms + window: 50ms + active: false + continuous: false + on_ble_manufacturer_data_advertise: + - manufacturer_id: '004C' # Apple — iBeacons live in their mfr data block + then: + - lambda: |- + // iBeacon: 02 15 <16-byte UUID> <2B major> <2B minor> <1B tx> + if (x.size() < 23) return; + if (x[0] != 0x02 || x[1] != 0x15) return; + + uint16_t major = ((uint16_t) x[18] << 8) | x[19]; + uint16_t minor = ((uint16_t) x[20] << 8) | x[21]; + + // Log every iBeacon we see (debug visibility). + char uuid_str[33]; + for (int i = 0; i < 16; i++) { + snprintf(uuid_str + 2 * i, 3, "%02X", x[2 + i]); + } + ESP_LOGI("frame", "iBeacon seen: uuid=%s major=0x%04X minor=0x%04X", + uuid_str, major, minor); + + // Compose the expected UUID once: prefix || own WiFi STA MAC. + static uint8_t expected[16]; + static bool built = false; + if (!built) { + const char *hex = "${ble_uuid_prefix}"; + for (int i = 0; i < 10; i++) { + unsigned int b = 0; + sscanf(hex + 2 * i, "%2x", &b); + expected[i] = (uint8_t) b; + } + uint8_t mac[6]; + esphome::get_mac_address_raw(mac); + memcpy(expected + 10, mac, 6); + built = true; + } + + if (memcmp(&x[2], expected, 16) != 0) { + ESP_LOGD("frame", " → no match (not our UUID)"); + return; + } + + id(beacon_matched) = true; + id(beacon_command) = ((uint32_t) major << 16) | minor; + id(beacon_seen_count) += 1; + ESP_LOGI("frame", " → MATCH (this device)"); + +# ============================================================================= +# Online Image — downloads PNG into PSRAM. The url here is a placeholder; +# do_refresh rewrites it to ${image_url_prefix}/.png before each fetch. +# ============================================================================= +online_image: + - url: "${image_url_prefix}/placeholder.png" + id: my_image + format: PNG + type: RGB + resize: 1600x1200 + on_download_finished: + - logger.log: "Image downloaded" + - globals.set: + id: image_loaded + value: 'true' + - logger.log: "Display rendered — pushing battery, then sleeping" + - lambda: 'id(battery).sample();' + - delay: 2s + - globals.set: + id: elapsed_since_ha_s + value: '0' + - globals.set: + id: refresh_in_progress + value: 'false' + - wifi.disable: + - delay: 200ms + - lambda: |- + if(!cached) { + id(eink_display)->update(); + }; + - script.execute: do_sleep + + on_error: + - logger.log: "Image download failed — sleeping; next wake retries if beacon still on" + - globals.set: + id: refresh_in_progress + value: 'false' + - wifi.disable: + - delay: 200ms + - script.execute: do_sleep + +# ============================================================================= +# Scripts — the state machine. +# ============================================================================= +script: + # GPIO21 status-LED breadcrumbs (no UART available during deep-sleep wakes). + # Pattern per wake: 1 blink (entered) → 2 or 5 blinks (BLE active? yes/no) + # → solid during scan → 3 blinks (matched) or 1 long blink (no match). + - id: led_blink + parameters: + count: int + then: + - repeat: + count: !lambda 'return count;' + then: + - output.turn_on: status_led + - delay: 80ms + - output.turn_off: status_led + - delay: 120ms + + - id: do_ble_scan + then: + - logger.log: "Starting BLE scan" + # Breadcrumb A: 1 blink — do_ble_scan entered. + - if: + condition: + lambda: 'return ${debug_led_breadcrumbs};' + then: + - script.execute: + id: led_blink + count: 1 + - script.wait: led_blink + # Wait for the BLE controller to finish its async init. The component's + # setup() only flags state=ENABLE; the actual esp_bt_controller_init / + # bluedroid_init / GAP-callback dance runs in loop(), with a hard-coded + # ~200ms internal delay before flipping to ACTIVE. start_scan silently + # no-ops if invoked beforehand — which was the deep-sleep bug. + - wait_until: + condition: + lambda: 'return id(ble_id).is_active();' + timeout: 3s + # Breadcrumb B: 2 blinks if BLE active, 5 blinks if not (= still broken). + - if: + condition: + lambda: 'return ${debug_led_breadcrumbs};' + then: + - if: + condition: + lambda: 'return id(ble_id).is_active();' + then: + - script.execute: + id: led_blink + count: 2 + else: + - script.execute: + id: led_blink + count: 5 + - script.wait: led_blink + - delay: 150ms + - output.turn_on: status_led + - esp32_ble_tracker.start_scan: + continuous: false + - delay: ${scan_duration_ms}ms + - esp32_ble_tracker.stop_scan: + - output.turn_off: status_led + - delay: 200ms + - logger.log: + format: "Scan done matched=%d cmd=0x%08X" + args: ['(int)id(beacon_matched)', 'id(beacon_command)'] + # Breadcrumb C: 3 blinks if matched, 1 long blink if not. + - if: + condition: + lambda: 'return ${debug_led_breadcrumbs};' + then: + - if: + condition: + lambda: 'return id(beacon_matched);' + then: + - script.execute: + id: led_blink + count: 3 + - script.wait: led_blink + else: + - output.turn_on: status_led + - delay: 500ms + - output.turn_off: status_led + - if: + condition: + lambda: 'return id(beacon_matched);' + then: + - if: + condition: + lambda: 'return id(beacon_command) == 0x00000000;' # REFRESH + then: + - if: + condition: + lambda: 'return id(elapsed_since_ha_s) >= ${debounce_s};' + then: + - script.execute: do_refresh + else: + - logger.log: "REFRESH inside debounce window — skip" + - script.execute: do_sleep + else: + - if: + condition: + lambda: 'return id(beacon_command) == 0x00010001;' # STAY AWAKE + then: + - globals.set: + id: stay_awake_mode + value: 'true' + - script.execute: do_stay_awake + else: + - logger.log: "Beacon match with reserved command — ignored" + - script.execute: do_sleep + else: + - if: + condition: + lambda: 'return id(heartbeat_due);' + then: + - script.execute: do_heartbeat + else: + - script.execute: do_sleep + + - id: do_refresh + then: + - logger.log: "REFRESH — WiFi up" + - globals.set: + id: refresh_in_progress + value: 'true' + - wifi.enable: + - wait_until: + condition: + wifi.connected: + timeout: 10s + - if: + condition: + wifi.connected: + then: + - online_image.set_url: + id: my_image + url: !lambda |- + uint8_t mac[6]; + esphome::get_mac_address_raw(mac); + char url[128]; + snprintf(url, sizeof(url), + "%s/%02x%02x%02x%02x%02x%02x.png", + "${image_url_prefix}", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + ESP_LOGI("frame", "WiFi connected — fetching %s", url); + return std::string(url); + - component.update: my_image + # Flow continues from online_image on_download_finished / on_error. + else: + - logger.log: "WiFi connect timed out — sleeping, retry next wake" + - globals.set: + id: refresh_in_progress + value: 'false' + - wifi.disable: + - script.execute: do_sleep + + - id: do_heartbeat + then: + - logger.log: "HEARTBEAT — pushing battery to HA" + - wifi.enable: + - wait_until: + condition: + wifi.connected: + timeout: 10s + - if: + condition: + wifi.connected: + then: + - lambda: 'id(battery).sample();' + - delay: 3s + - globals.set: + id: elapsed_since_ha_s + value: '0' + - logger.log: "Heartbeat done" + else: + - logger.log: "Heartbeat: WiFi failed" + - wifi.disable: + - delay: 200ms + - script.execute: do_sleep + + - id: do_stay_awake + then: + - logger.log: "STAY-AWAKE — WiFi up; the 30s poll will sleep when HA flips ha_stay_awake off" + - globals.set: + id: stay_awake_mode + value: 'true' + - wifi.enable: + - wait_until: + condition: + wifi.connected: + timeout: 15s + - if: + condition: + wifi.connected: + then: + - delay: 5s + - lambda: 'id(battery).sample();' + - globals.set: + id: elapsed_since_ha_s + value: '0' + - logger.log: "Stay-awake ready" + else: + - logger.log: "Stay-awake: WiFi failed — sleeping" + - wifi.disable: + - script.execute: do_sleep + + - id: do_sleep + then: + - if: + condition: + lambda: 'return ${debug_no_sleep};' + then: + # DEBUG: simulate the deep-sleep + wake cycle without dropping UART. + - logger.log: + format: "[DEBUG] would deep_sleep for %ds (elapsed_since_ha=%us); simulating" + args: ['(int)${wake_interval_s}', 'id(elapsed_since_ha_s)'] + - delay: ${wake_interval_s}s + - logger.log: "[DEBUG] simulated wake — running timer-wake path" + # Reset transient per-boot state, mimic on_boot's timer-wake branch. + - globals.set: + id: beacon_matched + value: 'false' + - globals.set: + id: beacon_command + value: '0' + - globals.set: + id: cold_boot + value: 'false' + - lambda: |- + id(elapsed_since_ha_s) += ${wake_interval_s}; + id(heartbeat_due) = id(elapsed_since_ha_s) >= ${heartbeat_interval_s}; + - if: + condition: + lambda: 'return id(stay_awake_mode);' + then: + - logger.log: "[DEBUG] stay_awake_mode active — skipping scan loop" + else: + - script.execute: do_ble_scan + else: + - logger.log: + format: "Sleeping for %ds (elapsed_since_ha=%us)" + args: ['(int)${wake_interval_s}', 'id(elapsed_since_ha_s)'] + - deep_sleep.enter: + id: sleep_control + sleep_duration: !lambda 'return ${wake_interval_s} * 1000UL;' + +# ============================================================================= +# Stay-awake poll — fires only while we're alive (i.e., in stay-awake mode). +# When HA clears the flag, sleep. +# ============================================================================= +interval: + - interval: 30s + then: + - if: + condition: + and: + - lambda: 'return id(stay_awake_mode);' + - wifi.connected: + - binary_sensor.is_off: ha_stay_awake + then: + - logger.log: "Stay-awake cleared — returning to sleep loop" + - globals.set: + id: stay_awake_mode + value: 'false' + - wifi.disable: + - script.execute: do_sleep + +# ============================================================================= +# Display +# ============================================================================= +display: + - platform: seeed_epaper_spectra6 + id: eink_display + + clk_pin: 7 + mosi_pin: 9 + cs_master_pin: GPIO44 + cs_slave_pin: GPIO41 + dc_pin: GPIO10 + reset_pin: GPIO38 + power_pin: GPIO43 + busy_pin: + number: GPIO4 + inverted: true + mode: + input: true + pullup: true + + flip_mode: false + update_interval: never + + lambda: |- + if (id(image_loaded)) { + it.image(0,0, id(my_image)); + } else { + auto BLACK = Color(0, 0, 0); + auto WHITE = Color(255, 255, 255); + auto RED = Color(255, 0, 0); + auto GRAY = Color(128, 128, 128); + + it.fill(WHITE); + + it.rectangle(20, 20, 1560, 1160, BLACK); + it.rectangle(22, 22, 1556, 1156, BLACK); + + it.filled_circle(800, 380, 15, BLACK); + it.circle(800, 350, 80, BLACK); + it.circle(800, 350, 130, BLACK); + it.circle(800, 350, 180, BLACK); + it.filled_rectangle(600, 380, 400, 150, WHITE); + + for (int i = 0; i < 6; i++) { + it.line(650+i, 220, 950+i, 450, RED); + it.line(650+i, 450, 950+i, 220, RED); + } + + it.printf(800, 580, id(font_large), BLACK, TextAlign::CENTER, "No Connection"); + it.printf(800, 680, id(font_medium), GRAY, TextAlign::CENTER, "Waiting for beacon..."); + it.printf(800, 760, id(font_medium), GRAY, TextAlign::CENTER, "Press D2 to stay awake"); + } + +font: + - file: "gfonts://Roboto" + id: font_large + size: 80 + - file: "gfonts://Roboto" + id: font_medium + size: 40 + +# ============================================================================= +# Battery Voltage Monitoring via battery_monitor driver, logs recent +# samples and then averages them, looking up SOC via 3.7v +# lithium voltage->SOC LUT +# +# EE02 has voltage divider on GPIO1, enable pin on GPIO6 +# ============================================================================= +battery_monitor: + id: battery + voltage_pin: GPIO1 + enable_pin: GPIO6 + samples_to_average: 10 + +sensor: + - platform: battery_monitor + battery_monitor_id: battery + voltage: + name: "Battery Voltage" + percentage: + name: "Battery Percentage" + + - platform: template + name: "Beacons Seen" + accuracy_decimals: 0 + lambda: 'return id(beacon_seen_count);' + update_interval: 60s + +binary_sensor: + - platform: homeassistant + entity_id: input_boolean.bigink_stay_awake_request + id: ha_stay_awake + name: "Stay Awake Requested" + + - platform: template + name: "Stay Awake Mode" + lambda: 'return id(stay_awake_mode);'