Add multi-panel Pico UDP firmware and Python LED control.

Pico panels get static IPs on 10.1.1.10–14 with per-panel LED counts, Makefile deploy targets, and Python examples for animations, sync tests, and direct Pi SPI control.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-28 22:46:08 +12:00
parent d8323bb9a3
commit d5cab2efdf
52 changed files with 4677 additions and 1 deletions

116
scripts/panel_deploy.sh Executable file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Build and upload portal panel firmware over USB.
#
# Usage:
# ./scripts/panel_deploy.sh
# ./scripts/panel_deploy.sh --build-only
# ./scripts/panel_deploy.sh --upload-only
#
# Options:
# --build-only Build, do not upload
# --upload-only Upload existing build, skip compile
# --panel-id N Pass -DPANEL_ID_BUILD=N to cmake
# --no-dhcp Pass -DPORTAL_USE_DHCP=OFF to cmake
# --num-leds N Pass -DNUM_LEDS_BUILD=N to cmake
#
# Environment:
# PICO_SDK_PATH Path to pico-sdk (default: ~/pico/pico-sdk)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="${ROOT}/firmware/panel/build"
PICO_SDK_PATH="${PICO_SDK_PATH:-${HOME}/pico/pico-sdk}"
JOBS="$(nproc)"
DO_BUILD=1
DO_UPLOAD=1
CMAKE_EXTRA=()
die() {
echo "Error: $*" >&2
exit 1
}
usage() {
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
while (($# > 0)); do
case "$1" in
--build-only)
DO_UPLOAD=0
shift
;;
--upload-only)
DO_BUILD=0
shift
;;
--panel-id)
[[ $# -ge 2 ]] || die "--panel-id requires a number"
CMAKE_EXTRA+=("-DPANEL_ID_BUILD=${2}")
shift 2
;;
--no-dhcp)
CMAKE_EXTRA+=("-DPORTAL_USE_DHCP=OFF")
shift
;;
--num-leds)
[[ $# -ge 2 ]] || die "--num-leds requires a number"
CMAKE_EXTRA+=("-DNUM_LEDS_BUILD=${2}")
shift 2
;;
-h|--help)
usage 0
;;
*)
die "unknown option: $1 (try --help)"
;;
esac
done
build_firmware() {
[[ -d "${PICO_SDK_PATH}" ]] || die "pico-sdk not found at ${PICO_SDK_PATH} (set PICO_SDK_PATH)"
command -v cmake >/dev/null || die "cmake is required"
command -v make >/dev/null || die "make is required"
export PICO_SDK_PATH
mkdir -p "${BUILD_DIR}"
echo "==> Building panel firmware in ${BUILD_DIR}"
if [[ ! -f "${BUILD_DIR}/CMakeCache.txt" ]]; then
cmake -S "${ROOT}/firmware/panel" -B "${BUILD_DIR}" "${CMAKE_EXTRA[@]}"
elif ((${#CMAKE_EXTRA[@]} > 0)); then
cmake -S "${ROOT}/firmware/panel" -B "${BUILD_DIR}" "${CMAKE_EXTRA[@]}"
fi
cmake --build "${BUILD_DIR}" -j "${JOBS}"
[[ -f "${BUILD_DIR}/panel.uf2" ]] || die "build did not produce panel.uf2"
echo "==> Build OK: ${BUILD_DIR}/panel.uf2"
}
upload_usb() {
command -v picotool >/dev/null || die "picotool not found (install from https://github.com/raspberrypi/picotool)"
if picotool version 2>&1 | grep -q 'without USB support'; then
die "picotool was built without USB support; rebuild with libusb-1.0-0-dev installed"
fi
echo "==> Uploading over USB"
if ! picotool load -x -f "${BUILD_DIR}/panel.uf2" 2>&1 | tee /tmp/picotool_load.log; then
die "picotool load failed"
fi
if ! grep -q 'Loading into Flash' /tmp/picotool_load.log; then
die "picotool did not flash (hold BOOTSEL, plug USB, retry)"
fi
echo "==> USB upload OK"
}
if ((DO_BUILD)); then
build_firmware
fi
if ((DO_UPLOAD)); then
[[ -f "${BUILD_DIR}/panel.uf2" ]] || die "panel.uf2 not found; run without --upload-only first"
upload_usb
fi

117
scripts/setup_ap.sh Executable file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Set up a WiFi access point on wlan0 and share internet from ethernet (eth0).
#
# Usage:
# sudo ./scripts/setup_ap.sh
# sudo PORTAL_AP_SSID=mywifi PORTAL_AP_PASSWORD=secret ./scripts/setup_ap.sh
#
# Environment:
# PORTAL_AP_SSID WiFi name (default: portal)
# PORTAL_AP_PASSWORD WPA password, 8+ chars (default: portal1234)
# PORTAL_AP_CON_NAME NetworkManager connection name (default: portal-ap)
# PORTAL_AP_IF AP interface (default: wlan0)
# PORTAL_WAN_IF Uplink interface (default: eth0)
set -euo pipefail
SSID="${PORTAL_AP_SSID:-portal}"
PASSWORD="${PORTAL_AP_PASSWORD:-portal1234}"
CON_NAME="${PORTAL_AP_CON_NAME:-portal-ap}"
AP_IF="${PORTAL_AP_IF:-wlan0}"
WAN_IF="${PORTAL_WAN_IF:-eth0}"
die() {
echo "Error: $*" >&2
exit 1
}
if [[ "${EUID}" -ne 0 ]]; then
die "run as root: sudo $0"
fi
if ((${#PASSWORD} < 8)); then
die "PORTAL_AP_PASSWORD must be at least 8 characters"
fi
if ! command -v nmcli >/dev/null; then
die "NetworkManager (nmcli) is required"
fi
if ! systemctl is-active --quiet NetworkManager; then
die "NetworkManager is not running"
fi
if ! ip link show "${WAN_IF}" >/dev/null 2>&1; then
die "uplink interface ${WAN_IF} not found"
fi
if ! ip link show "${AP_IF}" >/dev/null 2>&1; then
die "AP interface ${AP_IF} not found"
fi
if ! ip link show "${WAN_IF}" | grep -q "state UP"; then
echo "Warning: ${WAN_IF} is not UP — connect ethernet before clients can reach the internet." >&2
fi
# NM shared AP mode uses hostapd on many Pi images.
if ! command -v hostapd >/dev/null; then
echo "Installing hostapd..."
apt-get update -qq
apt-get install -y hostapd
fi
# Allow forwarding between AP clients and ethernet.
sysctl -w net.ipv4.ip_forward=1 >/dev/null
cat >/etc/sysctl.d/99-portal-ap.conf <<'EOF'
# portal AP: share ethernet to WiFi clients
net.ipv4.ip_forward=1
EOF
rfkill unblock wifi >/dev/null 2>&1 || true
nmcli radio wifi on >/dev/null 2>&1 || true
if nmcli -t -f NAME connection show | grep -Fxq "${CON_NAME}"; then
echo "Updating existing connection '${CON_NAME}'..."
nmcli connection delete "${CON_NAME}" >/dev/null
fi
echo "Creating access point '${SSID}' on ${AP_IF}..."
nmcli connection add type wifi ifname "${AP_IF}" con-name "${CON_NAME}" \
autoconnect yes \
ssid "${SSID}" \
802-11-wireless.mode ap \
802-11-wireless.band bg \
ipv4.method shared \
ipv6.method ignore \
wifi-sec.key-mgmt wpa-psk \
wifi-sec.psk "${PASSWORD}" >/dev/null
# Prefer ethernet for upstream internet when both are available.
nmcli connection modify "${CON_NAME}" ipv4.never-default no
if nmcli -t -f NAME connection show | grep -Fxq "Wired connection 1"; then
nmcli connection modify "Wired connection 1" connection.autoconnect-priority 100
fi
nmcli connection modify "${CON_NAME}" connection.autoconnect-priority 50
echo "Starting access point..."
nmcli connection up "${CON_NAME}"
AP_IP="$(nmcli -g IP4.ADDRESS device show "${AP_IF}" 2>/dev/null | cut -d/ -f1)"
WAN_IP="$(nmcli -g IP4.ADDRESS device show "${WAN_IF}" 2>/dev/null | cut -d/ -f1 || true)"
cat <<EOF
Access point is running.
SSID: ${SSID}
Password: ${PASSWORD}
AP IP: ${AP_IP:-unknown} (${AP_IF})
Uplink: ${WAN_IF} ${WAN_IP:+($WAN_IP)}
Clients receive DHCP from the Pi and internet is shared via NAT through ${WAN_IF}.
The AP starts automatically on boot.
Stop it with:
sudo ./scripts/stop_ap.sh
EOF

62
scripts/stop_ap.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Stop the portal access point and return wlan0 to WiFi client mode.
set -euo pipefail
CON_NAME="${PORTAL_AP_CON_NAME:-portal-ap}"
WLAN_IF="${PORTAL_AP_IF:-wlan0}"
if [[ "${EUID}" -ne 0 ]]; then
echo "Error: run as root: sudo $0" >&2
exit 1
fi
if ! command -v nmcli >/dev/null; then
echo "Error: NetworkManager (nmcli) is required" >&2
exit 1
fi
if nmcli -t -f NAME connection show | grep -Fxq "${CON_NAME}"; then
nmcli connection down "${CON_NAME}" 2>/dev/null || true
nmcli connection modify "${CON_NAME}" connection.autoconnect no
echo "Access point '${CON_NAME}' stopped (autoconnect disabled)."
else
echo "Connection '${CON_NAME}' not found."
fi
rfkill unblock wifi >/dev/null 2>&1 || true
nmcli radio wifi on >/dev/null 2>&1 || true
nmcli device disconnect "${WLAN_IF}" 2>/dev/null || true
nmcli device wifi rescan 2>/dev/null || true
sleep 2
# Saved client profiles, newest first (skip the AP connection).
mapfile -t CLIENT_CONS < <(
nmcli -t -f NAME,TYPE,TIMESTAMP connection show | awk -F: -v ap="${CON_NAME}" '
$2 == "802-11-wireless" && $1 != ap { print $3 "\t" $1 }
' | sort -rn | cut -f2
)
CONNECTED=0
for client_con in "${CLIENT_CONS[@]}"; do
key_mgmt="$(nmcli -g 802-11-wireless-security.key-mgmt connection show "${client_con}" 2>/dev/null || true)"
if [[ -z "${key_mgmt}" ]]; then
echo "Skipping '${client_con}': missing 802-11-wireless-security.key-mgmt."
echo " Fix: sudo nmcli connection delete ${client_con}"
echo " sudo nmcli device wifi connect <SSID> password <pass>"
continue
fi
if nmcli connection up "${client_con}" ifname "${WLAN_IF}" 2>/dev/null; then
echo "wlan0 connected as client: ${client_con}"
CONNECTED=1
break
fi
done
if ((CONNECTED == 0)); then
echo "wlan0 is in client mode but no saved network connected."
echo "List networks: nmcli device wifi list"
echo "Connect: sudo nmcli device wifi connect <SSID> password <pass>"
fi
echo "Remove AP permanently: sudo nmcli connection delete ${CON_NAME}"