updates
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print timestamped USB logs from the camera and both eye boards."""
|
||||
|
||||
import argparse
|
||||
import select
|
||||
import time
|
||||
|
||||
import serial
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--camera")
|
||||
parser.add_argument("--eye1")
|
||||
parser.add_argument("--eye2")
|
||||
parser.add_argument("--duration", type=float, default=15)
|
||||
args = parser.parse_args()
|
||||
|
||||
paths = {name: path for name, path in
|
||||
(("camera", args.camera), ("eye1", args.eye1), ("eye2", args.eye2))
|
||||
if path}
|
||||
if not paths:
|
||||
parser.error("provide at least one of --camera, --eye1, or --eye2")
|
||||
ports = {name: serial.Serial(path, 115200, timeout=0)
|
||||
for name, path in paths.items()}
|
||||
buffers = {name: bytearray() for name in ports}
|
||||
started = time.monotonic()
|
||||
try:
|
||||
while time.monotonic() - started < args.duration:
|
||||
ready, _, _ = select.select(list(ports.values()), [], [], 0.2)
|
||||
for port in ready:
|
||||
name = next(name for name, candidate in ports.items() if candidate is port)
|
||||
buffers[name].extend(port.read(port.in_waiting or 1))
|
||||
while b"\n" in buffers[name]:
|
||||
line, _, rest = buffers[name].partition(b"\n")
|
||||
buffers[name] = bytearray(rest)
|
||||
stamp = time.monotonic() - started
|
||||
print(f"{stamp:8.3f} {name:6s} {line.decode(errors='replace').rstrip()}", flush=True)
|
||||
finally:
|
||||
for port in ports.values():
|
||||
port.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Save local Wi-Fi credentials and a random OTA key outside version control."""
|
||||
|
||||
import getpass
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
FIRMWARE = Path(__file__).resolve().parents[1]
|
||||
PRIVATE = FIRMWARE / "private"
|
||||
CONFIG = PRIVATE / "ota.json"
|
||||
HEADER = PRIVATE / "OtaSecrets.h"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
old = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
|
||||
ssid = input(f"2.4 GHz Wi-Fi name [{old.get('ssid', '')}]: ").strip() or old.get("ssid", "")
|
||||
if not ssid:
|
||||
raise SystemExit("A Wi-Fi name is required")
|
||||
password = getpass.getpass("Wi-Fi password (blank to keep saved value): ")
|
||||
password = password or old.get("password", "")
|
||||
if not password:
|
||||
raise SystemExit("A Wi-Fi password is required")
|
||||
key = old.get("ota_key") or secrets.token_hex(32)
|
||||
PRIVATE.mkdir(mode=0o700, exist_ok=True)
|
||||
CONFIG.write_text(json.dumps({"ssid": ssid, "password": password, "ota_key": key}, indent=2) + "\n")
|
||||
CONFIG.chmod(0o600)
|
||||
HEADER.write_text(
|
||||
"#pragma once\n"
|
||||
f"#define FREDDY_WIFI_SSID {json.dumps(ssid)}\n"
|
||||
f"#define FREDDY_WIFI_PASSWORD {json.dumps(password)}\n"
|
||||
f"#define FREDDY_OTA_KEY {json.dumps(key)}\n"
|
||||
)
|
||||
HEADER.chmod(0o600)
|
||||
print("Saved local configuration in firmware/private/. Rebuild and flash each device once by USB.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Encode the supplied Freddy iris as a 178 px LVGL RGB565+alpha image.
|
||||
|
||||
The source WebP stays unchanged. Run from the project root with the bundled
|
||||
Python (Pillow required): python firmware/tools/make_iris.py
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw
|
||||
import hashlib
|
||||
import math
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "reference_images/official_freddy_eye_texture.webp"
|
||||
OUTPUT = ROOT / "firmware/eye_test/FreddyIris.cpp"
|
||||
PREVIEW = ROOT / "firmware/assets/freddy_iris_preview.png"
|
||||
DISPLAY_PREVIEW = ROOT / "firmware/assets/freddy_eye_display_preview.png"
|
||||
BLINK_PREVIEW = ROOT / "firmware/assets/freddy_eye_blink_preview.png"
|
||||
RED_PREVIEW = ROOT / "firmware/assets/freddy_eye_red_preview.png"
|
||||
SIZE = 178
|
||||
|
||||
|
||||
def main() -> None:
|
||||
source = Image.open(SOURCE).convert("RGB")
|
||||
if source.size != (508, 466):
|
||||
raise ValueError(f"Unexpected source size: {source.size}")
|
||||
|
||||
# The artwork's painted disc spans roughly x=159..372, y=126..337.
|
||||
# Keep an equal square around that disc, then scale to the renderer's iris.
|
||||
iris = source.crop((159, 125, 373, 339)).resize(
|
||||
(SIZE, SIZE), Image.Resampling.LANCZOS
|
||||
)
|
||||
preview = Image.new("RGBA", (SIZE, SIZE))
|
||||
encoded = bytearray()
|
||||
radius = SIZE / 2
|
||||
|
||||
for y in range(SIZE):
|
||||
for x in range(SIZE):
|
||||
r, g, b = iris.getpixel((x, y))
|
||||
distance = math.hypot(x + 0.5 - radius, y + 0.5 - radius)
|
||||
# Two-pixel feather removes the source's white rectangular backing.
|
||||
alpha = round(max(0.0, min(1.0, (radius - distance) / 2.0)) * 255)
|
||||
preview.putpixel((x, y), (r, g, b, alpha))
|
||||
rgb565 = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)
|
||||
encoded.extend((rgb565 & 0xFF, rgb565 >> 8, alpha))
|
||||
|
||||
PREVIEW.parent.mkdir(parents=True, exist_ok=True)
|
||||
preview.save(PREVIEW)
|
||||
display = Image.new("RGBA", (480, 480), (57, 39, 29, 255))
|
||||
draw = ImageDraw.Draw(display)
|
||||
draw.ellipse((18, 18, 461, 461), fill=(116, 56, 36, 255))
|
||||
draw.ellipse((24, 24, 455, 455), fill=(245, 244, 234, 255))
|
||||
display.alpha_composite(preview, (151, 151))
|
||||
DISPLAY_PREVIEW.parent.mkdir(parents=True, exist_ok=True)
|
||||
display.save(DISPLAY_PREVIEW)
|
||||
blink = display.copy()
|
||||
blink_draw = ImageDraw.Draw(blink)
|
||||
blink_draw.rectangle((0, 0, 479, 139), fill=(116, 56, 36, 255))
|
||||
blink_draw.rectangle((0, 340, 479, 479), fill=(116, 56, 36, 255))
|
||||
blink.save(BLINK_PREVIEW)
|
||||
red = Image.new("RGBA", (480, 480), (57, 39, 29, 255))
|
||||
red_draw = ImageDraw.Draw(red)
|
||||
red_draw.ellipse((18, 18, 461, 461), fill=(116, 56, 36, 255))
|
||||
red_draw.ellipse((24, 24, 455, 455), fill=(33, 25, 27, 255))
|
||||
for diameter, color, alpha in (
|
||||
(178, (21, 9, 9), 255),
|
||||
(144, (110, 11, 8), 34),
|
||||
(116, (190, 27, 19), 55),
|
||||
(82, (226, 45, 30), 90),
|
||||
(48, (250, 65, 43), 165),
|
||||
(28, (255, 58, 43), 255),
|
||||
(13, (255, 242, 227), 255),
|
||||
):
|
||||
layer = Image.new("RGBA", red.size)
|
||||
offset = (480 - diameter) // 2
|
||||
ImageDraw.Draw(layer).ellipse(
|
||||
(offset, offset, offset + diameter - 1, offset + diameter - 1),
|
||||
fill=(*color, alpha),
|
||||
)
|
||||
red = Image.alpha_composite(red, layer)
|
||||
red.save(RED_PREVIEW)
|
||||
lines = [
|
||||
"// Generated from reference_images/official_freddy_eye_texture.webp.",
|
||||
f"// Source SHA-256: {hashlib.sha256(SOURCE.read_bytes()).hexdigest()}",
|
||||
"// Regenerate with firmware/tools/make_iris.py; do not edit this array.",
|
||||
'#include "FreddyIris.h"',
|
||||
"",
|
||||
"static const uint8_t kFreddyIrisPixels[] = {",
|
||||
]
|
||||
for offset in range(0, len(encoded), 18):
|
||||
lines.append(
|
||||
" " + ", ".join(f"0x{byte:02x}" for byte in encoded[offset : offset + 18]) + ","
|
||||
)
|
||||
lines += [
|
||||
"};",
|
||||
"",
|
||||
"const lv_img_dsc_t kFreddyIris = {",
|
||||
" .header = {.cf = LV_IMG_CF_TRUE_COLOR_ALPHA, .always_zero = 0,",
|
||||
f" .reserved = 0, .w = {SIZE}, .h = {SIZE}}},",
|
||||
" .data_size = sizeof(kFreddyIrisPixels),",
|
||||
" .data = kFreddyIrisPixels,",
|
||||
"};",
|
||||
]
|
||||
OUTPUT.write_text("\n".join(lines) + "\n")
|
||||
print(f"Wrote {OUTPUT} ({len(encoded):,} image bytes) and previews in {PREVIEW.parent}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Push a built application image to one Freddy ESP32 on the local Wi-Fi."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG = Path(__file__).resolve().parents[1] / "private" / "ota.json"
|
||||
TARGETS = ("eye-1", "eye-2", "camera")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--host", required=True, help="Device IP address on the home Wi-Fi")
|
||||
parser.add_argument("--target", required=True, choices=TARGETS)
|
||||
parser.add_argument("--bin", required=True, type=Path, dest="image_path")
|
||||
parser.add_argument("--port", type=int, default=3232)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not CONFIG.exists():
|
||||
raise SystemExit("Run firmware/tools/configure_ota.py first")
|
||||
key = json.loads(CONFIG.read_text())["ota_key"].encode()
|
||||
image = args.image_path.read_bytes()
|
||||
marker = f"FREDDY_OTA_TARGET:{args.target}".encode()
|
||||
if marker not in image:
|
||||
raise SystemExit(f"Image does not contain the expected {marker.decode()} marker")
|
||||
|
||||
base = f"http://{args.host}:{args.port}"
|
||||
with urllib.request.urlopen(base + "/ota/info", timeout=10) as response:
|
||||
info = json.load(response)
|
||||
if info.get("device") != args.target:
|
||||
raise SystemExit(f"Device reports {info.get('device')!r}, expected {args.target!r}")
|
||||
if not 0 < len(image) <= info.get("slot_bytes", 0):
|
||||
raise SystemExit(f"Image is {len(image)} bytes; device slot is {info.get('slot_bytes')} bytes")
|
||||
|
||||
digest = hashlib.sha256(image).hexdigest()
|
||||
nonce = info["nonce"]
|
||||
message = f"{args.target}\n{nonce}\n{digest}\n{len(image)}".encode()
|
||||
signature = hmac.new(key, message, hashlib.sha256).hexdigest()
|
||||
request = urllib.request.Request(
|
||||
base + "/ota",
|
||||
data=image,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-Freddy-Nonce": nonce,
|
||||
"X-Freddy-SHA256": digest,
|
||||
"X-Freddy-Signature": signature,
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=180) as response:
|
||||
result = json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
raise SystemExit(f"Update rejected (HTTP {error.code}): {error.read().decode(errors='replace')}") from error
|
||||
if result != {"ok": True}:
|
||||
raise SystemExit(f"Unexpected reply: {result!r}")
|
||||
print(f"{args.target}: image accepted ({len(image)} bytes, SHA-256 {digest}). Waiting for reboot…")
|
||||
old_slot = info["running_slot"]
|
||||
deadline = time.monotonic() + 60
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(2)
|
||||
try:
|
||||
with urllib.request.urlopen(base + "/ota/info", timeout=3) as response:
|
||||
current = json.load(response)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if current.get("device") == args.target and current.get("running_slot") != old_slot:
|
||||
print(f"{args.target}: back online in firmware slot {current['running_slot']}.")
|
||||
return
|
||||
raise SystemExit(
|
||||
"Image was accepted, but the new slot was not confirmed online within 60 seconds. "
|
||||
"Check device power and USB logs before retrying."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user