diff --git a/.github/workflows/e2e-docker.yml b/.github/workflows/e2e-docker.yml index 1534f0f8c..5e5ffe889 100644 --- a/.github/workflows/e2e-docker.yml +++ b/.github/workflows/e2e-docker.yml @@ -33,6 +33,17 @@ jobs: bash embedded/esp32/ci_build_image.sh ' + - name: Build Pico firmware images + timeout-minutes: 30 + run: | + docker run --rm \ + frameos \ + bash -lc ' + set -euo pipefail + bash embedded/pico2/ci_build_image.sh + FRAMEOS_PICO_PLATFORM=pico2w bash embedded/pico2/ci_build_image.sh + ' + - name: Run Docker container run: | docker run -d \ diff --git a/Dockerfile b/Dockerfile index 295a91972..7f07fd317 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ ARG PYTHON_IMAGE=python:3.12-slim-bookworm ARG ESP_IDF_VERSION=v5.5.4 ARG ESP_IDF_TARGET=esp32s3 +ARG PICO_SDK_VERSION=2.2.0 FROM ${PYTHON_IMAGE} AS nim-toolchain @@ -107,6 +108,37 @@ RUN set -eux; \ qemu-system-xtensa --version; \ rm -rf "${IDF_TOOLS_PATH}/dist" +FROM ${PYTHON_IMAGE} AS pico-sdk-toolchain + +ARG PICO_SDK_VERSION + +ENV DEBIAN_FRONTEND=noninteractive +ENV PICO_SDK_PATH=/opt/pico/pico-sdk + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + gcc-arm-none-eabi \ + git \ + libnewlib-arm-none-eabi \ + libstdc++-arm-none-eabi-dev \ + libstdc++-arm-none-eabi-newlib \ + ninja-build \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + mkdir -p "$(dirname "${PICO_SDK_PATH}")"; \ + git clone --depth 1 --branch "${PICO_SDK_VERSION}" \ + https://github.com/raspberrypi/pico-sdk.git "${PICO_SDK_PATH}"; \ + git -C "${PICO_SDK_PATH}" submodule update --init --depth 1; \ + test -f "${PICO_SDK_PATH}/external/pico_sdk_import.cmake"; \ + cmake --version; \ + arm-none-eabi-gcc --version; \ + printf '#include \n' | arm-none-eabi-g++ -x c++ -std=gnu++17 -E - >/dev/null + FROM nim-toolchain AS app-builder ARG FRAMEOS_ARCHIVE_BASE_URL=https://archive.frameos.net @@ -235,6 +267,7 @@ ENV VIRTUAL_ENV=/app/backend/.venv ENV FRAMEOS_NATIVE_JS_TRANSPILE=/app/frameos/build/native_js_transpile ENV IDF_PATH=/opt/esp/esp-idf ENV IDF_TOOLS_PATH=/opt/esp/idf-tools +ENV PICO_SDK_PATH=/opt/pico/pico-sdk ENV PATH="/opt/nim/bin:${VIRTUAL_ENV}/bin:${PATH}" WORKDIR /app @@ -253,6 +286,7 @@ RUN set -eux; \ dosfstools \ e2fsprogs \ flex \ + gcc-arm-none-eabi \ genimage \ git \ gnupg \ @@ -261,6 +295,9 @@ RUN set -eux; \ libgcrypt20 \ libffi-dev \ libglib2.0-0 \ + libnewlib-arm-none-eabi \ + libstdc++-arm-none-eabi-dev \ + libstdc++-arm-none-eabi-newlib \ libpixman-1-0 \ libsdl2-2.0-0 \ libssl-dev \ @@ -285,10 +322,12 @@ RUN set -eux; \ COPY --from=nim-toolchain /opt/nim /opt/nim COPY --from=esp-idf-toolchain /opt/esp /opt/esp +COPY --from=pico-sdk-toolchain /opt/pico /opt/pico COPY --from=app-builder /root/.nimble /root/.nimble COPY --from=python-deps /app/backend/.venv /app/backend/.venv RUN bash -lc 'set -euo pipefail; . "${IDF_PATH}/export.sh" >/dev/null 2>&1; qemu-system-xtensa --version' +RUN bash -lc 'set -euo pipefail; test -f "${PICO_SDK_PATH}/external/pico_sdk_import.cmake"; arm-none-eabi-gcc --version; printf "#include \n" | arm-none-eabi-g++ -x c++ -std=gnu++17 -E - >/dev/null' COPY docker-entrypoint.sh versions.json ./ COPY backend backend diff --git a/backend/app/api/frames.py b/backend/app/api/frames.py index 4359bd4cd..f52a16c75 100644 --- a/backend/app/api/frames.py +++ b/backend/app/api/frames.py @@ -122,6 +122,7 @@ from app.tasks.binary_builder import FrameBinaryBuilder from app.tasks.embedded_firmware import ( ensure_embedded_frame_defaults, + embedded_toolchain_error, embedded_toolchain_available, latest_embedded_firmware, normalize_embedded_platform, @@ -2638,11 +2639,12 @@ async def api_frame_embedded_firmware( _not_found() if (frame.mode or "rpios") != "embedded": _bad_request("Firmware generation is only available for embedded frames") - if not embedded_toolchain_available(): - _bad_request( - "ESP-IDF toolchain not found on the backend. " - "Install it and set IDF_PATH (see embedded/esp32/README.md)." - ) + try: + platform = normalize_embedded_platform((frame.embedded or {}).get("platform")) + except ValueError as exc: + _bad_request(str(exc)) + if not embedded_toolchain_available(platform): + _bad_request(embedded_toolchain_error(platform)) try: ensure_embedded_frame_defaults(frame) diff --git a/backend/app/api/tests/test_embedded_firmware.py b/backend/app/api/tests/test_embedded_firmware.py index 4eff73671..06ee4e17c 100644 --- a/backend/app/api/tests/test_embedded_firmware.py +++ b/backend/app/api/tests/test_embedded_firmware.py @@ -5,6 +5,8 @@ from app.tasks.embedded_firmware import ( EMBEDDED_DEFAULT_MAX_HTTP_RESPONSE_BYTES, EMBEDDED_FIRMWARE_VERSION, + EMBEDDED_PICO2_PLATFORM, + EMBEDDED_PICO2W_PLATFORM, EMBEDDED_RENDER_REMOTE, EMBEDDED_SUPPORTED_PANELS, FOS_PIXEL_2BPP_GRAY, @@ -22,6 +24,7 @@ embedded_module_psram_bytes, embedded_panel_for_frame, embedded_pins_for_frame, + embedded_platform_for_frame, embedded_pixel_format_for_panel, embedded_render_psram_bytes, embedded_render_mode_for_frame, @@ -43,6 +46,31 @@ async def create_embedded_frame(async_client) -> dict: return response.json()['frame'] +async def create_pico2_frame(async_client) -> dict: + response = await async_client.post('/api/frames/new', json={ + 'name': 'Pico 2 Frame', + 'frame_host': '', + 'server_host': 'localhost', + 'mode': 'embedded', + 'platform': 'pico2', + }) + assert response.status_code == 200, response.text + return response.json()['frame'] + + +async def create_pico2w_frame(async_client) -> dict: + response = await async_client.post('/api/frames/new', json={ + 'name': 'Pico 2 W Frame', + 'frame_host': '', + 'server_host': 'localhost', + 'mode': 'embedded', + 'platform': 'pico2w', + 'network': {'wifiSSID': 'Test WiFi', 'wifiPassword': 'secret1234'}, + }) + assert response.status_code == 200, response.text + return response.json()['frame'] + + @pytest.mark.asyncio async def test_new_embedded_frame(async_client): frame = await create_embedded_frame(async_client) @@ -59,6 +87,40 @@ async def test_new_embedded_frame(async_client): assert frame['device_config']['pins']['cs2'] == -1 +@pytest.mark.asyncio +async def test_new_pico2_frame(async_client): + frame = await create_pico2_frame(async_client) + assert frame['mode'] == 'embedded' + assert frame['embedded']['platform'] == 'pico2' + assert frame['agent']['agentEnabled'] is False + assert frame['https_proxy']['enable'] is False + assert frame['device'] == 'waveshare.EPD_2in13_V4' + assert frame['device_config']['pins'] == { + 'rst': 21, + 'dc': 20, + 'cs': 17, + 'cs2': -1, + 'busy': 16, + 'sck': 18, + 'mosi': 19, + 'pwr': -1, + } + + +@pytest.mark.asyncio +async def test_new_pico2w_frame(async_client): + frame = await create_pico2w_frame(async_client) + assert frame['mode'] == 'embedded' + assert frame['embedded']['platform'] == 'pico2w' + assert frame['agent']['agentEnabled'] is False + assert frame['https_proxy']['enable'] is False + assert frame['device'] == 'waveshare.EPD_2in13_V4' + assert frame['network']['wifiSSID'] == 'Test WiFi' + assert frame['network']['wifiPassword'] == 'secret1234' + assert frame['device_config']['pins']['sck'] == 18 + assert frame['device_config']['pins']['mosi'] == 19 + + @pytest.mark.asyncio async def test_new_embedded_frame_rejects_unknown_platform(async_client): response = await async_client.post('/api/frames/new', json={ @@ -113,6 +175,22 @@ async def test_firmware_status_idle(async_client): assert response.json() == {'firmware': {'status': 'idle', 'platform': 'esp32-s3'}} +@pytest.mark.asyncio +async def test_pico2_firmware_status_idle(async_client): + frame = await create_pico2_frame(async_client) + response = await async_client.get(f"/api/frames/{frame['id']}/embedded/firmware") + assert response.status_code == 200 + assert response.json() == {'firmware': {'status': 'idle', 'platform': 'pico2'}} + + +@pytest.mark.asyncio +async def test_pico2w_firmware_status_idle(async_client): + frame = await create_pico2w_frame(async_client) + response = await async_client.get(f"/api/frames/{frame['id']}/embedded/firmware") + assert response.status_code == 200 + assert response.json() == {'firmware': {'status': 'idle', 'platform': 'pico2w'}} + + @pytest.mark.asyncio async def test_firmware_endpoints_reject_non_embedded_frames(async_client): response = await async_client.post('/api/frames/new', json={ @@ -142,6 +220,15 @@ async def test_firmware_build_requires_toolchain(async_client): assert 'ESP-IDF toolchain not found' in response.json()['detail'] +@pytest.mark.asyncio +async def test_pico2_firmware_build_requires_pico_sdk(async_client): + frame = await create_pico2_frame(async_client) + with patch('app.api.frames.embedded_toolchain_available', return_value=False): + response = await async_client.post(f"/api/frames/{frame['id']}/embedded/firmware") + assert response.status_code == 400 + assert 'Pico SDK toolchain not found' in response.json()['detail'] + + @pytest.mark.asyncio async def test_firmware_build_queues_job(async_client, db, redis): frame = await create_embedded_frame(async_client) @@ -159,6 +246,34 @@ async def test_firmware_build_queues_job(async_client, db, redis): assert stored.embedded['firmware']['status'] == 'queued' +@pytest.mark.asyncio +async def test_pico2_firmware_build_queues_job(async_client, db, redis): + frame = await create_pico2_frame(async_client) + with patch('app.api.frames.embedded_toolchain_available', return_value=True), \ + patch('app.tasks.embedded_firmware.embedded_toolchain_available', return_value=True): + response = await async_client.post(f"/api/frames/{frame['id']}/embedded/firmware") + assert response.status_code == 200, response.text + data = response.json() + assert data['message'] == 'Firmware build started' + assert data['firmware']['status'] == 'queued' + assert data['firmware']['platform'] == 'pico2' + assert data['firmware']['queueJobId'].startswith(f"embedded_firmware:{frame['id']}:") + + +@pytest.mark.asyncio +async def test_pico2w_firmware_build_queues_job(async_client, db, redis): + frame = await create_pico2w_frame(async_client) + with patch('app.api.frames.embedded_toolchain_available', return_value=True), \ + patch('app.tasks.embedded_firmware.embedded_toolchain_available', return_value=True): + response = await async_client.post(f"/api/frames/{frame['id']}/embedded/firmware") + assert response.status_code == 200, response.text + data = response.json() + assert data['message'] == 'Firmware build started' + assert data['firmware']['status'] == 'queued' + assert data['firmware']['platform'] == 'pico2w' + assert data['firmware']['queueJobId'].startswith(f"embedded_firmware:{frame['id']}:") + + @pytest.mark.asyncio async def test_firmware_download(async_client, db, tmp_path): frame = await create_embedded_frame(async_client) @@ -191,6 +306,62 @@ async def test_firmware_download(async_client, db, tmp_path): assert 'frameos-esp32-s3.bin' in response.headers.get('content-disposition', '') +@pytest.mark.asyncio +async def test_pico2_firmware_download(async_client, db, tmp_path): + frame = await create_pico2_frame(async_client) + + artifact = tmp_path / 'frameos-pico2.uf2' + artifact.write_bytes(b'uf2-firmware-bytes') + stored = db.get(Frame, frame['id']) + stored.embedded = { + 'platform': 'pico2', + 'firmware': { + 'status': 'ready', + 'platform': 'pico2', + 'firmwareVersion': EMBEDDED_FIRMWARE_VERSION, + 'filename': 'frameos-pico2.uf2', + 'path': str(artifact), + 'panel': 'EPD_2in13_V4', + 'configHash': embedded_firmware_config_hash(stored), + }, + } + db.add(stored) + db.commit() + + response = await async_client.get(f"/api/frames/{frame['id']}/embedded/firmware/download") + assert response.status_code == 200 + assert response.content == b'uf2-firmware-bytes' + assert 'frameos-pico2.uf2' in response.headers.get('content-disposition', '') + + +@pytest.mark.asyncio +async def test_pico2w_firmware_download(async_client, db, tmp_path): + frame = await create_pico2w_frame(async_client) + + artifact = tmp_path / 'frameos-pico2w.uf2' + artifact.write_bytes(b'pico2w-uf2-firmware-bytes') + stored = db.get(Frame, frame['id']) + stored.embedded = { + 'platform': 'pico2w', + 'firmware': { + 'status': 'ready', + 'platform': 'pico2w', + 'firmwareVersion': EMBEDDED_FIRMWARE_VERSION, + 'filename': 'frameos-pico2w.uf2', + 'path': str(artifact), + 'panel': 'EPD_2in13_V4', + 'configHash': embedded_firmware_config_hash(stored), + }, + } + db.add(stored) + db.commit() + + response = await async_client.get(f"/api/frames/{frame['id']}/embedded/firmware/download") + assert response.status_code == 200 + assert response.content == b'pico2w-uf2-firmware-bytes' + assert 'frameos-pico2w.uf2' in response.headers.get('content-disposition', '') + + @pytest.mark.asyncio async def test_firmware_from_older_project_version_is_stale(async_client, db, tmp_path): frame = await create_embedded_frame(async_client) @@ -246,6 +417,24 @@ async def test_firmware_ota_queues_build_when_artifact_not_ready(async_client): assert firmware['otaUpdate']['status'] == 'queued' +@pytest.mark.asyncio +async def test_pico2_firmware_ota_is_not_supported(async_client): + frame = await create_pico2_frame(async_client) + response = await async_client.post(f"/api/frames/{frame['id']}/embedded/firmware/ota") + + assert response.status_code == 400 + assert 'OTA updates are not supported' in response.json()['detail'] + + +@pytest.mark.asyncio +async def test_pico2w_firmware_ota_is_not_supported(async_client): + frame = await create_pico2w_frame(async_client) + response = await async_client.post(f"/api/frames/{frame['id']}/embedded/firmware/ota") + + assert response.status_code == 400 + assert 'OTA updates are not supported' in response.json()['detail'] + + @pytest.mark.asyncio async def test_firmware_ota_requests_device_update(async_client, db, tmp_path): frame = await create_embedded_frame(async_client) @@ -420,6 +609,37 @@ def test_embedded_defaults_choose_response_limit_and_pin_layout(): assert embedded_pins_for_frame(custom)["sck"] == 11 +def test_pico2_defaults_choose_remote_render_and_pin_layout(): + frame = Frame(id=7, embedded={"platform": "pico2"}, device="web_only") + ensure_embedded_frame_defaults(frame) + assert embedded_platform_for_frame(frame) == EMBEDDED_PICO2_PLATFORM + assert embedded_render_mode_for_frame(frame) == EMBEDDED_RENDER_REMOTE + assert frame.device == "waveshare.EPD_2in13_V4" + assert frame.https_proxy["enable"] is False + assert frame.device_config["pins"]["sck"] == 18 + assert frame.device_config["pins"]["mosi"] == 19 + + custom = Frame( + embedded={"platform": "pico2"}, + device="waveshare.EPD_2in13_V4", + device_config={"pins": {"rst": 30, "dc": 22, "sclk": 18}}, + ) + assert embedded_pins_for_frame(custom)["rst"] == 21 + assert embedded_pins_for_frame(custom)["dc"] == 22 + assert embedded_pins_for_frame(custom)["sck"] == 18 + + +def test_pico2w_defaults_choose_remote_render_and_pin_layout(): + frame = Frame(id=7, embedded={"platform": "pico2w"}, device="web_only") + ensure_embedded_frame_defaults(frame) + assert embedded_platform_for_frame(frame) == EMBEDDED_PICO2W_PLATFORM + assert embedded_render_mode_for_frame(frame) == EMBEDDED_RENDER_REMOTE + assert frame.device == "waveshare.EPD_2in13_V4" + assert frame.https_proxy["enable"] is False + assert frame.device_config["pins"]["busy"] == 16 + assert frame.device_config["pins"]["cs"] == 17 + + def test_large_spectra_panel_can_use_thin_client_on_8mb(): frame = Frame(device="waveshare.EPD_13in3e", device_config={"renderMode": "remote"}) assert embedded_render_mode_for_frame(frame) == EMBEDDED_RENDER_REMOTE @@ -525,6 +745,40 @@ def test_generated_config_bakes_remote_render_mode(): assert "#define FRAMEOS_DEFAULT_RENDER_MODE 1" in header +def test_generated_config_bakes_pico2_platform_settings(): + frame = Frame( + id=7, + server_host="backend.local", + server_port=8989, + server_api_key="key", + embedded={"platform": "pico2"}, + device="waveshare.EPD_2in13_V4", + ) + header = _generated_config_header(frame) + assert '#define FRAMEOS_DEFAULT_PLATFORM "pico2"' in header + assert "#define FRAMEOS_DEFAULT_RENDER_MODE 1" in header + assert "#define FRAMEOS_PICO2_FLASH_BYTES 4194304" in header + assert "#define FRAMEOS_PICO2_SRAM_BYTES 532480" in header + + +def test_generated_config_bakes_pico2w_platform_settings(): + frame = Frame( + id=7, + server_host="backend.local", + server_port=8989, + server_api_key="key", + embedded={"platform": "pico2w"}, + device="waveshare.EPD_2in13_V4", + network={"wifiSSID": "Test WiFi", "wifiPassword": "secret1234"}, + ) + header = _generated_config_header(frame, wifi_ssid="Test WiFi", wifi_password="secret1234") + assert '#define FRAMEOS_DEFAULT_PLATFORM "pico2w"' in header + assert '#define FRAMEOS_DEFAULT_WIFI_SSID "Test WiFi"' in header + assert '#define FRAMEOS_DEFAULT_WIFI_PASS "secret1234"' in header + assert "#define FRAMEOS_DEFAULT_RENDER_MODE 1" in header + assert "#define FRAMEOS_PICO2_FLASH_BYTES 4194304" in header + + def test_generated_config_bakes_disabled_backend_logs(): frame = Frame(id=7, server_host="backend.local", server_port=8989, server_api_key="key", device="waveshare.EPD_7in5_V2", diff --git a/backend/app/tasks/embedded_firmware.py b/backend/app/tasks/embedded_firmware.py index e072ebe65..e327437e0 100644 --- a/backend/app/tasks/embedded_firmware.py +++ b/backend/app/tasks/embedded_firmware.py @@ -1,12 +1,11 @@ -"""Build flashable firmware images for embedded (ESP32) frames. +"""Build flashable firmware images for embedded frames. -The firmware runs the FrameOS embedded runtime: Wi-Fi provisioning, the Nim -renderer (pixie on PSRAM), a Waveshare -e-ink driver, thin-client fetch, and OTA A/B updates. The build bakes -per-frame defaults into ``main/generated_config.h`` (backend URL, API key, -panel, pins), cross-compiles the Nim runtime via ``build_nim.sh`` when nim is -installed, and produces two artifacts: the merged image flashable at 0x0 and -the bare app image the device pulls over the air. +ESP32-S3 firmware runs the full FrameOS embedded runtime: Wi-Fi provisioning, +the Nim renderer (pixie on PSRAM), a Waveshare e-ink driver, thin-client fetch, +and OTA A/B updates. Pico 2 / Pico 2 W firmware is a UF2 USB/serial runtime +shell with frame identity and display wiring baked in; OTA and HTTP polling +remain unavailable for those platforms until the Pico firmware grows a network +transport. The pipeline mirrors the Buildroot SD image flow: an arq task builds the image, status lives on the frame's ``embedded.firmware`` JSON, and download @@ -24,6 +23,7 @@ import os import re import shutil +import subprocess from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional @@ -40,13 +40,41 @@ from app.utils.token import secure_token REPO_ROOT = Path(__file__).resolve().parents[3] -SUPPORTED_EMBEDDED_PLATFORM = "esp32-s3" -EMBEDDED_PLATFORM_ALIASES = {"", "esp32s3", "esp32-s3-devkitc-1"} +EMBEDDED_ESP32_S3_PLATFORM = "esp32-s3" +EMBEDDED_PICO2_PLATFORM = "pico2" +EMBEDDED_PICO2W_PLATFORM = "pico2w" +SUPPORTED_EMBEDDED_PLATFORM = EMBEDDED_ESP32_S3_PLATFORM +PICO_EMBEDDED_PLATFORMS = {EMBEDDED_PICO2_PLATFORM, EMBEDDED_PICO2W_PLATFORM} +SUPPORTED_EMBEDDED_PLATFORMS = {EMBEDDED_ESP32_S3_PLATFORM, *PICO_EMBEDDED_PLATFORMS} +EMBEDDED_PLATFORM_ALIASES = { + "": EMBEDDED_ESP32_S3_PLATFORM, + "esp32s3": EMBEDDED_ESP32_S3_PLATFORM, + "esp32-s3-devkitc-1": EMBEDDED_ESP32_S3_PLATFORM, + "pico-2": EMBEDDED_PICO2_PLATFORM, + "pico2": EMBEDDED_PICO2_PLATFORM, + "raspberry-pi-pico-2": EMBEDDED_PICO2_PLATFORM, + "raspberry-pi-pico2": EMBEDDED_PICO2_PLATFORM, + "rp2350": EMBEDDED_PICO2_PLATFORM, + "pico-2-w": EMBEDDED_PICO2W_PLATFORM, + "pico2-w": EMBEDDED_PICO2W_PLATFORM, + "pico2w": EMBEDDED_PICO2W_PLATFORM, + "raspberry-pi-pico-2-w": EMBEDDED_PICO2W_PLATFORM, + "raspberry-pi-pico2w": EMBEDDED_PICO2W_PLATFORM, + "rp2350w": EMBEDDED_PICO2W_PLATFORM, +} EMBEDDED_PROJECT_DIR = REPO_ROOT / "embedded" / "esp32" +EMBEDDED_PICO2_PROJECT_DIR = REPO_ROOT / "embedded" / "pico2" EMBEDDED_IDF_TARGET = "esp32s3" +EMBEDDED_PICO_BOARDS = { + EMBEDDED_PICO2_PLATFORM: "pico2", + EMBEDDED_PICO2W_PLATFORM: "pico2_w", +} +EMBEDDED_PICO2_FLASH_BYTES = 4 * 1024 * 1024 +EMBEDDED_PICO2_SRAM_BYTES = 520 * 1024 # Bump when the firmware project changes so existing "ready" images rebuild on next request -EMBEDDED_FIRMWARE_VERSION = 25 # Pull-side OTA checks from the ESP32 render loop +EMBEDDED_FIRMWARE_VERSION = 26 # Pull-side OTA checks from the ESP32 render loop EMBEDDED_DEFAULT_PANEL = "EPD_7in5_V2" +EMBEDDED_PICO2_DEFAULT_PANEL = "EPD_2in13_V4" EMBEDDED_DEFAULT_MAX_HTTP_RESPONSE_BYTES = 4 * 1024 * 1024 EMBEDDED_PIN_KEYS = ("rst", "dc", "cs", "cs2", "busy", "sck", "mosi", "pwr") EMBEDDED_DEFAULT_PINS = { @@ -60,6 +88,16 @@ "pwr": -1, } EMBEDDED_13IN3E_DEFAULT_PINS = {**EMBEDDED_DEFAULT_PINS, "cs2": 8} +EMBEDDED_PICO2_DEFAULT_PINS = { + "rst": 21, + "dc": 20, + "cs": 17, + "cs2": -1, + "busy": 16, + "sck": 18, + "mosi": 19, + "pwr": -1, +} # FOSB pixel formats. Keep in sync with fos_pixel_format_t in # embedded/esp32/components/frameos_display/include/frameos_display.h. FOS_PIXEL_1BPP = 1 @@ -127,11 +165,30 @@ def normalize_embedded_platform(platform: str | None) -> str: value = (platform or "").strip() - if value == SUPPORTED_EMBEDDED_PLATFORM or value in EMBEDDED_PLATFORM_ALIASES: - return SUPPORTED_EMBEDDED_PLATFORM + if value in SUPPORTED_EMBEDDED_PLATFORMS: + return value + alias = EMBEDDED_PLATFORM_ALIASES.get(value.lower()) + if alias: + return alias raise ValueError(f"Unsupported embedded platform: {value or '(empty)'}") +def embedded_platform_for_frame(frame: Frame) -> str: + embedded = frame.embedded if isinstance(frame.embedded, dict) else {} + return normalize_embedded_platform(embedded.get("platform")) + + +def _safe_embedded_platform_for_frame(frame: Frame) -> str: + try: + return embedded_platform_for_frame(frame) + except ValueError: + return SUPPORTED_EMBEDDED_PLATFORM + + +def embedded_is_pico_platform(platform: str | None) -> bool: + return normalize_embedded_platform(platform) in PICO_EMBEDDED_PLATFORMS + + def embedded_artifact_dir() -> Path: return Path( os.environ.get("FRAMEOS_EMBEDDED_ARTIFACT_DIR") @@ -143,8 +200,59 @@ def embedded_idf_path() -> Path: return Path(os.environ.get("IDF_PATH") or (Path.home() / "esp" / "esp-idf")) -def embedded_toolchain_available() -> bool: - return (embedded_idf_path() / "export.sh").is_file() +def embedded_pico_sdk_path() -> Path: + return Path(os.environ.get("PICO_SDK_PATH") or (Path.home() / "pico" / "pico-sdk")) + + +def _embedded_pico_cxx_headers_available() -> bool: + if shutil.which("arm-none-eabi-g++") is None: + return False + try: + result = subprocess.run( + ["arm-none-eabi-g++", "-x", "c++", "-std=gnu++17", "-E", "-"], + input="#include \n", + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + except OSError: + return False + return result.returncode == 0 + + +def embedded_platform_supports_ota(platform: str | None) -> bool: + return normalize_embedded_platform(platform) == EMBEDDED_ESP32_S3_PLATFORM + + +def embedded_toolchain_available(platform: str | None = None) -> bool: + normalized = normalize_embedded_platform(platform) + if normalized == EMBEDDED_ESP32_S3_PLATFORM: + return (embedded_idf_path() / "export.sh").is_file() + if normalized in PICO_EMBEDDED_PLATFORMS: + sdk = embedded_pico_sdk_path() + return ( + (sdk / "external" / "pico_sdk_import.cmake").is_file() + and shutil.which("cmake") is not None + and shutil.which("arm-none-eabi-gcc") is not None + and _embedded_pico_cxx_headers_available() + ) + return False + + +def embedded_toolchain_error(platform: str | None = None) -> str: + normalized = normalize_embedded_platform(platform) + if normalized in PICO_EMBEDDED_PLATFORMS: + return ( + f"Pico SDK toolchain not found at {embedded_pico_sdk_path()}. " + "Set PICO_SDK_PATH and install cmake plus arm-none-eabi-gcc/g++ " + "with the ARM newlib C++ headers " + "(see embedded/pico2/README.md)." + ) + return ( + f"ESP-IDF toolchain not found at {embedded_idf_path()}. " + "Set IDF_PATH or install it (see embedded/esp32/README.md)." + ) def _read_sdkconfig_values(path: Path) -> dict[str, str]: @@ -216,6 +324,8 @@ def embedded_module_psram_bytes(frame: Frame) -> int: def embedded_render_mode_for_frame(frame: Frame) -> int: """Default render mode baked into the firmware image: local unless opted into remote/thin-client mode in device_config or embedded metadata.""" + if embedded_is_pico_platform(embedded_platform_for_frame(frame)): + return EMBEDDED_RENDER_REMOTE for source in (frame.device_config, frame.embedded): if isinstance(source, dict): value = source.get("renderMode", source.get("render_mode")) @@ -243,14 +353,16 @@ def embedded_max_http_response_bytes_for_frame(frame: Frame) -> int: return value -def embedded_default_pins_for_panel(panel: str) -> dict[str, int]: +def embedded_default_pins_for_panel(panel: str, platform: str | None = None) -> dict[str, int]: + if embedded_is_pico_platform(platform): + return dict(EMBEDDED_PICO2_DEFAULT_PINS) if panel == "EPD_13in3e": return dict(EMBEDDED_13IN3E_DEFAULT_PINS) return dict(EMBEDDED_DEFAULT_PINS) def embedded_default_pins_for_frame(frame: Frame) -> dict[str, int]: - return embedded_default_pins_for_panel(embedded_panel_for_frame(frame)) + return embedded_default_pins_for_panel(embedded_panel_for_frame(frame), embedded_platform_for_frame(frame)) def embedded_pins_for_frame(frame: Frame) -> dict[str, int]: @@ -262,7 +374,8 @@ def embedded_pins_for_frame(frame: Frame) -> dict[str, int]: raw_value = raw_pins.get(key) if raw_value is None and key == "sck": raw_value = raw_pins.get("sclk") - if isinstance(raw_value, int) and not isinstance(raw_value, bool) and -1 <= raw_value <= 48: + max_pin = 29 if embedded_is_pico_platform(embedded_platform_for_frame(frame)) else 48 + if isinstance(raw_value, int) and not isinstance(raw_value, bool) and -1 <= raw_value <= max_pin: pins[key] = raw_value return pins @@ -415,9 +528,11 @@ def c_str(value: object) -> str: tls_certs = https_proxy.get("certs", {}) tls_port = https_proxy.get("port") or 8443 + platform = embedded_platform_for_frame(frame) lines = [ - "/* Generated by the FrameOS backend for this frame — do not edit. */", + "/* Generated by the FrameOS backend for this frame - do not edit. */", "#pragma once", + f"#define FRAMEOS_DEFAULT_PLATFORM {c_str(platform)}", f"#define FRAMEOS_DEFAULT_WIFI_SSID {c_str(wifi_ssid)}", f"#define FRAMEOS_DEFAULT_WIFI_PASS {c_str(wifi_password)}", f"#define FRAMEOS_DEFAULT_BACKEND_URL {c_str(backend_url)}", @@ -441,6 +556,10 @@ def c_str(value: object) -> str: for key, macro in mapping.items(): lines.append(f"#define FRAMEOS_DEFAULT_PIN_{macro} {pins[key]}") + if embedded_is_pico_platform(platform): + lines.append(f"#define FRAMEOS_PICO2_FLASH_BYTES {EMBEDDED_PICO2_FLASH_BYTES}") + lines.append(f"#define FRAMEOS_PICO2_SRAM_BYTES {EMBEDDED_PICO2_SRAM_BYTES}") + # Optional power-management settings (M4). Absent → firmware defaults # (no deep sleep, no battery pin); all still overridable from the device. device_config = embedded_device_config(frame) @@ -477,10 +596,11 @@ def ensure_embedded_frame_defaults(frame: Frame, platform: str | None = None) -> if not frame.frame_port or frame.frame_port == 8787: frame.frame_port = 80 - # No SSH or agent on a microcontroller. HTTPS uses the same frame - # certificate model as Pi frames, but is served natively by ESP-IDF instead - # of through Caddy. + # No SSH or agent on a microcontroller. ESP32 serves HTTPS natively with the + # frame certificate model; Pico firmware does not expose an HTTP server yet. frame.https_proxy = normalize_https_proxy(frame.https_proxy) + if embedded_is_pico_platform(normalized_platform): + frame.https_proxy = {**frame.https_proxy, "enable": False} agent = dict(frame.agent or {}) agent["agentEnabled"] = False agent["agentRunCommands"] = False @@ -496,7 +616,12 @@ def ensure_embedded_frame_defaults(frame: Frame, platform: str | None = None) -> if not frame.server_api_key: frame.server_api_key = secure_token(32) if not frame.device or frame.device == "web_only": - frame.device = f"waveshare.{EMBEDDED_DEFAULT_PANEL}" + default_panel = ( + EMBEDDED_PICO2_DEFAULT_PANEL + if embedded_is_pico_platform(normalized_platform) + else EMBEDDED_DEFAULT_PANEL + ) + frame.device = f"waveshare.{default_panel}" frame.max_http_response_bytes = embedded_max_http_response_bytes_for_frame(frame) @@ -523,6 +648,19 @@ def latest_embedded_firmware(frame: Frame) -> dict[str, Any] | None: "error": "The generated firmware was built from an older firmware project version", } if firmware.get("status") == "ready": + platform = firmware.get("platform") + expected_platform = embedded_platform_for_frame(frame) + if isinstance(platform, str): + try: + firmware_platform = normalize_embedded_platform(platform) + except ValueError: + firmware_platform = platform + if firmware_platform != expected_platform: + return { + **firmware, + "status": "stale", + "error": "The generated firmware was built for a different embedded platform", + } panel = firmware.get("panel") if isinstance(panel, str) and panel != embedded_panel_for_frame(frame): return { @@ -542,9 +680,10 @@ def latest_embedded_firmware(frame: Frame) -> dict[str, Any] | None: if firmware.get("status") == "ready": if not isinstance(path, str) or not Path(path).is_file(): return {**firmware, "status": "missing", "error": "The generated firmware file is missing"} - ota_path = firmware.get("otaPath") - if not isinstance(ota_path, str) or not Path(ota_path).is_file(): - return {**firmware, "status": "missing", "error": "The generated OTA firmware file is missing"} + if embedded_platform_supports_ota(embedded_platform_for_frame(frame)): + ota_path = firmware.get("otaPath") + if not isinstance(ota_path, str) or not Path(ota_path).is_file(): + return {**firmware, "status": "missing", "error": "The generated OTA firmware file is missing"} return firmware @@ -572,11 +711,9 @@ async def start_embedded_firmware( *, force: bool = False, ) -> tuple[bool, dict[str, Any]]: - if not embedded_toolchain_available(): - raise ValueError( - f"ESP-IDF toolchain not found at {embedded_idf_path()}. " - "Set IDF_PATH or install it (see embedded/esp32/README.md)." - ) + platform = embedded_platform_for_frame(frame) + if not embedded_toolchain_available(platform): + raise ValueError(embedded_toolchain_error(platform)) firmware = latest_embedded_firmware(frame) if firmware and firmware.get("status") == "ready" and not force: @@ -594,7 +731,7 @@ async def start_embedded_firmware( "status": "queued", "requestId": request_id, "queueJobId": queue_job_id, - "platform": SUPPORTED_EMBEDDED_PLATFORM, + "platform": platform, "queuedAt": queued_at, "startedAt": queued_at, } @@ -665,6 +802,9 @@ async def request_embedded_firmware_ota( ota_update: dict[str, Any] | None = None, ) -> Any: frame = get_fresh_frame(db, int(frame.id)) or frame + platform = embedded_platform_for_frame(frame) + if not embedded_platform_supports_ota(platform): + raise ValueError("OTA updates are not supported for Raspberry Pi Pico firmware") firmware = latest_embedded_firmware(frame) or {} ota_path = firmware.get("otaPath") if firmware.get("status") != "ready": @@ -714,6 +854,10 @@ async def request_or_queue_embedded_firmware_ota( *, force: bool = False, ) -> tuple[str, dict[str, Any], Any | None]: + platform = embedded_platform_for_frame(frame) + if not embedded_platform_supports_ota(platform): + raise ValueError("OTA updates are not supported for Raspberry Pi Pico firmware") + firmware = await refresh_embedded_firmware_status(db, redis, frame) or {} if firmware.get("status") == "ready" and not force: payload = await request_embedded_firmware_ota(db, redis, frame) @@ -762,6 +906,7 @@ async def embedded_firmware_task(ctx: dict[str, Any], id: int, request_id: str | try: ensure_embedded_frame_defaults(frame) + platform = embedded_platform_for_frame(frame) if request_id and not _firmware_request_matches(frame, request_id): await log(db, redis, id, "stderr", "Ignoring stale embedded firmware worker job") return @@ -773,7 +918,7 @@ async def embedded_firmware_task(ctx: dict[str, Any], id: int, request_id: str | await _set_firmware_status(db, redis, frame, { **_preserved_queue_metadata(current), "status": "error", - "platform": SUPPORTED_EMBEDDED_PLATFORM, + "platform": _safe_embedded_platform_for_frame(frame), "error": str(exc), "completedAt": _utc_now(), }) @@ -782,6 +927,14 @@ async def embedded_firmware_task(ctx: dict[str, Any], id: int, request_id: str | async def _build_firmware(db: Session, redis: Redis, frame: Frame, request_id: str | None) -> None: + platform = embedded_platform_for_frame(frame) + if embedded_is_pico_platform(platform): + await _build_pico_firmware(db, redis, frame, request_id, platform) + return + await _build_esp32_firmware(db, redis, frame, request_id) + + +async def _build_esp32_firmware(db: Session, redis: Redis, frame: Frame, request_id: str | None) -> None: if not EMBEDDED_PROJECT_DIR.is_dir(): raise ValueError(f"Embedded firmware project not found at {EMBEDDED_PROJECT_DIR}") idf_path = embedded_idf_path() @@ -796,7 +949,7 @@ async def _build_firmware(db: Session, redis: Redis, frame: Frame, request_id: s **_preserved_queue_metadata(current), "status": "building", "requestId": request_id or current.get("requestId"), - "platform": SUPPORTED_EMBEDDED_PLATFORM, + "platform": EMBEDDED_ESP32_S3_PLATFORM, "startedAt": started_at, "lastHeartbeatAt": started_at, }) @@ -915,10 +1068,10 @@ def define_safe(value: str, fallback: str) -> str: artifact_dir = embedded_artifact_dir() artifact_dir.mkdir(parents=True, exist_ok=True) - filename = f"frameos-{SUPPORTED_EMBEDDED_PLATFORM}-frame{frame.id}.bin" + filename = f"frameos-{EMBEDDED_ESP32_S3_PLATFORM}-frame{frame.id}.bin" artifact_path = artifact_dir / filename shutil.copyfile(merged_bin, artifact_path) - ota_filename = f"frameos-{SUPPORTED_EMBEDDED_PLATFORM}-frame{frame.id}-ota.bin" + ota_filename = f"frameos-{EMBEDDED_ESP32_S3_PLATFORM}-frame{frame.id}-ota.bin" ota_artifact_path = artifact_dir / ota_filename shutil.copyfile(ota_bin, ota_artifact_path) @@ -928,7 +1081,7 @@ def define_safe(value: str, fallback: str) -> str: **_preserved_queue_metadata(current), "status": "ready", "requestId": request_id or current.get("requestId"), - "platform": SUPPORTED_EMBEDDED_PLATFORM, + "platform": EMBEDDED_ESP32_S3_PLATFORM, "firmwareVersion": EMBEDDED_FIRMWARE_VERSION, "filename": filename, "path": str(artifact_path), @@ -951,6 +1104,119 @@ def define_safe(value: str, fallback: str) -> str: await request_pending_embedded_firmware_ota(db, redis, frame) +async def _build_pico_firmware( + db: Session, + redis: Redis, + frame: Frame, + request_id: str | None, + platform: str, +) -> None: + if not EMBEDDED_PICO2_PROJECT_DIR.is_dir(): + raise ValueError(f"Pico firmware project not found at {EMBEDDED_PICO2_PROJECT_DIR}") + sdk_path = embedded_pico_sdk_path() + if not embedded_toolchain_available(platform): + raise ValueError(embedded_toolchain_error(platform)) + pico_board = EMBEDDED_PICO_BOARDS[platform] + + current = latest_embedded_firmware(frame) or {} + started_at = _utc_now() + await _set_firmware_status(db, redis, frame, { + **_preserved_queue_metadata(current), + "status": "building", + "requestId": request_id or current.get("requestId"), + "platform": platform, + "startedAt": started_at, + "lastHeartbeatAt": started_at, + }) + selected_panel = embedded_panel_for_frame(frame) + await log(db, redis, int(frame.id), "stdout", + f"Building Raspberry Pi {platform} firmware with Pico SDK at {sdk_path} (panel={selected_panel})") + + build_dir = EMBEDDED_PICO2_PROJECT_DIR / f"build-{platform}" + env = {k: v for k, v in os.environ.items() if k not in {"VIRTUAL_ENV"}} + env["PICO_SDK_PATH"] = str(sdk_path) + env["PICO_BOARD"] = pico_board + + generated_header = EMBEDDED_PICO2_PROJECT_DIR / "generated_config.h" + generated_config = _generated_config_header(frame) + generated_config_hash = hashlib.sha256(generated_config.encode("utf-8")).hexdigest() + generated_header.write_text(generated_config, encoding="utf-8") + + command = ( + f'cmake -S . -B "{build_dir}" -DPICO_SDK_PATH="{sdk_path}" -DPICO_BOARD={pico_board} ' + f'&& cmake --build "{build_dir}" --parallel' + ) + + async with _build_lock: + process = await asyncio.create_subprocess_exec( + "bash", "-c", command, + cwd=str(EMBEDDED_PICO2_PROJECT_DIR), + env=env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + output_tail: list[str] = [] + assert process.stdout is not None + last_heartbeat = datetime.now(timezone.utc) + while True: + line = await process.stdout.readline() + if not line: + break + text = line.decode("utf-8", errors="replace").rstrip() + if text: + output_tail.append(text) + del output_tail[:-50] + now = datetime.now(timezone.utc) + if (now - last_heartbeat).total_seconds() >= 15: + last_heartbeat = now + frame = get_fresh_frame(db, int(frame.id)) or frame + current = latest_embedded_firmware(frame) or {} + if current.get("status") == "building": + await _set_firmware_status(db, redis, frame, {**current, "lastHeartbeatAt": _utc_now()}) + returncode = await process.wait() + + if returncode != 0: + tail = "\n".join(output_tail[-20:]) + raise ValueError(f"Pico SDK build failed with exit code {returncode}:\n{tail}") + + uf2 = build_dir / "frameos_pico2.uf2" + if not uf2.is_file(): + raise ValueError(f"Build succeeded but {uf2} was not produced") + elf = build_dir / "frameos_pico2.elf" + if not elf.is_file(): + raise ValueError(f"Build succeeded but {elf} was not produced") + + artifact_dir = embedded_artifact_dir() + artifact_dir.mkdir(parents=True, exist_ok=True) + filename = f"frameos-{platform}-frame{frame.id}.uf2" + artifact_path = artifact_dir / filename + shutil.copyfile(uf2, artifact_path) + + frame = get_fresh_frame(db, int(frame.id)) or frame + current = latest_embedded_firmware(frame) or {} + await _set_firmware_status(db, redis, frame, { + **_preserved_queue_metadata(current), + "status": "ready", + "requestId": request_id or current.get("requestId"), + "platform": platform, + "firmwareVersion": EMBEDDED_FIRMWARE_VERSION, + "filename": filename, + "path": str(artifact_path), + "size": artifact_path.stat().st_size, + "sha256": _sha256(artifact_path), + "flashOffset": "UF2/BOOTSEL", + "flashMethod": "uf2", + "panel": selected_panel, + "configHash": generated_config_hash, + "elfSha256": _sha256(elf), + "startedAt": current.get("startedAt") or started_at, + "completedAt": _utc_now(), + "downloadUrl": f"/api/frames/{frame.id}/embedded/firmware/download", + }) + await log(db, redis, int(frame.id), "stdout", + f"Raspberry Pi {platform} firmware ready: {filename} ({artifact_path.stat().st_size} bytes)") + + async def _firmware_queue_job_active(redis: Redis, firmware: dict[str, Any]) -> bool: job_id = firmware.get("queueJobId") if not isinstance(job_id, str) or not job_id: diff --git a/embedded/pico2/.gitignore b/embedded/pico2/.gitignore new file mode 100644 index 000000000..64f2787d5 --- /dev/null +++ b/embedded/pico2/.gitignore @@ -0,0 +1,3 @@ +build/ +build-*/ +generated_config.h diff --git a/embedded/pico2/CMakeLists.txt b/embedded/pico2/CMakeLists.txt new file mode 100644 index 000000000..cfcab92e7 --- /dev/null +++ b/embedded/pico2/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.13) + +set(PICO_BOARD pico2 CACHE STRING "Raspberry Pi Pico SDK board") + +if(NOT DEFINED PICO_SDK_PATH AND DEFINED ENV{PICO_SDK_PATH}) + set(PICO_SDK_PATH "$ENV{PICO_SDK_PATH}") +endif() + +if(NOT PICO_SDK_PATH) + message(FATAL_ERROR "Set PICO_SDK_PATH to a Raspberry Pi Pico SDK checkout") +endif() + +include("${PICO_SDK_PATH}/external/pico_sdk_import.cmake") + +project(frameos_pico2 C CXX ASM) +set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) + +pico_sdk_init() + +add_executable(frameos_pico2 + main.c +) + +target_include_directories(frameos_pico2 PRIVATE + ${CMAKE_CURRENT_LIST_DIR} +) + +target_compile_definitions(frameos_pico2 PRIVATE + FRAMEOS_PICO2_FIRMWARE=1 + FRAMEOS_PICO_BOARD_NAME=\"${PICO_BOARD}\" +) + +target_link_libraries(frameos_pico2 + pico_stdlib + hardware_spi +) + +pico_enable_stdio_usb(frameos_pico2 1) +pico_enable_stdio_uart(frameos_pico2 0) +pico_add_extra_outputs(frameos_pico2) diff --git a/embedded/pico2/README.md b/embedded/pico2/README.md new file mode 100644 index 000000000..89cd34510 --- /dev/null +++ b/embedded/pico2/README.md @@ -0,0 +1,45 @@ +# FrameOS Pico firmware + +FrameOS firmware target for Raspberry Pi Pico 2 and Pico 2 W / RP2350 boards +with 4MB flash. + +This target produces a UF2 image that boots a USB serial FrameOS runtime shell +with frame identity, backend URL, API key, panel, and GPIO defaults baked in. +Pico 2 W builds use the Pico SDK `pico2_w` board so wireless-capable hardware is +selected and Wi-Fi credentials can be baked into the frame settings. HTTP +polling, local Nim rendering, and OTA updates remain ESP32-S3 features until a +Pico network transport is added. + +## Build prerequisites + +- Raspberry Pi Pico SDK checkout +- CMake +- `arm-none-eabi-gcc/g++` with the ARM newlib C++ headers + +Example: + +```bash +export PICO_SDK_PATH=$HOME/pico/pico-sdk +cmake -S embedded/pico2 -B embedded/pico2/build -DPICO_BOARD=pico2 +cmake --build embedded/pico2/build +``` + +Use `-DPICO_BOARD=pico2_w` for Pico 2 W. The backend firmware builder writes +`generated_config.h` before configuring the project and stores the resulting UF2 +as the downloadable image. + +The FrameOS Docker image includes the Pico SDK at `/opt/pico/pico-sdk`, +`arm-none-eabi-gcc/g++`, and the ARM newlib C++ headers, so backend firmware +builds run inside the packaged container without mounting host tooling. CI uses +the same image and helper for both boards: + +```bash +bash embedded/pico2/ci_build_image.sh +FRAMEOS_PICO_PLATFORM=pico2w bash embedded/pico2/ci_build_image.sh +``` + +## Flashing + +Hold `BOOTSEL` while plugging in the Pico board, then copy the generated `.uf2` +file to the mounted `RPI-RP2` drive. After reboot, open the USB serial port at +any baud rate and send `help` or `status`. diff --git a/embedded/pico2/ci_build_image.sh b/embedded/pico2/ci_build_image.sh new file mode 100644 index 000000000..dec79ef83 --- /dev/null +++ b/embedded/pico2/ci_build_image.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Build a FrameOS Pico UF2 and check the 4MB flash budget. +# +# CI runs this inside the FrameOS Docker image so the packaged Pico SDK and ARM +# GCC toolchain are exercised, not a developer's host setup. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PLATFORM="${FRAMEOS_PICO_PLATFORM:-pico2}" +case "$PLATFORM" in + pico2) + DEFAULT_BOARD="pico2" + ;; + pico2w|pico2-w|pico-2-w) + PLATFORM="pico2w" + DEFAULT_BOARD="pico2_w" + ;; + *) + echo "Unsupported FRAMEOS_PICO_PLATFORM: $PLATFORM" >&2 + exit 1 + ;; +esac + +BOARD="${FRAMEOS_PICO_BOARD:-$DEFAULT_BOARD}" +BUILD_NAME="${FRAMEOS_PICO_BUILD_DIR:-build-ci-$PLATFORM}" +if [[ "$BUILD_NAME" = /* ]]; then + BUILD_DIR="$BUILD_NAME" +else + BUILD_DIR="$SCRIPT_DIR/$BUILD_NAME" +fi + +: "${PICO_SDK_PATH:=/opt/pico/pico-sdk}" + +if [[ ! -f "$PICO_SDK_PATH/external/pico_sdk_import.cmake" ]]; then + echo "PICO_SDK_PATH does not point at the Pico SDK: $PICO_SDK_PATH" >&2 + exit 1 +fi + +if ! command -v cmake >/dev/null; then + echo "cmake not found on PATH" >&2 + exit 1 +fi + +if ! command -v arm-none-eabi-gcc >/dev/null; then + echo "arm-none-eabi-gcc not found on PATH" >&2 + exit 1 +fi + +if ! command -v arm-none-eabi-g++ >/dev/null; then + echo "arm-none-eabi-g++ not found on PATH" >&2 + exit 1 +fi + +if ! printf '#include \n' | arm-none-eabi-g++ -x c++ -std=gnu++17 -E - >/dev/null 2>&1; then + echo "ARM C++ standard library headers not found; install libstdc++-arm-none-eabi-dev/newlib" >&2 + exit 1 +fi + +if [[ -d "$BUILD_DIR" && "${FRAMEOS_PICO_REUSE_BUILD:-0}" != "1" ]]; then + cat >&2 </dev/null; then + CMAKE_GENERATOR_ARGS=(-G Ninja) +fi + +echo "Building FrameOS Pico firmware for $PLATFORM ($BOARD)" +cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" \ + "${CMAKE_GENERATOR_ARGS[@]}" \ + -DPICO_SDK_PATH="$PICO_SDK_PATH" \ + -DPICO_BOARD="$BOARD" +cmake --build "$BUILD_DIR" --parallel + +UF2="$BUILD_DIR/frameos_pico2.uf2" +ELF="$BUILD_DIR/frameos_pico2.elf" +BIN="$BUILD_DIR/frameos_pico2.bin" + +file_size() { + local path="$1" + local size + if size="$(stat -c '%s' "$path" 2>/dev/null)"; then + echo "$size" + else + stat -f '%z' "$path" + fi +} + +for output in "$UF2" "$ELF" "$BIN"; do + if [[ ! -s "$output" ]]; then + echo "Missing or empty build output: $output" >&2 + exit 1 + fi +done + +FLASH_BYTES=$((4 * 1024 * 1024)) +BIN_BYTES="$(file_size "$BIN")" +UF2_BYTES="$(file_size "$UF2")" + +if (( BIN_BYTES > FLASH_BYTES )); then + echo "Pico firmware binary is too large: $BIN_BYTES bytes > $FLASH_BYTES byte flash" >&2 + exit 1 +fi + +if command -v arm-none-eabi-size >/dev/null; then + arm-none-eabi-size "$ELF" +fi + +echo "Pico binary: $BIN_BYTES bytes ($((FLASH_BYTES - BIN_BYTES)) bytes free in 4MB flash)" +echo "Pico UF2: $UF2_BYTES bytes" diff --git a/embedded/pico2/main.c b/embedded/pico2/main.c new file mode 100644 index 000000000..d54b25c9e --- /dev/null +++ b/embedded/pico2/main.c @@ -0,0 +1,155 @@ +#include +#include + +#include "hardware/gpio.h" +#include "pico/stdlib.h" + +#if __has_include("generated_config.h") +#include "generated_config.h" +#endif + +#ifndef FRAMEOS_DEFAULT_BACKEND_URL +#define FRAMEOS_DEFAULT_BACKEND_URL "" +#endif +#ifndef FRAMEOS_DEFAULT_API_KEY +#define FRAMEOS_DEFAULT_API_KEY "" +#endif +#ifndef FRAMEOS_DEFAULT_FRAME_ID +#define FRAMEOS_DEFAULT_FRAME_ID 0 +#endif +#ifndef FRAMEOS_DEFAULT_HOSTNAME +#define FRAMEOS_DEFAULT_HOSTNAME "frameos-pico" +#endif +#ifndef FRAMEOS_DEFAULT_PANEL +#define FRAMEOS_DEFAULT_PANEL "none" +#endif +#ifndef FRAMEOS_DEFAULT_RENDER_MODE +#define FRAMEOS_DEFAULT_RENDER_MODE 1 +#endif +#ifndef FRAMEOS_DEFAULT_INTERVAL_SEC +#define FRAMEOS_DEFAULT_INTERVAL_SEC 300 +#endif +#ifndef FRAMEOS_DEFAULT_PIN_RST +#define FRAMEOS_DEFAULT_PIN_RST 21 +#endif +#ifndef FRAMEOS_DEFAULT_PIN_DC +#define FRAMEOS_DEFAULT_PIN_DC 20 +#endif +#ifndef FRAMEOS_DEFAULT_PIN_CS +#define FRAMEOS_DEFAULT_PIN_CS 17 +#endif +#ifndef FRAMEOS_DEFAULT_PIN_CS2 +#define FRAMEOS_DEFAULT_PIN_CS2 -1 +#endif +#ifndef FRAMEOS_DEFAULT_PIN_BUSY +#define FRAMEOS_DEFAULT_PIN_BUSY 16 +#endif +#ifndef FRAMEOS_DEFAULT_PIN_SCK +#define FRAMEOS_DEFAULT_PIN_SCK 18 +#endif +#ifndef FRAMEOS_DEFAULT_PIN_MOSI +#define FRAMEOS_DEFAULT_PIN_MOSI 19 +#endif +#ifndef FRAMEOS_DEFAULT_PIN_PWR +#define FRAMEOS_DEFAULT_PIN_PWR -1 +#endif +#ifndef FRAMEOS_DEFAULT_PLATFORM +#define FRAMEOS_DEFAULT_PLATFORM "pico" +#endif +#ifndef FRAMEOS_PICO_BOARD_NAME +#define FRAMEOS_PICO_BOARD_NAME "pico" +#endif + +static void print_status(void) { + printf("{\"event\":\"status\",\"source\":\"pico\",\"platform\":\"%s\"," + "\"board\":\"%s\",\"frameId\":%d," + "\"hostname\":\"%s\",\"backend\":\"%s\",\"panel\":\"%s\"," + "\"renderMode\":%d,\"intervalSec\":%d," + "\"pins\":{\"rst\":%d,\"dc\":%d,\"cs\":%d,\"cs2\":%d," + "\"busy\":%d,\"sck\":%d,\"mosi\":%d,\"pwr\":%d}}\n", + FRAMEOS_DEFAULT_PLATFORM, + FRAMEOS_PICO_BOARD_NAME, + FRAMEOS_DEFAULT_FRAME_ID, + FRAMEOS_DEFAULT_HOSTNAME, + FRAMEOS_DEFAULT_BACKEND_URL, + FRAMEOS_DEFAULT_PANEL, + FRAMEOS_DEFAULT_RENDER_MODE, + FRAMEOS_DEFAULT_INTERVAL_SEC, + FRAMEOS_DEFAULT_PIN_RST, + FRAMEOS_DEFAULT_PIN_DC, + FRAMEOS_DEFAULT_PIN_CS, + FRAMEOS_DEFAULT_PIN_CS2, + FRAMEOS_DEFAULT_PIN_BUSY, + FRAMEOS_DEFAULT_PIN_SCK, + FRAMEOS_DEFAULT_PIN_MOSI, + FRAMEOS_DEFAULT_PIN_PWR); +} + +static void print_help(void) { + puts("FrameOS Pico commands:"); + puts(" status print baked frame identity and display wiring"); + puts(" ping print pong"); + puts(" help print this help"); +} + +static void handle_line(char *line) { + char *end = line + strlen(line); + while (end > line && (end[-1] == '\r' || end[-1] == '\n' || end[-1] == ' ' || end[-1] == '\t')) { + *--end = '\0'; + } + if (strcmp(line, "status") == 0) { + print_status(); + } else if (strcmp(line, "ping") == 0) { + puts("{\"event\":\"pong\",\"source\":\"pico\"}"); + } else if (strcmp(line, "help") == 0 || strcmp(line, "?") == 0) { + print_help(); + } else if (line[0] != '\0') { + printf("{\"event\":\"error\",\"source\":\"pico\",\"message\":\"unknown command: %s\"}\n", line); + } +} + +int main(void) { + stdio_init_all(); + +#ifdef PICO_DEFAULT_LED_PIN + const uint led_pin = PICO_DEFAULT_LED_PIN; + gpio_init(led_pin); + gpio_set_dir(led_pin, GPIO_OUT); +#endif + + sleep_ms(1500); + printf("{\"event\":\"bootup\",\"source\":\"pico\",\"platform\":\"%s\"," + "\"board\":\"%s\",\"flashMB\":4,\"sramKB\":520}\n", + FRAMEOS_DEFAULT_PLATFORM, + FRAMEOS_PICO_BOARD_NAME); + print_status(); + print_help(); + + char line[96]; + size_t line_len = 0; +#ifdef PICO_DEFAULT_LED_PIN + absolute_time_t next_blink = make_timeout_time_ms(500); + bool led = false; +#endif + + while (true) { + int ch = getchar_timeout_us(1000); + if (ch != PICO_ERROR_TIMEOUT) { + if (ch == '\r' || ch == '\n') { + line[line_len] = '\0'; + handle_line(line); + line_len = 0; + } else if (line_len + 1 < sizeof(line)) { + line[line_len++] = (char) ch; + } + } + +#ifdef PICO_DEFAULT_LED_PIN + if (absolute_time_diff_us(get_absolute_time(), next_blink) <= 0) { + led = !led; + gpio_put(led_pin, led); + next_blink = make_timeout_time_ms(1000); + } +#endif + } +} diff --git a/frontend/src/devices.ts b/frontend/src/devices.ts index ddf8ee13a..522fe806a 100644 --- a/frontend/src/devices.ts +++ b/frontend/src/devices.ts @@ -217,8 +217,22 @@ export const buildrootPlatforms: Option[] = [ ] export const EMBEDDED_ESP32_S3 = 'esp32-s3' +export const EMBEDDED_PICO2 = 'pico2' +export const EMBEDDED_PICO2_W = 'pico2w' -export const embeddedPlatforms: Option[] = [{ value: EMBEDDED_ESP32_S3, label: 'ESP32-S3' }] +export const embeddedPlatforms: Option[] = [ + { value: EMBEDDED_ESP32_S3, label: 'ESP32-S3' }, + { value: EMBEDDED_PICO2, label: 'Raspberry Pi Pico 2 (4MB)' }, + { value: EMBEDDED_PICO2_W, label: 'Raspberry Pi Pico 2 W (4MB)' }, +] + +export function embeddedPlatformIsPico(platform?: string | null): boolean { + return platform === EMBEDDED_PICO2 || platform === EMBEDDED_PICO2_W +} + +export function embeddedPlatformHasWifi(platform?: string | null): boolean { + return (platform || EMBEDDED_ESP32_S3) !== EMBEDDED_PICO2 +} export const rpiOSPlatforms: Option[] = [ { value: '', label: 'Autodetect' }, @@ -234,5 +248,5 @@ export const rpiOSPlatforms: Option[] = [ export const modes: Option[] = [ { value: 'rpios', label: 'Raspberry Pi OS (default)' }, { value: 'buildroot', label: 'Buildroot (beta)' }, - { value: 'embedded', label: 'ESP32' }, + { value: 'embedded', label: 'Embedded' }, ] diff --git a/frontend/src/scenes/frame/panels/Apps/Apps.tsx b/frontend/src/scenes/frame/panels/Apps/Apps.tsx index d43951227..c580da91c 100644 --- a/frontend/src/scenes/frame/panels/Apps/Apps.tsx +++ b/frontend/src/scenes/frame/panels/Apps/Apps.tsx @@ -90,7 +90,7 @@ export function Apps() { ) : null} {unsupported ? ( - ESP32 unsupported + Embedded unsupported ) : null} {app.output?.map((output, i) => ( diff --git a/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx b/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx index 41e72d30b..d76c61703 100644 --- a/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx +++ b/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx @@ -24,6 +24,8 @@ import { buildrootPlatforms, embeddedPlatforms, EMBEDDED_ESP32_S3, + embeddedPlatformHasWifi, + embeddedPlatformIsPico, modes, } from '../../../../devices' import { secureToken } from '../../../../utils/secureToken' @@ -187,31 +189,46 @@ const ESP32_XIAO_13IN3E_PIN_LAYOUT: Esp32PinLayout = { cs2: 8, } -function esp32RecommendedPinLayout(device?: string): Esp32PinLayout { +const PICO2_PIN_LAYOUT: Esp32PinLayout = { + rst: 21, + dc: 20, + cs: 17, + cs2: -1, + busy: 16, + sck: 18, + mosi: 19, + pwr: -1, +} + +function esp32RecommendedPinLayout(device?: string, platform?: string): Esp32PinLayout { + if (embeddedPlatformIsPico(platform)) { + return { ...PICO2_PIN_LAYOUT } + } return device === 'waveshare.EPD_13in3e' ? { ...ESP32_XIAO_13IN3E_PIN_LAYOUT } : { ...ESP32_XIAO_PIN_LAYOUT } } -function normalizeEsp32PinNumber(value: unknown, fallback: number): number { +function normalizeEsp32PinNumber(value: unknown, fallback: number, platform?: string): number { if (typeof value !== 'number' || !Number.isFinite(value)) { return fallback } - return Math.max(-1, Math.min(48, Math.round(value))) + const maxPin = embeddedPlatformIsPico(platform) ? 29 : 48 + return Math.max(-1, Math.min(maxPin, Math.round(value))) } -function normalizeEsp32PinLayout(value: Esp32Pins | undefined, device?: string): Esp32PinLayout { - const recommended = esp32RecommendedPinLayout(device) +function normalizeEsp32PinLayout(value: Esp32Pins | undefined, device?: string, platform?: string): Esp32PinLayout { + const recommended = esp32RecommendedPinLayout(device, platform) if (!value || typeof value !== 'object') { return recommended } return { - rst: normalizeEsp32PinNumber(value.rst, recommended.rst), - dc: normalizeEsp32PinNumber(value.dc, recommended.dc), - cs: normalizeEsp32PinNumber(value.cs, recommended.cs), - cs2: normalizeEsp32PinNumber(value.cs2, recommended.cs2), - busy: normalizeEsp32PinNumber(value.busy, recommended.busy), - sck: normalizeEsp32PinNumber(value.sck ?? value.sclk, recommended.sck), - mosi: normalizeEsp32PinNumber(value.mosi, recommended.mosi), - pwr: normalizeEsp32PinNumber(value.pwr, recommended.pwr), + rst: normalizeEsp32PinNumber(value.rst, recommended.rst, platform), + dc: normalizeEsp32PinNumber(value.dc, recommended.dc, platform), + cs: normalizeEsp32PinNumber(value.cs, recommended.cs, platform), + cs2: normalizeEsp32PinNumber(value.cs2, recommended.cs2, platform), + busy: normalizeEsp32PinNumber(value.busy, recommended.busy, platform), + sck: normalizeEsp32PinNumber(value.sck ?? value.sclk, recommended.sck, platform), + mosi: normalizeEsp32PinNumber(value.mosi, recommended.mosi, platform), + pwr: normalizeEsp32PinNumber(value.pwr, recommended.pwr, platform), } } @@ -220,6 +237,9 @@ function esp32PinLayoutsEqual(first: Esp32PinLayout, second: Esp32PinLayout): bo } function esp32PinLayoutPresetValue(pins: Esp32PinLayout): string { + if (esp32PinLayoutsEqual(pins, PICO2_PIN_LAYOUT)) { + return 'pico2' + } if (esp32PinLayoutsEqual(pins, ESP32_XIAO_PIN_LAYOUT)) { return 'xiao' } @@ -229,7 +249,10 @@ function esp32PinLayoutPresetValue(pins: Esp32PinLayout): string { return 'custom' } -function esp32PinLayoutPresetOptions(device?: string): Option[] { +function esp32PinLayoutPresetOptions(device?: string, platform?: string): Option[] { + if (embeddedPlatformIsPico(platform)) { + return [{ value: 'pico2', label: 'Raspberry Pi Pico 2 / 2 W SPI0' }, { value: 'custom', label: 'Custom' }] + } const xiao = { value: 'xiao', label: 'Seeed XIAO ESP32-S3' } const xiao13in3e = { value: 'xiao-13in3e', label: 'Seeed XIAO ESP32-S3 + CS2 on GPIO8' } return device === 'waveshare.EPD_13in3e' @@ -237,7 +260,10 @@ function esp32PinLayoutPresetOptions(device?: string): Option[] { : [xiao, xiao13in3e, { value: 'custom', label: 'Custom' }] } -function esp32PinLayoutForPreset(preset: string, device?: string): Esp32PinLayout | null { +function esp32PinLayoutForPreset(preset: string, device?: string, platform?: string): Esp32PinLayout | null { + if (preset === 'pico2') { + return { ...PICO2_PIN_LAYOUT } + } if (preset === 'xiao') { return { ...ESP32_XIAO_PIN_LAYOUT } } @@ -245,7 +271,7 @@ function esp32PinLayoutForPreset(preset: string, device?: string): Esp32PinLayou return { ...ESP32_XIAO_13IN3E_PIN_LAYOUT } } if (preset === 'recommended') { - return esp32RecommendedPinLayout(device) + return esp32RecommendedPinLayout(device, platform) } return null } @@ -310,9 +336,12 @@ export function FrameSettings({ const mountpoints = frameForm.mountpoints ?? { enabled: false, items: [] } const mountpointItems = mountpoints.items ?? [] const errorBehavior = normalizeFrameErrorBehavior(frameForm.error_behavior ?? frame.error_behavior) - const isBuildrootMode = mode === 'buildroot' - const isEmbeddedMode = mode === 'embedded' - const showWifiCredentials = isBuildrootMode || isEmbeddedMode + const deploymentMode = mode as FrameType['mode'] + const isBuildrootMode = deploymentMode === 'buildroot' + const isEmbeddedMode = deploymentMode === 'embedded' + const embeddedPlatform = frameForm.embedded?.platform ?? frame.embedded?.platform ?? EMBEDDED_ESP32_S3 + const isEmbeddedPico = isEmbeddedMode && embeddedPlatformIsPico(embeddedPlatform) + const showWifiCredentials = isBuildrootMode || (isEmbeddedMode && embeddedPlatformHasWifi(embeddedPlatform)) const maxHttpResponsePlaceholder = String( isEmbeddedMode ? EMBEDDED_DEFAULT_MAX_HTTP_RESPONSE_BYTES : DEFAULT_MAX_HTTP_RESPONSE_BYTES ) @@ -664,7 +693,7 @@ export function FrameSettings({ } nextValues.device_config = { ...(frameForm.device_config ?? {}), - pins: normalizeEsp32PinLayout(frameForm.device_config?.pins, frameForm.device), + pins: normalizeEsp32PinLayout(frameForm.device_config?.pins, frameForm.device, EMBEDDED_ESP32_S3), } setFrameFormValues({ ...nextValues, @@ -686,12 +715,15 @@ export function FrameSettings({ onChange(nextDevice) if (isEmbeddedMode) { const currentPins = frameForm.device_config?.pins - const previousPins = normalizeEsp32PinLayout(currentPins, previousDevice) - if (!currentPins || esp32PinLayoutsEqual(previousPins, esp32RecommendedPinLayout(previousDevice))) { + const previousPins = normalizeEsp32PinLayout(currentPins, previousDevice, embeddedPlatform) + if ( + !currentPins || + esp32PinLayoutsEqual(previousPins, esp32RecommendedPinLayout(previousDevice, embeddedPlatform)) + ) { setFrameFormValues({ device_config: { ...(frameForm.device_config ?? {}), - pins: esp32RecommendedPinLayout(nextDevice), + pins: esp32RecommendedPinLayout(nextDevice, embeddedPlatform), }, }) } @@ -800,26 +832,42 @@ export function FrameSettings({ <> - { + setFrameFormValues({ + embedded: { ...(frameForm.embedded ?? {}), platform: nextPlatform }, + device_config: { + ...(frameForm.device_config ?? {}), + pins: esp32RecommendedPinLayout(frameForm.device, nextPlatform), + }, + }) + }} + /> {({ value, onChange }) => { - const pins = normalizeEsp32PinLayout(value as Esp32Pins | undefined, frameForm.device) + const pins = normalizeEsp32PinLayout( + value as Esp32Pins | undefined, + frameForm.device, + embeddedPlatform + ) const preset = esp32PinLayoutPresetValue(pins) - const recommended = esp32RecommendedPinLayout(frameForm.device) + const recommended = esp32RecommendedPinLayout(frameForm.device, embeddedPlatform) return (
+ - HTTP access key
} - labelRight={ - - } - tooltip="This key is used when communicating with the frame over HTTP." - > - touchFrameFormField('frame_access_key')} - type={frameFormTouches.frame_access_key ? 'text' : 'password'} - placeholder="" - required - /> -
- - ) : null} - +