Initial frame device

This commit is contained in:
2026-06-09 14:45:39 +02:00
parent cdecab07af
commit 443f0305ef

682
frame.yaml Normal file
View File

@@ -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 = <prefix><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}/<wifi_sta_mac_lowercase_no_separators>.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}/<mac>.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);'