# ============================================================================= # 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);'