diff --git a/build_static/app.js.gz b/build_static/app.js.gz new file mode 100644 index 0000000..ba8b242 Binary files /dev/null and b/build_static/app.js.gz differ diff --git a/build_static/styles.css b/build_static/styles.css new file mode 100644 index 0000000..e3ecf1c --- /dev/null +++ b/build_static/styles.css @@ -0,0 +1,37 @@ +/* General tab styles */ +.tabs { + display: flex; + justify-content: center; + margin-bottom: 20px; +} + +.tab { + padding: 10px 20px; + margin: 0 10px; + cursor: pointer; + background-color: #f1f1f1; + border: 1px solid #ccc; + border-radius: 4px; + transition: background-color 0.3s ease; +} + +.tab:hover { + background-color: #ddd; +} + +.tab.active { + background-color: #ccc; +} + +.tab-content { + display: flex; + justify-content: center; +} + +.tab-pane { + display: none; +} + +.tab-pane.active { + display: block; +} diff --git a/build_static/styles.css.gz b/build_static/styles.css.gz new file mode 100644 index 0000000..009b842 Binary files /dev/null and b/build_static/styles.css.gz differ diff --git a/db/tab.json b/db/tab.json index aa03731..ffdc6a2 100644 --- a/db/tab.json +++ b/db/tab.json @@ -2,7 +2,7 @@ "1": { "name": "default", "names": [ - "1","2","3","4","5","6","7","8" + "a","b","c","d","e","f","g","h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" ], "presets": [ [ diff --git a/flash.sh b/flash.sh new file mode 100755 index 0000000..4025284 --- /dev/null +++ b/flash.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env sh +set -eu + +# Environment variables: +# PORT - serial port (default: /dev/ttyUSB0) +# BAUD - baud rate (default: 460800) +# FIRMWARE - local path to firmware .bin +# FW_URL - URL to download firmware if FIRMWARE not provided or missing + +PORT=${PORT:-} +BAUD=${BAUD:-460800} +CHIP=${CHIP:-esp32} # esp32 | esp32c3 + +# Map chip-specific settings +ESPT_CHIP="$CHIP" +FLASH_OFFSET=0x1000 +DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32/" +BOARD_ID="ESP32_GENERIC" +BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/" +case "$CHIP" in + esp32c3) + ESPT_CHIP="esp32c3" + FLASH_OFFSET=0x0 + DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32C3/" + BOARD_ID="ESP32_GENERIC_C3" + BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/" + ;; + esp32) + ESPT_CHIP="esp32" + FLASH_OFFSET=0x1000 + DEFAULT_DOWNLOAD_PAGE="https://micropython.org/download/ESP32/" + BOARD_ID="ESP32_GENERIC" + BOARD_PAGE="https://micropython.org/download/${BOARD_ID}/" + ;; + *) + echo "Unsupported CHIP: $CHIP (supported: esp32, esp32c3)" >&2 + exit 1 + ;; +esac + +# Download-only mode: fetch the appropriate firmware and exit +if [ -n "${DOWNLOAD_ONLY:-}" ]; then + # Prefer resolving latest if nothing provided + if [ -z "${FIRMWARE:-}" ] && [ -z "${FW_URL:-}" ]; then + LATEST=1 + fi + if ! resolve_firmware; then + echo "Failed to resolve firmware for CHIP=$CHIP" >&2 + exit 1 + fi + echo "$FIRMWARE" + exit 0 +fi + +# Helper: resolve the latest firmware URL for a given board pattern with multiple fallbacks +resolve_latest_url() { + board_pattern="$1" # e.g., ESP32_GENERIC_C3-.*\.bin + # Candidate pages to try in order + pages="${BOARD_PAGE} ${DOWNLOAD_PAGE:-$DEFAULT_DOWNLOAD_PAGE} https://micropython.org/download/ https://micropython.org/resources/firmware/" + for page in $pages; do + echo "Trying to resolve latest from $page" >&2 + html=$(curl -fsSL -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36' -e 'https://micropython.org/download/' "$page" || true) + [ -z "$html" ] && continue + # Prefer matching the board pattern + url=$(printf "%s" "$html" \ + | sed -n 's/.*href=\"\([^\"]*\.bin\)\".*/\1/p' \ + | grep -E "$board_pattern" \ + | head -n1) + if [ -n "$url" ]; then + case "$url" in + http*) echo "$url"; return 0 ;; + /*) echo "https://micropython.org$url"; return 0 ;; + *) echo "$page$url"; return 0 ;; + esac + fi + done + return 1 +} + +# If LATEST is set and neither FIRMWARE nor FW_URL are provided, auto-detect latest URL +if [ -n "${LATEST:-}" ] && [ -z "${FIRMWARE:-}" ] && [ -z "${FW_URL:-}" ]; then + # Default board identifiers for each chip + case "$CHIP" in + esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;; + esp32) BOARD_ID="ESP32_GENERIC" ;; + *) BOARD_ID="ESP32_GENERIC" ;; + esac + pattern="${BOARD_ID}-.*\\.bin" + echo "Resolving latest firmware for $BOARD_ID" + if FW_URL=$(resolve_latest_url "$pattern"); then + export FW_URL + echo "Latest firmware resolved to: $FW_URL" + else + echo "Failed to resolve latest firmware for pattern $pattern" >&2 + exit 1 + fi +fi + +# Resolve firmware path, downloading if needed +resolve_firmware() { + if [ -z "${FIRMWARE:-}" ]; then + if [ -n "${FW_URL:-}" ] || [ -n "${LATEST:-}" ]; then + # If FW_URL still unset, resolve latest using board-specific pattern + if [ -z "${FW_URL:-}" ]; then + case "$CHIP" in + esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;; + esp32) BOARD_ID="ESP32_GENERIC" ;; + *) BOARD_ID="ESP32_GENERIC" ;; + esac + pattern="${BOARD_ID}-.*\\.bin" + echo "Resolving latest firmware for $BOARD_ID" + if ! FW_URL=$(resolve_latest_url "$pattern"); then + echo "Failed to resolve latest firmware for pattern $pattern" >&2 + exit 1 + fi + fi + mkdir -p .cache + FIRMWARE=".cache/$(basename "$FW_URL")" + if [ ! -f "$FIRMWARE" ]; then + echo "Downloading firmware from $FW_URL to $FIRMWARE" + curl -L --fail -o "$FIRMWARE" "$FW_URL" + else + echo "Firmware already downloaded at $FIRMWARE" + fi + else + # Default fallback: fetch latest using board-specific pattern + case "$CHIP" in + esp32c3) BOARD_ID="ESP32_GENERIC_C3" ;; + esp32) BOARD_ID="ESP32_GENERIC" ;; + *) BOARD_ID="ESP32_GENERIC" ;; + esac + pattern="${BOARD_ID}-.*\\.bin" + echo "No FIRMWARE or FW_URL specified. Auto-fetching latest for $BOARD_ID" + if ! FW_URL=$(resolve_latest_url "$pattern"); then + echo "Failed to resolve latest firmware for pattern $pattern" >&2 + exit 1 + fi + mkdir -p .cache + FIRMWARE=".cache/$(basename "$FW_URL")" + if [ ! -f "$FIRMWARE" ]; then + echo "Downloading firmware from $FW_URL to $FIRMWARE" + curl -L --fail -o "$FIRMWARE" "$FW_URL" + else + echo "Firmware already downloaded at $FIRMWARE" + fi + fi + else + if [ ! -f "$FIRMWARE" ]; then + if [ -n "${FW_URL:-}" ]; then + mkdir -p "$(dirname "$FIRMWARE")" + echo "Firmware not found at $FIRMWARE. Downloading from $FW_URL" + curl -L --fail -o "$FIRMWARE" "$FW_URL" + else + echo "Firmware file not found: $FIRMWARE. Provide FW_URL to download automatically." >&2 + exit 1 + fi + fi + fi +} + +# Auto-detect PORT if not specified +if [ -z "$PORT" ]; then + candidates="$(ls /dev/tty/ACM* /dev/tty/USB* 2>/dev/null || true)" + # Some systems expose without /dev/tty/ prefix patterns; try common Linux paths + [ -z "$candidates" ] && candidates="$(ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true)" + # Prefer ACM (often for C3) then USB + PORT=$(printf "%s\n" $candidates | grep -E "/dev/ttyACM[0-9]+" | head -n1 || true) + [ -z "$PORT" ] && PORT=$(printf "%s\n" $candidates | grep -E "/dev/ttyUSB[0-9]+" | head -n1 || true) + if [ -z "$PORT" ]; then + echo "No serial port detected. Connect the board and set PORT=/dev/ttyACM0 (or /dev/ttyUSB0)." >&2 + exit 1 + fi + echo "Auto-detected PORT=$PORT" +fi + +# Preflight: ensure port exists +if [ ! -e "$PORT" ]; then + echo "Port $PORT does not exist. Detected candidates:" >&2 + ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null || true + exit 1 +fi + +ESPL="python -m esptool" + +detect_chip() { + # Try to detect actual connected chip using esptool and override if needed + out=$($ESPL --port "$PORT" --baud "$BAUD" chip_id 2>&1 || true) + case "$out" in + *"ESP32-C3"*) DETECTED_CHIP=esp32c3 ;; + *"ESP32"*) DETECTED_CHIP=esp32 ;; + *) DETECTED_CHIP="" ;; + esac + if [ -n "$DETECTED_CHIP" ] && [ "$DETECTED_CHIP" != "$ESPT_CHIP" ]; then + echo "Detected chip $DETECTED_CHIP differs from requested $ESPT_CHIP. Using detected chip." + ESPT_CHIP="$DETECTED_CHIP" + case "$ESPT_CHIP" in + esp32c3) FLASH_OFFSET=0x0 ;; + esp32) FLASH_OFFSET=0x1000 ;; + esac + fi +} + +detect_chip + +# Now that we know the actual chip, resolve the correct firmware for it +resolve_firmware + +# Validate firmware matches detected chip; if not, auto-correct by fetching the right image +EXPECTED_BOARD_ID="ESP32_GENERIC" +case "$ESPT_CHIP" in + esp32c3) EXPECTED_BOARD_ID="ESP32_GENERIC_C3" ;; + esp32) EXPECTED_BOARD_ID="ESP32_GENERIC" ;; + +esac + +FW_BASENAME="$(basename "$FIRMWARE")" +case "$FW_BASENAME" in + ${EXPECTED_BOARD_ID}-*.bin) : ;; # ok + *) + echo "Firmware $FW_BASENAME does not match detected chip ($ESPT_CHIP). Fetching correct image for $EXPECTED_BOARD_ID..." + pattern="${EXPECTED_BOARD_ID}-.*\\.bin" + if ! FW_URL=$(resolve_latest_url "$pattern"); then + echo "Failed to resolve a firmware matching $EXPECTED_BOARD_ID" >&2 + exit 1 + fi + mkdir -p .cache + FIRMWARE=".cache/$(basename "$FW_URL")" + if [ ! -f "$FIRMWARE" ]; then + echo "Downloading firmware from $FW_URL to $FIRMWARE" + curl -L --fail -o "$FIRMWARE" "$FW_URL" + else + echo "Firmware already downloaded at $FIRMWARE" + fi + ;; +esac + +$ESPL --chip "$ESPT_CHIP" --port "$PORT" --baud "$BAUD" erase_flash + +echo "Writing firmware $FIRMWARE to $FLASH_OFFSET..." +$ESPL --chip "$ESPT_CHIP" --port "$PORT" --baud "$BAUD" write_flash -z "$FLASH_OFFSET" "$FIRMWARE" + +echo "Done." + + diff --git a/src/controllers/preset.py b/src/controllers/preset.py index 059b83e..51e576f 100644 --- a/src/controllers/preset.py +++ b/src/controllers/preset.py @@ -3,7 +3,7 @@ from microdot.session import with_session from models.preset import Preset from models.profile import Profile from models.espnow import ESPNow -from util.espnow_message import build_message, build_preset_dict +from util.espnow_message import build_message, build_preset_dict, ESPNOW_MAX_PAYLOAD_BYTES import asyncio import json @@ -161,7 +161,7 @@ async def send_presets(request, session): msg = build_message(presets=chunk_presets, save=save_flag, default=default_id) await esp.send(msg) - MAX_BYTES = 240 + MAX_BYTES = ESPNOW_MAX_PAYLOAD_BYTES SEND_DELAY_MS = 100 entries = list(presets_by_name.items()) total_presets = len(entries) diff --git a/src/main.py b/src/main.py index f6d95d6..18e2e4c 100644 --- a/src/main.py +++ b/src/main.py @@ -19,6 +19,7 @@ import controllers.scene as scene import controllers.pattern as pattern import controllers.settings as settings_controller from models.espnow import ESPNow +from util.espnow_message import split_espnow_message async def main(port=80): @@ -98,9 +99,17 @@ async def main(port=80): except Exception: print("WS received raw:", data) - # Forward raw JSON payload over ESPNow to configured peers + # Forward JSON over ESPNow; split into multiple frames if > 250 bytes try: - await esp.send(data) + try: + parsed = json.loads(data) + chunks = split_espnow_message(parsed) + except (json.JSONDecodeError, ValueError): + chunks = [data] + for i, chunk in enumerate(chunks): + if i > 0: + await asyncio.sleep_ms(100) + await esp.send(chunk) except Exception: try: await ws.send(json.dumps({"error": "ESP-NOW send failed"})) diff --git a/src/util/espnow_message.py b/src/util/espnow_message.py index a377e2b..86cc09b 100644 --- a/src/util/espnow_message.py +++ b/src/util/espnow_message.py @@ -2,10 +2,15 @@ ESPNow message builder utility for LED driver communication. This module provides utilities to build ESPNow messages according to the API specification. +ESPNow has a 250-byte payload limit; messages larger than that must be split into multiple +frames. """ import json +# ESPNow payload limit (bytes). Messages larger than this must be split. +ESPNOW_MAX_PAYLOAD_BYTES = 240 + def build_message(presets=None, select=None, save=False, default=None): """ @@ -54,6 +59,82 @@ def build_message(presets=None, select=None, save=False, default=None): return json.dumps(message) +def split_espnow_message(msg_dict, max_bytes=None): + """ + Split a message dict into one or more JSON strings each within ESPNow payload limit. + If the message fits in max_bytes, returns a single-element list. Otherwise splits + "select" and/or "presets" into multiple messages (other keys like v, b, default, save + are included only in the first message). + + Args: + msg_dict: Full message as a dict (e.g. from json.loads). + max_bytes: Max payload size in bytes (default ESPNOW_MAX_PAYLOAD_BYTES). + + Returns: + List of JSON strings, each <= max_bytes, to send in order. + """ + if max_bytes is None: + max_bytes = ESPNOW_MAX_PAYLOAD_BYTES + + single = json.dumps(msg_dict) + if len(single) <= max_bytes: + return [single] + + # Keys to attach only to the first message we emit + first_only = {k: msg_dict[k] for k in ("b", "default", "save") if k in msg_dict} + out = [] + + def emit(chunk_dict, is_first): + m = {"v": msg_dict.get("v", "1")} + if is_first and first_only: + m.update(first_only) + m.update(chunk_dict) + s = json.dumps(m) + if len(s) > max_bytes: + raise ValueError(f"Chunk still too large ({len(s)} > {max_bytes})") + out.append(s) + + def chunk_dict(key, items_dict): + if not items_dict: + return + items = list(items_dict.items()) + i = 0 + first = True + while i < len(items): + chunk = {} + while i < len(items): + k, v = items[i] + trial = dict(chunk) + trial[k] = v + trial_msg = {"v": msg_dict.get("v", "1"), key: trial} + if first_only and first: + trial_msg.update(first_only) + if len(json.dumps(trial_msg)) <= max_bytes: + chunk[k] = v + i += 1 + else: + if not chunk: + # Single entry too large; send as-is and hope receiver accepts + chunk[k] = v + i += 1 + break + if chunk: + emit({key: chunk}, first) + first = False + if not chunk: + break + + if "select" in msg_dict: + chunk_dict("select", msg_dict["select"]) + if "presets" in msg_dict: + chunk_dict("presets", msg_dict["presets"]) + + if not out: + # Fallback: emit one message even if over limit (receiver may reject) + out = [single] + return out + + def build_select_message(device_name, preset_name, step=None): """ Build a select message for a single device.