Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2077c0199 |
BIN
build_static/app.js.gz
Normal file
BIN
build_static/app.js.gz
Normal file
Binary file not shown.
37
build_static/styles.css
Normal file
37
build_static/styles.css
Normal file
@@ -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;
|
||||
}
|
||||
BIN
build_static/styles.css.gz
Normal file
BIN
build_static/styles.css.gz
Normal file
Binary file not shown.
@@ -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": [
|
||||
[
|
||||
|
||||
244
flash.sh
Executable file
244
flash.sh
Executable file
@@ -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."
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
13
src/main.py
13
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"}))
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user