Compare commits
4 Commits
0e96223bf6
...
v1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b2077c0199 | |||
| 0fdc11c0b0 | |||
| 91bd78ab31 | |||
| 2be0640622 |
2
Pipfile
2
Pipfile
@@ -21,4 +21,4 @@ python_version = "3.12"
|
|||||||
[scripts]
|
[scripts]
|
||||||
web = "python /home/pi/led-controller/tests/web.py"
|
web = "python /home/pi/led-controller/tests/web.py"
|
||||||
watch = "python -m watchfiles 'python tests/web.py' src tests"
|
watch = "python -m watchfiles 'python tests/web.py' src tests"
|
||||||
install = "pipenv install"
|
install = "pipenv install"
|
||||||
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": {
|
"1": {
|
||||||
"name": "default",
|
"name": "default",
|
||||||
"names": [
|
"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": [
|
"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."
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +128,11 @@ async def run_local():
|
|||||||
"""Serve the settings page."""
|
"""Serve the settings page."""
|
||||||
return send_file('src/templates/settings.html')
|
return send_file('src/templates/settings.html')
|
||||||
|
|
||||||
|
# Favicon: avoid 404 in browser console (no file needed)
|
||||||
|
@app.route('/favicon.ico')
|
||||||
|
def favicon(request):
|
||||||
|
return '', 204
|
||||||
|
|
||||||
# Static file route
|
# Static file route
|
||||||
@app.route("/static/<path:path>")
|
@app.route("/static/<path:path>")
|
||||||
def static_handler(request, path):
|
def static_handler(request, path):
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from microdot.session import with_session
|
|||||||
from models.preset import Preset
|
from models.preset import Preset
|
||||||
from models.profile import Profile
|
from models.profile import Profile
|
||||||
from models.espnow import ESPNow
|
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 asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ async def send_presets(request, session):
|
|||||||
msg = build_message(presets=chunk_presets, save=save_flag, default=default_id)
|
msg = build_message(presets=chunk_presets, save=save_flag, default=default_id)
|
||||||
await esp.send(msg)
|
await esp.send(msg)
|
||||||
|
|
||||||
MAX_BYTES = 240
|
MAX_BYTES = ESPNOW_MAX_PAYLOAD_BYTES
|
||||||
SEND_DELAY_MS = 100
|
SEND_DELAY_MS = 100
|
||||||
entries = list(presets_by_name.items())
|
entries = list(presets_by_name.items())
|
||||||
total_presets = len(entries)
|
total_presets = len(entries)
|
||||||
@@ -179,14 +179,20 @@ async def send_presets(request, session):
|
|||||||
batch = test_batch
|
batch = test_batch
|
||||||
last_msg = test_msg
|
last_msg = test_msg
|
||||||
else:
|
else:
|
||||||
await send_chunk(batch)
|
try:
|
||||||
|
await send_chunk(batch)
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
|
||||||
await asyncio.sleep_ms(SEND_DELAY_MS)
|
await asyncio.sleep_ms(SEND_DELAY_MS)
|
||||||
messages_sent += 1
|
messages_sent += 1
|
||||||
batch = {name: preset_obj}
|
batch = {name: preset_obj}
|
||||||
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
|
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
|
||||||
|
|
||||||
if batch:
|
if batch:
|
||||||
await send_chunk(batch)
|
try:
|
||||||
|
await send_chunk(batch)
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
|
||||||
await asyncio.sleep_ms(SEND_DELAY_MS)
|
await asyncio.sleep_ms(SEND_DELAY_MS)
|
||||||
messages_sent += 1
|
messages_sent += 1
|
||||||
|
|
||||||
|
|||||||
@@ -13,51 +13,6 @@ async def get_settings(request):
|
|||||||
# trigger MicroPython's "dict update sequence has wrong length" quirk.
|
# trigger MicroPython's "dict update sequence has wrong length" quirk.
|
||||||
return json.dumps(settings), 200, {'Content-Type': 'application/json'}
|
return json.dumps(settings), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
@controller.get('/wifi/station')
|
|
||||||
async def get_station_status(request):
|
|
||||||
"""Get WiFi station connection status."""
|
|
||||||
status = wifi.get_sta_status()
|
|
||||||
if status:
|
|
||||||
return json.dumps(status), 200, {'Content-Type': 'application/json'}
|
|
||||||
return json.dumps({"error": "Failed to get station status"}), 500
|
|
||||||
|
|
||||||
@controller.post('/wifi/station')
|
|
||||||
async def connect_station(request):
|
|
||||||
"""Connect to WiFi station with credentials."""
|
|
||||||
try:
|
|
||||||
data = request.json
|
|
||||||
ssid = data.get('ssid')
|
|
||||||
password = data.get('password', '')
|
|
||||||
ip = data.get('ip')
|
|
||||||
gateway = data.get('gateway')
|
|
||||||
|
|
||||||
if not ssid:
|
|
||||||
return json.dumps({"error": "SSID is required"}), 400
|
|
||||||
|
|
||||||
# Save credentials to settings
|
|
||||||
settings['wifi_station_ssid'] = ssid
|
|
||||||
settings['wifi_station_password'] = password
|
|
||||||
if ip:
|
|
||||||
settings['wifi_station_ip'] = ip
|
|
||||||
if gateway:
|
|
||||||
settings['wifi_station_gateway'] = gateway
|
|
||||||
settings.save()
|
|
||||||
|
|
||||||
# Attempt connection
|
|
||||||
result = wifi.connect(ssid, password, ip, gateway)
|
|
||||||
if result:
|
|
||||||
return json.dumps({
|
|
||||||
"message": "Connected successfully",
|
|
||||||
"ip": result[0],
|
|
||||||
"netmask": result[1],
|
|
||||||
"gateway": result[2],
|
|
||||||
"dns": result[3] if len(result) > 3 else None
|
|
||||||
}), 200, {'Content-Type': 'application/json'}
|
|
||||||
else:
|
|
||||||
return json.dumps({"error": "Failed to connect"}), 400
|
|
||||||
except Exception as e:
|
|
||||||
return json.dumps({"error": str(e)}), 500
|
|
||||||
|
|
||||||
@controller.get('/wifi/ap')
|
@controller.get('/wifi/ap')
|
||||||
async def get_ap_config(request):
|
async def get_ap_config(request):
|
||||||
"""Get Access Point configuration."""
|
"""Get Access Point configuration."""
|
||||||
@@ -106,15 +61,6 @@ async def configure_ap(request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 500
|
return json.dumps({"error": str(e)}), 500
|
||||||
|
|
||||||
@controller.get('/wifi/station/credentials')
|
|
||||||
async def get_station_credentials(request):
|
|
||||||
"""Get saved WiFi station credentials (without password)."""
|
|
||||||
return json.dumps({
|
|
||||||
"ssid": settings.get('wifi_station_ssid', ''),
|
|
||||||
"ip": settings.get('wifi_station_ip', ''),
|
|
||||||
"gateway": settings.get('wifi_station_gateway', '')
|
|
||||||
}), 200, {'Content-Type': 'application/json'}
|
|
||||||
|
|
||||||
@controller.put('/settings')
|
@controller.put('/settings')
|
||||||
async def update_settings(request):
|
async def update_settings(request):
|
||||||
"""Update general settings."""
|
"""Update general settings."""
|
||||||
|
|||||||
23
src/main.py
23
src/main.py
@@ -9,7 +9,6 @@ from microdot.session import Session
|
|||||||
from settings import Settings
|
from settings import Settings
|
||||||
|
|
||||||
import aioespnow
|
import aioespnow
|
||||||
import network
|
|
||||||
import controllers.preset as preset
|
import controllers.preset as preset
|
||||||
import controllers.profile as profile
|
import controllers.profile as profile
|
||||||
import controllers.group as group
|
import controllers.group as group
|
||||||
@@ -20,6 +19,7 @@ import controllers.scene as scene
|
|||||||
import controllers.pattern as pattern
|
import controllers.pattern as pattern
|
||||||
import controllers.settings as settings_controller
|
import controllers.settings as settings_controller
|
||||||
from models.espnow import ESPNow
|
from models.espnow import ESPNow
|
||||||
|
from util.espnow_message import split_espnow_message
|
||||||
|
|
||||||
|
|
||||||
async def main(port=80):
|
async def main(port=80):
|
||||||
@@ -27,9 +27,6 @@ async def main(port=80):
|
|||||||
print(settings)
|
print(settings)
|
||||||
print("Starting")
|
print("Starting")
|
||||||
|
|
||||||
sta = network.WLAN(network.STA_IF)
|
|
||||||
sta.active(True)
|
|
||||||
|
|
||||||
# Initialize ESPNow singleton (config + peers)
|
# Initialize ESPNow singleton (config + peers)
|
||||||
esp = ESPNow()
|
esp = ESPNow()
|
||||||
|
|
||||||
@@ -102,8 +99,22 @@ async def main(port=80):
|
|||||||
except Exception:
|
except Exception:
|
||||||
print("WS received raw:", data)
|
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
|
||||||
await esp.send(data)
|
try:
|
||||||
|
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"}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import network
|
||||||
|
|
||||||
import aioespnow
|
import aioespnow
|
||||||
|
|
||||||
|
|
||||||
@@ -20,11 +22,17 @@ class ESPNow:
|
|||||||
if getattr(self, "_initialized", False):
|
if getattr(self, "_initialized", False):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Initialize ESPNow once (no disk persistence)
|
# ESP-NOW requires a WiFi interface to be active (STA or AP). Activate STA
|
||||||
|
# so ESP-NOW has an interface to use; we don't need to connect to an AP.
|
||||||
|
try:
|
||||||
|
sta = network.WLAN(network.STA_IF)
|
||||||
|
sta.active(True)
|
||||||
|
except Exception as e:
|
||||||
|
print("ESPNow: STA active failed:", e)
|
||||||
|
|
||||||
self._esp = aioespnow.AIOESPNow()
|
self._esp = aioespnow.AIOESPNow()
|
||||||
self._esp.active(True)
|
self._esp.active(True)
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._esp.add_peer(b"\xff\xff\xff\xff\xff\xff")
|
self._esp.add_peer(b"\xff\xff\xff\xff\xff\xff")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -56,6 +64,6 @@ class ESPNow:
|
|||||||
try:
|
try:
|
||||||
await self._esp.asend(b"\xff\xff\xff\xff\xff\xff", payload)
|
await self._esp.asend(b"\xff\xff\xff\xff\xff\xff", payload)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log send failures but don't crash the app
|
|
||||||
print("ESPNow.send error:", e)
|
print("ESPNow.send error:", e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|||||||
@@ -80,44 +80,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStationStatus() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/settings/wifi/station');
|
|
||||||
const status = await response.json();
|
|
||||||
const statusEl = document.getElementById('station-status');
|
|
||||||
if (!statusEl) return;
|
|
||||||
if (status.connected) {
|
|
||||||
statusEl.innerHTML = `
|
|
||||||
<h4>Connection Status: <span class="status-connected">Connected</span></h4>
|
|
||||||
<p><strong>SSID:</strong> ${status.ssid || 'N/A'}</p>
|
|
||||||
<p><strong>IP Address:</strong> ${status.ip || 'N/A'}</p>
|
|
||||||
<p><strong>Gateway:</strong> ${status.gateway || 'N/A'}</p>
|
|
||||||
<p><strong>Netmask:</strong> ${status.netmask || 'N/A'}</p>
|
|
||||||
<p><strong>DNS:</strong> ${status.dns || 'N/A'}</p>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
statusEl.innerHTML = `
|
|
||||||
<h4>Connection Status: <span class="status-disconnected">Disconnected</span></h4>
|
|
||||||
<p>Not connected to any WiFi network</p>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading station status:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStationCredentials() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/settings/wifi/station/credentials');
|
|
||||||
const creds = await response.json();
|
|
||||||
if (creds.ssid) document.getElementById('station-ssid').value = creds.ssid;
|
|
||||||
if (creds.ip) document.getElementById('station-ip').value = creds.ip;
|
|
||||||
if (creds.gateway) document.getElementById('station-gateway').value = creds.gateway;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading station credentials:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAPStatus() {
|
async function loadAPStatus() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/settings/wifi/ap');
|
const response = await fetch('/settings/wifi/ap');
|
||||||
@@ -149,8 +111,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
settingsModal.classList.add('active');
|
settingsModal.classList.add('active');
|
||||||
// Load current WiFi status/config when opening
|
// Load current WiFi status/config when opening
|
||||||
loadDeviceSettings();
|
loadDeviceSettings();
|
||||||
loadStationStatus();
|
|
||||||
loadStationCredentials();
|
|
||||||
loadAPStatus();
|
loadAPStatus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -169,45 +129,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const stationForm = document.getElementById('station-form');
|
|
||||||
if (stationForm) {
|
|
||||||
stationForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const ssid = (document.getElementById('station-ssid').value || '').trim();
|
|
||||||
if (!ssid) {
|
|
||||||
showSettingsMessage('SSID is required', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const formData = {
|
|
||||||
ssid,
|
|
||||||
password: document.getElementById('station-password').value || '',
|
|
||||||
ip: (document.getElementById('station-ip').value || '').trim() || null,
|
|
||||||
gateway: (document.getElementById('station-gateway').value || '').trim() || null,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const response = await fetch('/settings/wifi/station', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(formData),
|
|
||||||
});
|
|
||||||
let result = {};
|
|
||||||
try {
|
|
||||||
result = await response.json();
|
|
||||||
} catch (_) {
|
|
||||||
result = { error: response.status === 400 ? 'Bad request (check SSID and connection)' : 'Request failed' };
|
|
||||||
}
|
|
||||||
if (response.ok) {
|
|
||||||
showSettingsMessage('WiFi station connected successfully!', 'success');
|
|
||||||
setTimeout(loadStationStatus, 1000);
|
|
||||||
} else {
|
|
||||||
showSettingsMessage(`Error: ${result.error || 'Failed to connect'}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showSettingsMessage(`Error: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const deviceForm = document.getElementById('device-form');
|
const deviceForm = document.getElementById('device-form');
|
||||||
if (deviceForm) {
|
if (deviceForm) {
|
||||||
deviceForm.addEventListener('submit', async (e) => {
|
deviceForm.addEventListener('submit', async (e) => {
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ const getEspnowSocket = () => {
|
|||||||
espnowPendingMessages = [];
|
espnowPendingMessages = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
espnowSocket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data && data.error) {
|
||||||
|
console.error('ESP-NOW:', data.error);
|
||||||
|
alert('ESP-NOW send failed. ' + (data.error === 'ESP-NOW send failed' ? 'Check device WiFi/interface.' : data.error));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore non-JSON or non-error messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
espnowSocket.onclose = () => {
|
espnowSocket.onclose = () => {
|
||||||
espnowSocketReady = false;
|
espnowSocketReady = false;
|
||||||
espnowSocket = null;
|
espnowSocket = null;
|
||||||
|
|||||||
@@ -794,9 +794,7 @@ header h1 {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
} header h1 {
|
||||||
|
|
||||||
header h1 {
|
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
} /* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */
|
} /* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */
|
||||||
.header-actions {
|
.header-actions {
|
||||||
@@ -1047,9 +1045,7 @@ header h1 {
|
|||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}#settings-modal .modal-content > p.muted-text {
|
||||||
|
|
||||||
#settings-modal .modal-content > p.muted-text {
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}#settings-modal .settings-section.ap-settings-section {
|
}#settings-modal .settings-section.ap-settings-section {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
|
|||||||
@@ -250,7 +250,7 @@
|
|||||||
<div id="settings-modal" class="modal">
|
<div id="settings-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2>Device Settings</h2>
|
<h2>Device Settings</h2>
|
||||||
<p class="muted-text">Configure WiFi and device settings.</p>
|
<p class="muted-text">Configure WiFi Access Point and device settings.</p>
|
||||||
|
|
||||||
<div id="settings-message" class="message"></div>
|
<div id="settings-message" class="message"></div>
|
||||||
|
|
||||||
@@ -269,48 +269,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- WiFi Station Settings -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>WiFi Station (Client)</h3>
|
|
||||||
|
|
||||||
<div id="station-status" class="status-info">
|
|
||||||
<h4>Connection Status</h4>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="station-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="station-ssid">SSID (Network Name)</label>
|
|
||||||
<input type="text" id="station-ssid" name="ssid" placeholder="Enter WiFi network name" required>
|
|
||||||
<small>The name of the WiFi network to connect to</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="station-password">Password</label>
|
|
||||||
<input type="password" id="station-password" name="password" placeholder="Enter WiFi password">
|
|
||||||
<small>Leave empty for open networks</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="station-ip">IP Address (Optional)</label>
|
|
||||||
<input type="text" id="station-ip" name="ip" placeholder="192.168.1.100">
|
|
||||||
<small>Static IP address (leave empty for DHCP)</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="station-gateway">Gateway (Optional)</label>
|
|
||||||
<input type="text" id="station-gateway" name="gateway" placeholder="192.168.1.1">
|
|
||||||
<small>Gateway/router IP address</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group">
|
|
||||||
<button type="submit" class="btn btn-primary btn-full">Connect</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WiFi Access Point Settings -->
|
<!-- WiFi Access Point Settings -->
|
||||||
<div class="settings-section ap-settings-section">
|
<div class="settings-section ap-settings-section">
|
||||||
<h3>WiFi Access Point</h3>
|
<h3>WiFi Access Point</h3>
|
||||||
|
|||||||
@@ -170,53 +170,11 @@
|
|||||||
|
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Device Settings</h1>
|
<h1>Device Settings</h1>
|
||||||
<p>Configure WiFi and device settings</p>
|
<p>Configure WiFi Access Point settings</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="message" class="message"></div>
|
<div id="message" class="message"></div>
|
||||||
|
|
||||||
<!-- WiFi Station Settings -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h2>WiFi Station (Client) Settings</h2>
|
|
||||||
|
|
||||||
<div id="station-status" class="status-info">
|
|
||||||
<h3>Connection Status</h3>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="station-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="station-ssid">SSID (Network Name)</label>
|
|
||||||
<input type="text" id="station-ssid" name="ssid" placeholder="Enter WiFi network name" required>
|
|
||||||
<small>The name of the WiFi network to connect to</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="station-password">Password</label>
|
|
||||||
<input type="password" id="station-password" name="password" placeholder="Enter WiFi password">
|
|
||||||
<small>Leave empty for open networks</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="station-ip">IP Address (Optional)</label>
|
|
||||||
<input type="text" id="station-ip" name="ip" placeholder="192.168.1.100">
|
|
||||||
<small>Static IP address (leave empty for DHCP)</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="station-gateway">Gateway (Optional)</label>
|
|
||||||
<input type="text" id="station-gateway" name="gateway" placeholder="192.168.1.1">
|
|
||||||
<small>Gateway/router IP address</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group">
|
|
||||||
<button type="submit" class="btn btn-primary btn-full">Connect</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WiFi Access Point Settings -->
|
<!-- WiFi Access Point Settings -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h2>WiFi Access Point Settings</h2>
|
<h2>WiFi Access Point Settings</h2>
|
||||||
@@ -264,47 +222,6 @@
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load station status
|
|
||||||
async function loadStationStatus() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/settings/wifi/station');
|
|
||||||
const status = await response.json();
|
|
||||||
|
|
||||||
const statusEl = document.getElementById('station-status');
|
|
||||||
if (status.connected) {
|
|
||||||
statusEl.innerHTML = `
|
|
||||||
<h3>Connection Status: <span class="status-connected">Connected</span></h3>
|
|
||||||
<p><strong>SSID:</strong> ${status.ssid || 'N/A'}</p>
|
|
||||||
<p><strong>IP Address:</strong> ${status.ip || 'N/A'}</p>
|
|
||||||
<p><strong>Gateway:</strong> ${status.gateway || 'N/A'}</p>
|
|
||||||
<p><strong>Netmask:</strong> ${status.netmask || 'N/A'}</p>
|
|
||||||
<p><strong>DNS:</strong> ${status.dns || 'N/A'}</p>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
statusEl.innerHTML = `
|
|
||||||
<h3>Connection Status: <span class="status-disconnected">Disconnected</span></h3>
|
|
||||||
<p>Not connected to any WiFi network</p>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading station status:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load saved station credentials
|
|
||||||
async function loadStationCredentials() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/settings/wifi/station/credentials');
|
|
||||||
const creds = await response.json();
|
|
||||||
|
|
||||||
if (creds.ssid) document.getElementById('station-ssid').value = creds.ssid;
|
|
||||||
if (creds.ip) document.getElementById('station-ip').value = creds.ip;
|
|
||||||
if (creds.gateway) document.getElementById('station-gateway').value = creds.gateway;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading station credentials:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load AP status and config
|
// Load AP status and config
|
||||||
async function loadAPStatus() {
|
async function loadAPStatus() {
|
||||||
try {
|
try {
|
||||||
@@ -334,39 +251,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Station form submission
|
|
||||||
document.getElementById('station-form').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const formData = {
|
|
||||||
ssid: document.getElementById('station-ssid').value,
|
|
||||||
password: document.getElementById('station-password').value,
|
|
||||||
ip: document.getElementById('station-ip').value || null,
|
|
||||||
gateway: document.getElementById('station-gateway').value || null
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/settings/wifi/station', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(formData)
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showMessage('WiFi station connected successfully!', 'success');
|
|
||||||
setTimeout(loadStationStatus, 1000);
|
|
||||||
} else {
|
|
||||||
showMessage(`Error: ${result.error || 'Failed to connect'}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Error: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// AP form submission
|
// AP form submission
|
||||||
document.getElementById('ap-form').addEventListener('submit', async (e) => {
|
document.getElementById('ap-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -415,15 +299,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load all data on page load
|
// Load all data on page load
|
||||||
loadStationStatus();
|
|
||||||
loadStationCredentials();
|
|
||||||
loadAPStatus();
|
loadAPStatus();
|
||||||
|
|
||||||
// Refresh status every 10 seconds
|
// Refresh status every 10 seconds
|
||||||
setInterval(() => {
|
setInterval(loadAPStatus, 10000);
|
||||||
loadStationStatus();
|
|
||||||
loadAPStatus();
|
|
||||||
}, 10000);
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
ESPNow message builder utility for LED driver communication.
|
ESPNow message builder utility for LED driver communication.
|
||||||
|
|
||||||
This module provides utilities to build ESPNow messages according to the API specification.
|
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
|
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):
|
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)
|
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):
|
def build_select_message(device_name, preset_name, step=None):
|
||||||
"""
|
"""
|
||||||
Build a select message for a single device.
|
Build a select message for a single device.
|
||||||
|
|||||||
@@ -1,28 +1,4 @@
|
|||||||
import network
|
import network
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
def connect(ssid, password, ip, gateway):
|
|
||||||
if ssid is None:
|
|
||||||
print("Missing ssid")
|
|
||||||
return None
|
|
||||||
if password is None:
|
|
||||||
password = ''
|
|
||||||
try:
|
|
||||||
sta_if = network.WLAN(network.STA_IF)
|
|
||||||
if ip is not None and gateway is not None:
|
|
||||||
sta_if.ifconfig((ip, '255.255.255.0', gateway, '1.1.1.1'))
|
|
||||||
if not sta_if.isconnected():
|
|
||||||
print('connecting to network...')
|
|
||||||
sta_if.active(True)
|
|
||||||
sta_if.connect(ssid, password)
|
|
||||||
sleep(0.1)
|
|
||||||
if sta_if.isconnected():
|
|
||||||
return sta_if.ifconfig()
|
|
||||||
return None
|
|
||||||
return sta_if.ifconfig()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to connect to wifi {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def ap(ssid, password, channel=None):
|
def ap(ssid, password, channel=None):
|
||||||
@@ -42,6 +18,7 @@ def get_mac():
|
|||||||
ap_if = network.WLAN(network.AP_IF)
|
ap_if = network.WLAN(network.AP_IF)
|
||||||
return ap_if.config('mac')
|
return ap_if.config('mac')
|
||||||
|
|
||||||
|
|
||||||
def get_ap_config():
|
def get_ap_config():
|
||||||
"""Get current AP configuration."""
|
"""Get current AP configuration."""
|
||||||
try:
|
try:
|
||||||
@@ -63,38 +40,3 @@ def get_ap_config():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting AP config: {e}")
|
print(f"Error getting AP config: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_sta_status():
|
|
||||||
"""Get current station connection status."""
|
|
||||||
try:
|
|
||||||
sta_if = network.WLAN(network.STA_IF)
|
|
||||||
if sta_if.active():
|
|
||||||
if sta_if.isconnected():
|
|
||||||
config = sta_if.ifconfig()
|
|
||||||
return {
|
|
||||||
'connected': True,
|
|
||||||
'ssid': sta_if.config('essid'),
|
|
||||||
'ip': config[0] if config else None,
|
|
||||||
'gateway': config[2] if len(config) > 2 else None,
|
|
||||||
'netmask': config[1] if len(config) > 1 else None,
|
|
||||||
'dns': config[3] if len(config) > 3 else None
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
'connected': False,
|
|
||||||
'ssid': None,
|
|
||||||
'ip': None,
|
|
||||||
'gateway': None,
|
|
||||||
'netmask': None,
|
|
||||||
'dns': None
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
'connected': False,
|
|
||||||
'ssid': None,
|
|
||||||
'ip': None,
|
|
||||||
'gateway': None,
|
|
||||||
'netmask': None,
|
|
||||||
'dns': None
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error getting STA status: {e}")
|
|
||||||
return None
|
|
||||||
|
|||||||
Reference in New Issue
Block a user