This commit is contained in:
Joshua King
2026-10-02 12:23:58 -04:00
parent f0af1dbbcb
commit ad1e7e6d01
219 changed files with 488314 additions and 0 deletions
+45
View File
@@ -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()
+39
View File
@@ -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()
+109
View File
@@ -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()
+83
View File
@@ -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()