From 09cf47362c5539def447d94f72126845e6da55cb Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 3 Jun 2026 16:59:30 +0000 Subject: [PATCH 1/2] Add Buildroot support for Allwinner T113 --- backend/app/api/frames.py | 4 +- backend/app/tasks/buildroot_image.py | 594 +++++++++++++----- .../app/tasks/tests/test_buildroot_image.py | 49 +- docs/buildroot-allwinner-t113.md | 87 +++ docs/buildroot-raspberry-pi-zero-2w.md | 92 +++ frontend/src/devices.ts | 2 + tools/buildroot-images/buildroot_images.py | 38 +- 7 files changed, 686 insertions(+), 180 deletions(-) create mode 100644 docs/buildroot-allwinner-t113.md create mode 100644 docs/buildroot-raspberry-pi-zero-2w.md diff --git a/backend/app/api/frames.py b/backend/app/api/frames.py index 8779ae974..723b6ef66 100644 --- a/backend/app/api/frames.py +++ b/backend/app/api/frames.py @@ -122,8 +122,8 @@ ensure_buildroot_frame_defaults, latest_buildroot_sd_image, normalize_buildroot_platform, - resolve_buildroot_base_entry, start_buildroot_sd_image, + try_resolve_buildroot_base_entry, validate_buildroot_network, validate_buildroot_wifi_credentials, ) @@ -2378,7 +2378,7 @@ async def api_frame_buildroot_sd_image_status(id: int, db: Session = Depends(get platform = normalize_buildroot_platform((frame.buildroot or {}).get("platform")) except ValueError as exc: _bad_request(str(exc)) - base_entry = await resolve_buildroot_base_entry(platform) + base_entry = await try_resolve_buildroot_base_entry(platform) return { "sdImage": latest_buildroot_sd_image(frame, base_entry) or { diff --git a/backend/app/tasks/buildroot_image.py b/backend/app/tasks/buildroot_image.py index bb26deb1e..28c62943d 100644 --- a/backend/app/tasks/buildroot_image.py +++ b/backend/app/tasks/buildroot_image.py @@ -9,6 +9,7 @@ import shlex import shutil import tempfile +from dataclasses import dataclass from datetime import datetime, timezone from functools import lru_cache from pathlib import Path @@ -55,7 +56,9 @@ from app.utils.versions import current_frameos_version REPO_ROOT = Path(__file__).resolve().parents[3] -SUPPORTED_BUILDROOT_PLATFORM = "raspberry-pi-zero-2-w" +BUILDROOT_RASPBERRY_PI_ZERO_2_W = "raspberry-pi-zero-2-w" +BUILDROOT_ALLWINNER_T113_S3 = "allwinner-t113-s3" +SUPPORTED_BUILDROOT_PLATFORM = BUILDROOT_RASPBERRY_PI_ZERO_2_W BUILDROOT_HOST_CXXFLAGS = "-O2 -pipe -std=gnu++17" BUILDROOT_HOST_CFLAGS = "-O2 -pipe" BUILDROOT_JLEVEL = int(os.environ.get("FRAMEOS_BUILDROOT_JLEVEL", "0")) @@ -149,6 +152,11 @@ distro="debian", version="bookworm", ) +ALLWINNER_T113_FRAMEOS_BUILD_TARGET = TargetMetadata( + arch="armhf", + distro="debian", + version="bookworm", +) ACTIVE_SD_IMAGE_STATUSES = {"queued", "building"} ACTIVE_ARQ_JOB_STATUSES = { JobStatus.deferred, @@ -157,13 +165,313 @@ } +@dataclass(frozen=True, slots=True) +class BuildrootPlatformSpec: + slug: str + label: str + defconfig: str + frameos_target: TargetMetadata + build_log_name: str + default_boot_config_lines: tuple[str, ...] = () + supports_boot_config_lines: bool = False + buildroot_config_lines: tuple[str, ...] = () + kernel_config_fragment_lines: tuple[str, ...] = () + post_image_script: str = "" + frameos_partition_number: int = 3 + assets_partition_number: int = 4 + boot_partition_number: int = 1 + aliases: tuple[str, ...] = () + + +PI_KERNEL_CONFIG_FRAGMENT_LINES = ( + "# Avoid case-colliding xtables target/match objects on macOS bind mounts.", + "# CONFIG_NETFILTER_XT_TARGET_DSCP is not set", + "# CONFIG_NETFILTER_XT_TARGET_HL is not set", + "# CONFIG_NETFILTER_XT_TARGET_RATEEST is not set", + "# CONFIG_NETFILTER_XT_TARGET_TCPMSS is not set", + "# CONFIG_NETFILTER_XT_MATCH_RATEEST is not set", + "# CONFIG_IP_NF_TARGET_ECN is not set", + "# CONFIG_IP_NF_TARGET_TTL is not set", + "# CONFIG_IP6_NF_TARGET_HL is not set", + "", + "# Trimmed for Raspberry Pi Zero 2 W use cases.", + "# Keep HID, HDMI, Wi-Fi, Bluetooth and USB storage; trim the rest.", + "# Telephony/streaming/media/input-complexity reducers.", + "# CONFIG_AUXDISPLAY is not set", + "# CONFIG_CAN is not set", + "# CONFIG_DVB_CORE is not set", + "# CONFIG_DVB_USB is not set", + "# CONFIG_HAMRADIO is not set", + "# CONFIG_MEDIA_DIGITAL_TV_SUPPORT is not set", + "# CONFIG_MEDIA_PCI_SUPPORT is not set", + "# CONFIG_MEDIA_PLATFORM_DRIVERS is not set", + "# CONFIG_MEDIA_USB_SUPPORT is not set", + "# CONFIG_STAGING is not set", + "# CONFIG_VIDEO_DEV is not set", + "# CONFIG_VIDEO_HDPVR is not set", + "# CONFIG_VIDEO_OV2640 is not set", + "# CONFIG_USB_ACM is not set", + "# CONFIG_USB_NET_AX88179_178A is not set", + "# CONFIG_USB_NET_CDCETHER is not set", + "# CONFIG_USB_NET_CDC_SUBSET is not set", + "# CONFIG_USB_NET_CDC_NCM is not set", + "# CONFIG_USB_NET_CDC_MBIM is not set", + "# CONFIG_USB_NET_DM9601 is not set", + "# CONFIG_USB_NET_CDC_EEM is not set", + "# CONFIG_USB_NET_HUAWEI_CDC_NCM is not set", + "# CONFIG_USB_NET_RNDIS_HOST is not set", + "# CONFIG_USB_OHCI_HCD_PLATFORM is not set", + "# CONFIG_USB_PRINTER is not set", + "# CONFIG_USB_ROLE_SWITCH is not set", + "# CONFIG_USB_SERIAL is not set", + "# CONFIG_USB_MON is not set", + "# CONFIG_WIMAX is not set", + "", +) + + +ALLWINNER_T113_KERNEL_CONFIG_FRAGMENT_LINES = ( + "# FrameOS T113-S3/S4 devboard defaults.", + "CONFIG_DEVTMPFS=y", + "CONFIG_DEVTMPFS_MOUNT=y", + "CONFIG_CGROUPS=y", + "CONFIG_INOTIFY_USER=y", + "CONFIG_TMPFS_POSIX_ACL=y", + "CONFIG_TMPFS_XATTR=y", + "CONFIG_OVERLAY_FS=y", + "CONFIG_VT=y", + "CONFIG_VT_CONSOLE=y", + "CONFIG_FRAMEBUFFER_CONSOLE=y", + "CONFIG_DRM=y", + "CONFIG_DRM_SUN4I=y", + "CONFIG_DRM_PANEL_SIMPLE=y", + "CONFIG_BACKLIGHT_CLASS_DEVICE=y", + "CONFIG_SPI=y", + "CONFIG_SPI_SUN6I=y", + "CONFIG_SPI_SPIDEV=y", + "CONFIG_GPIO_SYSFS=y", + "CONFIG_GPIO_CDEV=y", + "CONFIG_INPUT_EVDEV=y", + "CONFIG_USB_SUPPORT=y", + "CONFIG_USB_MUSB_HDRC=y", + "CONFIG_USB_MUSB_SUNXI=y", + "CONFIG_USB_STORAGE=y", + "CONFIG_MMC=y", + "CONFIG_MMC_SUNXI=y", + "CONFIG_CFG80211=y", + "CONFIG_MAC80211=y", + "CONFIG_RTL8723DS=m", + "", +) + + +COMMON_BUILDROOT_CONFIG_LINES = ( + "BR2_INIT_SYSTEMD=y", + "BR2_ROOTFS_DEVICE_CREATION_DYNAMIC_EUDEV=y", + "BR2_ENABLE_LOCALE=y", + "BR2_SYSTEM_DHCP=\"eth0\"", + "BR2_TARGET_GENERIC_HOSTNAME=\"frameos\"", + "BR2_TARGET_GENERIC_ISSUE=\"Welcome to FrameOS\"", + "BR2_TARGET_ROOTFS_EXT2_SIZE=\"768M\"", + f"BR2_JLEVEL={BUILDROOT_JLEVEL}", + 'BR2_DL_DIR="/cache/dl"', + 'BR2_LINUX_KERNEL_CONFIG_FRAGMENT_FILES="/work/linux-fragment.config"', + f'BR2_LINUX_KERNEL_CUSTOM_LOGO_PATH="{BUILDROOT_BOOT_LOGO_WORK_PATH}"', + "BR2_PACKAGE_SYSTEMD=y", + "BR2_PACKAGE_SYSTEMD_TIMESYNCD=y", + "BR2_PACKAGE_DBUS=y", + "BR2_PACKAGE_DROPBEAR=y", + "BR2_PACKAGE_SUDO=y", + "BR2_PACKAGE_CA_CERTIFICATES=y", + "BR2_PACKAGE_TZDATA=y", + "BR2_PACKAGE_BASH=y", + "BR2_PACKAGE_COREUTILS=y", + "BR2_PACKAGE_FINDUTILS=y", + "BR2_PACKAGE_GZIP=y", + "BR2_PACKAGE_TAR=y", + "BR2_PACKAGE_NANO=y", + "BR2_PACKAGE_IPROUTE2=y", + "BR2_PACKAGE_KMOD=y", + "BR2_PACKAGE_OPENSSL=y", + "BR2_PACKAGE_ZLIB=y", + "BR2_PACKAGE_IMAGEMAGICK=y", + "BR2_PACKAGE_FFMPEG=y", + "BR2_PACKAGE_FFMPEG_FFPROBE=y", + "BR2_PACKAGE_FFMPEG_SWSCALE=y", + "BR2_PACKAGE_HOSTAPD=y", + "BR2_PACKAGE_DNSMASQ=y", + "BR2_PACKAGE_NETWORK_MANAGER=y", + "BR2_PACKAGE_NETWORK_MANAGER_CLI=y", + "BR2_PACKAGE_NETWORK_MANAGER_WIFI=y", + "BR2_PACKAGE_WPA_SUPPLICANT=y", + "BR2_PACKAGE_WPA_SUPPLICANT_DBUS=y", + "BR2_PACKAGE_WPA_SUPPLICANT_NL80211=y", + "BR2_PACKAGE_IW=y", + "BR2_PACKAGE_WIRELESS_TOOLS=y", + "BR2_PACKAGE_LIBEVDEV=y", + "# BR2_CCACHE is not set", + 'BR2_ROOTFS_OVERLAY="/work/overlay"', +) + + +PI_BUILDROOT_CONFIG_LINES = ( + *COMMON_BUILDROOT_CONFIG_LINES, + "BR2_PACKAGE_LINUX_FIRMWARE=y", + "BR2_PACKAGE_LINUX_FIRMWARE_BRCM_BCM43XXX=y", + "BR2_PACKAGE_BRCMFMAC_SDIO_FIRMWARE_RPI=y", + 'BR2_ROOTFS_POST_BUILD_SCRIPT="board/raspberrypi/post-build.sh /work/post-build.sh /work/partition-post-build.sh"', + 'BR2_ROOTFS_POST_IMAGE_SCRIPT="/work/post-image.sh"', +) + + +ALLWINNER_T113_BUILDROOT_CONFIG_LINES = ( + "BR2_TOOLCHAIN_BUILDROOT_GLIBC=y", + *COMMON_BUILDROOT_CONFIG_LINES, + "BR2_TARGET_GENERIC_GETTY_PORT=\"ttyS3\"", + "BR2_PACKAGE_RTL8723DS=y", + "BR2_PACKAGE_WPA_SUPPLICANT_AUTOSCAN=y", + "BR2_PACKAGE_WPA_SUPPLICANT_CLI=y", + "BR2_PACKAGE_WPA_SUPPLICANT_PASSPHRASE=y", + 'BR2_ROOTFS_POST_BUILD_SCRIPT="/work/post-build.sh /work/partition-post-build.sh"', + 'BR2_ROOTFS_POST_IMAGE_SCRIPT="/work/post-image.sh"', +) + + +def _allwinner_t113_post_image_script() -> str: + return f"""#!/usr/bin/env bash +set -euo pipefail + +genimage_cfg="${{BINARIES_DIR:?BINARIES_DIR is required}}/frameos-genimage.cfg" +genimage_tmp="${{BUILD_DIR:?BUILD_DIR is required}}/genimage.tmp" +rootpath_tmp="$(mktemp -d)" +trap 'rm -rf "$rootpath_tmp"' EXIT + +mkdir -p "${{BINARIES_DIR}}/extlinux" +cat > "${{BINARIES_DIR}}/extlinux/extlinux.conf" <<'EOF' +label FRAMEOS-T113 + kernel /zImage + devicetree /sun8i-t113s-mangopi-mq-r-t113.dtb + append console=ttyS3,115200 root=/dev/mmcblk0p2 rootwait rw panic=10 ${{extra}} +EOF + +cat > "$genimage_cfg" < str: value = (platform or "").strip() - if value == SUPPORTED_BUILDROOT_PLATFORM or value in LEGACY_PLATFORM_ALIASES: + if value in LEGACY_PLATFORM_ALIASES: return SUPPORTED_BUILDROOT_PLATFORM + if value in BUILDROOT_PLATFORM_SPECS: + return value + for spec in BUILDROOT_PLATFORM_SPECS.values(): + if value in spec.aliases: + return spec.slug raise ValueError(f"Unsupported Buildroot platform: {value or '(empty)'}") +def buildroot_platform_spec(platform: str | None) -> BuildrootPlatformSpec: + return BUILDROOT_PLATFORM_SPECS[normalize_buildroot_platform(platform)] + + def buildroot_artifact_dir() -> Path: return Path(os.environ.get("FRAMEOS_ARTIFACT_DIR") or (REPO_ROOT / "db" / "artifacts" / "sd-images")) @@ -252,6 +560,15 @@ async def resolve_buildroot_base_entry(platform: str, frameos_version: str | Non return max(entries, key=lambda entry: str(entry.get("updated_at") or "")) +async def try_resolve_buildroot_base_entry(platform: str, frameos_version: str | None = None) -> dict[str, Any] | None: + try: + return await resolve_buildroot_base_entry(platform, frameos_version) + except RuntimeError as exc: + if "No cached Buildroot base image found" in str(exc): + return None + raise + + async def ensure_buildroot_base_image(entry: dict[str, Any], destination_dir: Path) -> Path: object_key = entry.get("object_key") sha256 = entry.get("sha256") @@ -284,8 +601,8 @@ async def ensure_buildroot_base_image(entry: dict[str, Any], destination_dir: Pa return image_path -def _lgpio_runtime_library_paths() -> list[Path]: - sysroot = cross_cache_root() / cross_cache_key(FRAMEOS_BUILD_TARGET) / "sysroot" +def _lgpio_runtime_library_paths(target: TargetMetadata = FRAMEOS_BUILD_TARGET) -> list[Path]: + sysroot = cross_cache_root() / cross_cache_key(target) / "sysroot" libraries: list[Path] = [] for lib_dir in (sysroot / "usr" / "lib", sysroot / "usr" / "local" / "lib"): if not lib_dir.is_dir(): @@ -293,9 +610,9 @@ def _lgpio_runtime_library_paths() -> list[Path]: for pattern in ("liblgpio.so*", "librgpio.so*"): libraries.extend(sorted(path for path in lib_dir.glob(pattern) if path.is_file())) prebuilt_target = resolve_prebuilt_target( - FRAMEOS_BUILD_TARGET.distro, - FRAMEOS_BUILD_TARGET.version, - FRAMEOS_BUILD_TARGET.arch, + target.distro, + target.version, + target.arch, ) if prebuilt_target: prebuilt_root = REPO_ROOT / "build" / "prebuilt-deps" / prebuilt_target @@ -307,8 +624,8 @@ def _lgpio_runtime_library_paths() -> list[Path]: return list(dict.fromkeys(libraries)) -def copy_lgpio_runtime_libraries(overlay_dir: Path) -> None: - runtime_libraries = _lgpio_runtime_library_paths() +def copy_lgpio_runtime_libraries(overlay_dir: Path, target: TargetMetadata = FRAMEOS_BUILD_TARGET) -> None: + runtime_libraries = _lgpio_runtime_library_paths(target) if not runtime_libraries: raise RuntimeError("Buildroot image requires lgpio runtime libraries, but none were found in the cross sysroot or prebuilt deps") destination = overlay_dir / "usr" / "lib" @@ -374,8 +691,11 @@ def _merge_boot_config_lines(content: str, requested_lines: list[str]) -> str: return merged -def _frame_boot_config_lines(frame: Frame) -> list[str]: - lines: list[str] = list(BUILDROOT_DEFAULT_BOOT_CONFIG_LINES) +def _frame_boot_config_lines(frame: Frame, platform: str | None = None) -> list[str]: + spec = buildroot_platform_spec(platform or (getattr(frame, "buildroot", None) or {}).get("platform")) + lines: list[str] = list(spec.default_boot_config_lines) + if not spec.supports_boot_config_lines: + return lines seen: set[str] = set(lines) for driver in drivers_for_frame(frame).values(): for line in getattr(driver, "lines", []) or []: @@ -539,7 +859,8 @@ async def start_buildroot_sd_image( *, force: bool = False, ) -> tuple[bool, dict[str, Any]]: - current_base_entry = await resolve_buildroot_base_entry(SUPPORTED_BUILDROOT_PLATFORM) + platform = normalize_buildroot_platform((frame.buildroot or {}).get("platform")) + current_base_entry = await try_resolve_buildroot_base_entry(platform) sd_image = latest_buildroot_sd_image(frame, current_base_entry) if sd_image and sd_image.get("status") == "ready" and not force: return False, sd_image @@ -564,7 +885,7 @@ async def start_buildroot_sd_image( "status": "queued", "requestId": request_id, "queueJobId": queue_job_id, - "platform": SUPPORTED_BUILDROOT_PLATFORM, + "platform": platform, "queuedAt": queued_at, "startedAt": queued_at, } @@ -645,6 +966,8 @@ def __init__(self, *, db: Session, redis: Redis, frame: Frame, request_id: str | self.redis = redis self.frame = frame self.request_id = request_id + self.platform = normalize_buildroot_platform((getattr(frame, "buildroot", None) or {}).get("platform")) + self.platform_spec = buildroot_platform_spec(self.platform) async def run(self) -> dict[str, Any]: validate_buildroot_wifi_credentials(self.frame) @@ -664,7 +987,7 @@ async def run(self) -> dict[str, Any]: temp_dir = Path(tmp) deployer = FrameDeployer(self.db, self.redis, self.frame, "", str(temp_dir)) build_id = deployer.build_id - raw_filename = f"frameos-{self.frame.id}-{SUPPORTED_BUILDROOT_PLATFORM}-{build_id}.img" + raw_filename = f"frameos-{self.frame.id}-{self.platform}-{build_id}.img" filename = f"{raw_filename}.gz" raw_output_path = artifact_dir / raw_filename output_path = artifact_dir / filename @@ -677,7 +1000,7 @@ async def run(self) -> dict[str, Any]: **_preserved_queue_metadata(latest_buildroot_sd_image(self.frame) or {}), "status": "building", "buildId": build_id, - "platform": SUPPORTED_BUILDROOT_PLATFORM, + "platform": self.platform, "frameosVersion": current_frameos_version(), "filename": filename, "rawFilename": raw_filename, @@ -706,23 +1029,53 @@ async def run(self) -> dict[str, Any]: partition_post_build_path = temp_dir / "partition-post-build.sh" post_image_path = temp_dir / "post-image.sh" kernel_fragment_path = temp_dir / "linux-fragment.config" - self._write_buildroot_config(config_path) - self._write_kernel_config_fragment(kernel_fragment_path) + self._write_buildroot_config(config_path, self.platform_spec) + self._write_kernel_config_fragment(kernel_fragment_path, self.platform_spec) self._write_post_build_script(post_build_path) self._write_partition_post_build_script(partition_post_build_path) - self._write_post_image_script(post_image_path) + self._write_post_image_script(post_image_path, self.platform_spec) + self._write_build_script(script_path, raw_output_path.name, self.platform_spec) self._write_boot_logo(temp_dir / Path(BUILDROOT_BOOT_LOGO_WORK_PATH).name) - base_entry = await resolve_buildroot_base_entry(SUPPORTED_BUILDROOT_PLATFORM) - base_image_path = await ensure_buildroot_base_image(base_entry, buildroot_base_cache_dir()) - compose_image = None - if not self._host_has_compose_tools(): - compose_image = await self._ensure_buildroot_image() - await self._compose_sd_image_from_base( - temp_dir=temp_dir, - base_image_path=base_image_path, - output_path=raw_output_path, - image=compose_image, - ) + base_entry = await try_resolve_buildroot_base_entry(self.platform) + if base_entry is not None: + base_image_path = await ensure_buildroot_base_image(base_entry, buildroot_base_cache_dir()) + compose_image = None + if not self._host_has_compose_tools(): + compose_image = await self._ensure_buildroot_image() + await self._compose_sd_image_from_base( + temp_dir=temp_dir, + base_image_path=base_image_path, + output_path=raw_output_path, + image=compose_image, + ) + else: + await self._log( + "stderr", + f"No cached Buildroot base image found for {self.platform}; running a full local Buildroot build", + ) + build_image = await self._ensure_buildroot_image() + output_cache_key = self._buildroot_output_cache_key( + build_id, + overlay_dir, + config_path, + post_build_path, + partition_post_build_path, + post_image_path, + build_image=build_image, + skip_apt_install=True, + spec=self.platform_spec, + ) + output_dir = output_cache_root / output_cache_key + output_dir.mkdir(parents=True, exist_ok=True) + await self._run_buildroot( + temp_dir=temp_dir, + artifact_dir=artifact_dir, + cache_dir=cache_dir, + source_dir=source_dir, + output_dir=output_dir, + image=build_image, + skip_apt_install=True, + ) if not raw_output_path.is_file(): raise RuntimeError(f"SD image composer completed without producing {raw_output_path.name}") @@ -736,15 +1089,17 @@ async def run(self) -> dict[str, Any]: **_preserved_queue_metadata(latest_buildroot_sd_image(self.frame) or {}), "status": "ready", "buildId": build_id, - "platform": SUPPORTED_BUILDROOT_PLATFORM, + "platform": self.platform, "frameosVersion": current_frameos_version(), "buildrootVersion": BUILDROOT_VERSION, - "baseImage": { - "frameosVersion": base_entry.get("frameos_version"), - "objectKey": base_entry.get("object_key"), - "sha256": base_entry.get("sha256"), - "updatedAt": base_entry.get("updated_at"), - }, + **({ + "baseImage": { + "frameosVersion": base_entry.get("frameos_version"), + "objectKey": base_entry.get("object_key"), + "sha256": base_entry.get("sha256"), + "updatedAt": base_entry.get("updated_at"), + }, + } if base_entry is not None else {"baseImage": None}), "filename": filename, "rawFilename": raw_filename, "path": str(output_path), @@ -770,7 +1125,7 @@ async def _build_frameos_binary( temp_dir: str, frame: Frame, ) -> FrameBinaryBuildResult: - await self._log("stdout", "Building FrameOS binary for Raspberry Pi Zero 2 W") + await self._log("stdout", f"Building FrameOS binary for {self.platform_spec.build_log_name}") builder = FrameBinaryBuilder( db=self.db, redis=self.redis, @@ -780,17 +1135,17 @@ async def _build_frameos_binary( ) plan = await builder.plan_build( force_cross_compile=False, - target_override=FRAMEOS_BUILD_TARGET, + target_override=self.platform_spec.frameos_target, compilation_mode=frame_compilation_mode(frame), ) return await builder.build(plan, precompiled_install_all_drivers=True) async def _build_agent_binary(self, deployer: FrameDeployer, temp_dir: str, frame: Frame) -> str: - await self._log("stdout", "Building FrameOS agent for Raspberry Pi Zero 2 W") + await self._log("stdout", f"Building FrameOS agent for {self.platform_spec.build_log_name}") prebuilt_target = resolve_prebuilt_target( - FRAMEOS_BUILD_TARGET.distro, - FRAMEOS_BUILD_TARGET.version, - FRAMEOS_BUILD_TARGET.arch, + self.platform_spec.frameos_target.distro, + self.platform_spec.frameos_target.version, + self.platform_spec.frameos_target.arch, ) if prebuilt_target: try: @@ -813,13 +1168,13 @@ async def _build_agent_binary(self, deployer: FrameDeployer, temp_dir: str, fram agent_deployer = AgentDeployer(self.db, self.redis, frame, "", temp_dir, force_source=True) agent_deployer.build_id = deployer.build_id build_dir, source_dir = agent_deployer._create_agent_build_folders() - await agent_deployer._create_local_build_archive(build_dir, source_dir, FRAMEOS_BUILD_TARGET.arch) + await agent_deployer._create_local_build_archive(build_dir, source_dir, self.platform_spec.frameos_target.arch) return await CrossCompiler( db=self.db, redis=self.redis, frame=frame, deployer=agent_deployer, - target=FRAMEOS_BUILD_TARGET, + target=self.platform_spec.frameos_target, temp_dir=temp_dir, build_dir=build_dir, logger=agent_deployer.log, @@ -938,7 +1293,7 @@ def _stage_overlay( (boot_overlay_dir / Path(BOOT_HOSTNAME_FILE).name).write_text(_hostname_for_frame(self.frame) + "\n", encoding="utf-8") self._write_boot_wifi_connection(boot_overlay_dir / Path(BOOT_WIFI_CONNECTION_FILE).name) - self._write_boot_config(overlay_dir, _frame_boot_config_lines(bootstrap_frame)) + self._write_boot_config(overlay_dir, _frame_boot_config_lines(bootstrap_frame, self.platform)) self._write_boot_authorized_keys(boot_overlay_dir / Path(BOOT_AUTHORIZED_KEYS_FILE).name) @staticmethod @@ -979,7 +1334,7 @@ def _write_service( destination.write_text("\n".join(rendered_lines) + "\n", encoding="utf-8") def _copy_runtime_libraries(self, overlay_dir: Path) -> None: - copy_lgpio_runtime_libraries(overlay_dir) + copy_lgpio_runtime_libraries(overlay_dir, self.platform_spec.frameos_target) def _write_boot_authorized_keys(self, authorized_keys: Path) -> None: settings = _get_frame_settings(self.db, self.frame) @@ -1012,116 +1367,18 @@ def _relative_symlink(target: str, link: Path) -> None: link.symlink_to(target) @staticmethod - def _write_buildroot_config(path: Path) -> None: + def _write_buildroot_config(path: Path, spec: BuildrootPlatformSpec | None = None) -> None: + spec = spec or buildroot_platform_spec(SUPPORTED_BUILDROOT_PLATFORM) path.write_text( - "\n".join( - [ - "BR2_INIT_SYSTEMD=y", - "BR2_ROOTFS_DEVICE_CREATION_DYNAMIC_EUDEV=y", - "BR2_ENABLE_LOCALE=y", - "BR2_SYSTEM_DHCP=\"eth0\"", - "BR2_TARGET_GENERIC_HOSTNAME=\"frameos\"", - "BR2_TARGET_GENERIC_ISSUE=\"Welcome to FrameOS\"", - "BR2_TARGET_ROOTFS_EXT2_SIZE=\"768M\"", - f"BR2_JLEVEL={BUILDROOT_JLEVEL}", - 'BR2_DL_DIR="/cache/dl"', - 'BR2_LINUX_KERNEL_CONFIG_FRAGMENT_FILES="/work/linux-fragment.config"', - f'BR2_LINUX_KERNEL_CUSTOM_LOGO_PATH="{BUILDROOT_BOOT_LOGO_WORK_PATH}"', - "BR2_PACKAGE_SYSTEMD=y", - "BR2_PACKAGE_SYSTEMD_TIMESYNCD=y", - "BR2_PACKAGE_DBUS=y", - "BR2_PACKAGE_DROPBEAR=y", - "BR2_PACKAGE_SUDO=y", - "BR2_PACKAGE_CA_CERTIFICATES=y", - "BR2_PACKAGE_TZDATA=y", - "BR2_PACKAGE_BASH=y", - "BR2_PACKAGE_COREUTILS=y", - "BR2_PACKAGE_FINDUTILS=y", - "BR2_PACKAGE_GZIP=y", - "BR2_PACKAGE_TAR=y", - "BR2_PACKAGE_NANO=y", - "BR2_PACKAGE_IPROUTE2=y", - "BR2_PACKAGE_KMOD=y", - "BR2_PACKAGE_OPENSSL=y", - "BR2_PACKAGE_ZLIB=y", - "BR2_PACKAGE_IMAGEMAGICK=y", - "BR2_PACKAGE_FFMPEG=y", - "BR2_PACKAGE_FFMPEG_FFPROBE=y", - "BR2_PACKAGE_FFMPEG_SWSCALE=y", - "BR2_PACKAGE_HOSTAPD=y", - "BR2_PACKAGE_DNSMASQ=y", - "BR2_PACKAGE_NETWORK_MANAGER=y", - "BR2_PACKAGE_NETWORK_MANAGER_CLI=y", - "BR2_PACKAGE_NETWORK_MANAGER_WIFI=y", - "BR2_PACKAGE_WPA_SUPPLICANT=y", - "BR2_PACKAGE_WPA_SUPPLICANT_DBUS=y", - "BR2_PACKAGE_WPA_SUPPLICANT_NL80211=y", - "BR2_PACKAGE_IW=y", - "BR2_PACKAGE_WIRELESS_TOOLS=y", - "BR2_PACKAGE_LINUX_FIRMWARE=y", - "BR2_PACKAGE_LINUX_FIRMWARE_BRCM_BCM43XXX=y", - "BR2_PACKAGE_BRCMFMAC_SDIO_FIRMWARE_RPI=y", - "BR2_PACKAGE_LIBEVDEV=y", - "# BR2_CCACHE is not set", - 'BR2_ROOTFS_OVERLAY="/work/overlay"', - 'BR2_ROOTFS_POST_BUILD_SCRIPT="board/raspberrypi/post-build.sh /work/post-build.sh /work/partition-post-build.sh"', - 'BR2_ROOTFS_POST_IMAGE_SCRIPT="/work/post-image.sh"', - "", - ] - ), + "\n".join([*spec.buildroot_config_lines, ""]), encoding="utf-8", ) @staticmethod - def _write_kernel_config_fragment(path: Path) -> None: + def _write_kernel_config_fragment(path: Path, spec: BuildrootPlatformSpec | None = None) -> None: + spec = spec or buildroot_platform_spec(SUPPORTED_BUILDROOT_PLATFORM) path.write_text( - "\n".join( - [ - "# Avoid case-colliding xtables target/match objects on macOS bind mounts.", - "# CONFIG_NETFILTER_XT_TARGET_DSCP is not set", - "# CONFIG_NETFILTER_XT_TARGET_HL is not set", - "# CONFIG_NETFILTER_XT_TARGET_RATEEST is not set", - "# CONFIG_NETFILTER_XT_TARGET_TCPMSS is not set", - "# CONFIG_NETFILTER_XT_MATCH_RATEEST is not set", - "# CONFIG_IP_NF_TARGET_ECN is not set", - "# CONFIG_IP_NF_TARGET_TTL is not set", - "# CONFIG_IP6_NF_TARGET_HL is not set", - "", - "# Trimmed for Raspberry Pi Zero 2 W use cases.", - "# Keep HID, HDMI, Wi-Fi, Bluetooth and USB storage; trim the rest.", - "# Telephony/streaming/media/input-complexity reducers.", - "# CONFIG_AUXDISPLAY is not set", - "# CONFIG_CAN is not set", - "# CONFIG_DVB_CORE is not set", - "# CONFIG_DVB_USB is not set", - "# CONFIG_HAMRADIO is not set", - "# CONFIG_MEDIA_DIGITAL_TV_SUPPORT is not set", - "# CONFIG_MEDIA_PCI_SUPPORT is not set", - "# CONFIG_MEDIA_PLATFORM_DRIVERS is not set", - "# CONFIG_MEDIA_USB_SUPPORT is not set", - "# CONFIG_STAGING is not set", - "# CONFIG_VIDEO_DEV is not set", - "# CONFIG_VIDEO_HDPVR is not set", - "# CONFIG_VIDEO_OV2640 is not set", - "# CONFIG_USB_ACM is not set", - "# CONFIG_USB_NET_AX88179_178A is not set", - "# CONFIG_USB_NET_CDCETHER is not set", - "# CONFIG_USB_NET_CDC_SUBSET is not set", - "# CONFIG_USB_NET_CDC_NCM is not set", - "# CONFIG_USB_NET_CDC_MBIM is not set", - "# CONFIG_USB_NET_DM9601 is not set", - "# CONFIG_USB_NET_CDC_EEM is not set", - "# CONFIG_USB_NET_HUAWEI_CDC_NCM is not set", - "# CONFIG_USB_NET_RNDIS_HOST is not set", - "# CONFIG_USB_OHCI_HCD_PLATFORM is not set", - "# CONFIG_USB_PRINTER is not set", - "# CONFIG_USB_ROLE_SWITCH is not set", - "# CONFIG_USB_SERIAL is not set", - "# CONFIG_USB_MON is not set", - "# CONFIG_WIMAX is not set", - "", - ] - ), + "\n".join(spec.kernel_config_fragment_lines), encoding="utf-8", ) @@ -1154,8 +1411,9 @@ def _write_partition_post_build_script(path: Path) -> None: os.chmod(path, 0o755) @staticmethod - def _write_post_image_script(path: Path) -> None: - path.write_text(POST_IMAGE_SCRIPT, encoding="utf-8") + def _write_post_image_script(path: Path, spec: BuildrootPlatformSpec | None = None) -> None: + spec = spec or buildroot_platform_spec(SUPPORTED_BUILDROOT_PLATFORM) + path.write_text(spec.post_image_script or POST_IMAGE_SCRIPT, encoding="utf-8") os.chmod(path, 0o755) @staticmethod @@ -1188,7 +1446,8 @@ def _buildroot_bootstrap_frame(self) -> Frame | Any: return SimpleNamespace(**bootstrap_data) @staticmethod - def _write_build_script(path: Path, output_filename: str) -> None: + def _write_build_script(path: Path, output_filename: str, spec: BuildrootPlatformSpec | None = None) -> None: + spec = spec or buildroot_platform_spec(SUPPORTED_BUILDROOT_PLATFORM) tarball = f"buildroot-{BUILDROOT_VERSION}.tar.gz" path.write_text( f"""#!/usr/bin/env bash @@ -1280,7 +1539,7 @@ def _write_build_script(path: Path, output_filename: str) -> None: rm -f "$ncurses_dir/.stamp_staging_installed" "$ncurses_dir/.stamp_target_installed" done fi -make -C /build/buildroot O=/build/output {BUILDROOT_DEFCONFIG} +make -C /build/buildroot O=/build/output {spec.defconfig} cat /work/frameos-buildroot.config >> /build/output/.config make -C /build/buildroot O=/build/output olddefconfig rm -f /build/output/build/linux-custom/.stamp_configured @@ -1302,7 +1561,7 @@ async def _run_buildroot( image: str, skip_apt_install: bool, ) -> None: - await self._log("stdout", f"Running Buildroot {BUILDROOT_VERSION} for Raspberry Pi Zero 2 W") + await self._log("stdout", f"Running Buildroot {BUILDROOT_VERSION} for {self.platform_spec.build_log_name}") docker_cmd = " ".join( [ "docker run --rm", @@ -1432,8 +1691,18 @@ async def _compose_sd_image_from_base( shutil.copy2(base_image_path, output_path) partitions = _mbr_partitions(output_path) - _replace_partition(output_path, partitions, 3, images_dir / "frameos.ext4") - _replace_partition(output_path, partitions, 4, images_dir / "assets.vfat") + _replace_partition( + output_path, + partitions, + self.platform_spec.frameos_partition_number, + images_dir / "frameos.ext4", + ) + _replace_partition( + output_path, + partitions, + self.platform_spec.assets_partition_number, + images_dir / "assets.vfat", + ) await self._patch_boot_partition(output_path, partitions, boot_root, image=image) async def _patch_boot_partition( @@ -1446,6 +1715,10 @@ async def _patch_boot_partition( ) -> None: if not partitions: raise RuntimeError("Cannot patch BOOT partition; SD image has no partitions") + boot_partition_index = self.platform_spec.boot_partition_number - 1 + if boot_partition_index < 0 or boot_partition_index >= len(partitions): + raise RuntimeError("Cannot patch BOOT partition; SD image does not contain the configured BOOT partition") + boot_partition_offset = partitions[boot_partition_index]["start"] compose_dir = boot_root.parent.parent script_path = compose_dir / "patch-boot.sh" @@ -1460,7 +1733,7 @@ async def _patch_boot_partition( apt-get install -y --no-install-recommends mtools fi disk="$image_dir"/{shlex.quote(output_path.name)} -offset={partitions[0]["start"]} +offset={boot_partition_offset} target="${{disk}}@@${{offset}}" mlabel -i "$target" ::BOOT merge_config() {{ @@ -1745,13 +2018,16 @@ def _buildroot_output_cache_key( *, build_image: str, skip_apt_install: bool, + spec: BuildrootPlatformSpec | None = None, ) -> str: + spec = spec or buildroot_platform_spec(SUPPORTED_BUILDROOT_PLATFORM) + def normalize_path(value: str) -> str: return value.replace(f"release_{build_id}", "release_$BUILD_ID") digest = hashlib.sha256() digest.update(f"buildroot-version={BUILDROOT_VERSION}\n".encode("utf-8")) - digest.update(f"buildroot-defconfig={BUILDROOT_DEFCONFIG}\n".encode("utf-8")) + digest.update(f"buildroot-defconfig={spec.defconfig}\n".encode("utf-8")) digest.update(f"buildroot-bootstrap-image={build_image}\n".encode("utf-8")) digest.update(f"buildroot-skip-apt-install={skip_apt_install}\n".encode("utf-8")) digest.update(f"buildroot-bootstrap-script-version={BUILDROOT_BOOTSTRAP_SCRIPT_VERSION}\n".encode("utf-8")) diff --git a/backend/app/tasks/tests/test_buildroot_image.py b/backend/app/tasks/tests/test_buildroot_image.py index 992b05cbb..90aa348fe 100644 --- a/backend/app/tasks/tests/test_buildroot_image.py +++ b/backend/app/tasks/tests/test_buildroot_image.py @@ -9,13 +9,17 @@ import pytest from app.tasks.buildroot_image import ( + BUILDROOT_ALLWINNER_T113_S3, BUILDROOT_DEFAULT_BOOT_CONFIG_LINES, FRAMEOS_BUILD_TARGET, BuildrootImageBuilder, + buildroot_platform_spec, ensure_buildroot_frame_defaults, _frame_boot_config_lines, _merge_boot_config_lines, _network_manager_wifi_connection, + normalize_buildroot_platform, + try_resolve_buildroot_base_entry, ) from app.tasks.binary_builder import FrameBinaryBuildResult from app.tasks.prebuilt_deps import resolve_prebuilt_target @@ -172,6 +176,47 @@ def test_buildroot_script_builds_output_on_container_filesystem(tmp_path): assert "cp /work/output/images/sdcard.img" not in script +def test_allwinner_t113_platform_uses_armhf_and_mangopi_defconfig(tmp_path): + spec = buildroot_platform_spec(BUILDROOT_ALLWINNER_T113_S3) + config_path = tmp_path / "frameos-buildroot.config" + post_image_path = tmp_path / "post-image.sh" + script_path = tmp_path / "buildroot-build.sh" + fragment_path = tmp_path / "linux-fragment.config" + + BuildrootImageBuilder._write_buildroot_config(config_path, spec) + BuildrootImageBuilder._write_post_image_script(post_image_path, spec) + BuildrootImageBuilder._write_build_script(script_path, "frameos-test.img", spec) + BuildrootImageBuilder._write_kernel_config_fragment(fragment_path, spec) + + config = config_path.read_text(encoding="utf-8") + post_image = post_image_path.read_text(encoding="utf-8") + script = script_path.read_text(encoding="utf-8") + fragment = fragment_path.read_text(encoding="utf-8") + + assert spec.frameos_target.arch == "armhf" + assert normalize_buildroot_platform("t113-s3") == BUILDROOT_ALLWINNER_T113_S3 + assert "make -C /build/buildroot O=/build/output mangopi_mq1rdw2_defconfig" in script + assert "BR2_PACKAGE_RTL8723DS=y" in config + assert 'BR2_TARGET_GENERIC_GETTY_PORT="ttyS3"' in config + assert 'BR2_ROOTFS_POST_BUILD_SCRIPT="/work/post-build.sh /work/partition-post-build.sh"' in config + assert "u-boot-sunxi-with-spl.bin" in post_image + assert "offset = 8K" in post_image + assert "sun8i-t113s-mangopi-mq-r-t113.dtb" in post_image + assert "root=/dev/mmcblk0p2" in post_image + assert "CONFIG_DRM_SUN4I=y" in fragment + assert "gpu_mem=32" not in post_image + + +@pytest.mark.asyncio +async def test_buildroot_base_resolver_returns_none_when_platform_has_no_cached_image(monkeypatch): + async def fake_manifest(): + return {"entries": []} + + monkeypatch.setattr("app.tasks.buildroot_image._buildroot_base_manifest", fake_manifest) + + assert await try_resolve_buildroot_base_entry(BUILDROOT_ALLWINNER_T113_S3) is None + + def test_buildroot_partition_scripts_create_frameos_and_assets_partitions(tmp_path): partition_post_build_path = tmp_path / "partition-post-build.sh" post_image_path = tmp_path / "post-image.sh" @@ -555,7 +600,7 @@ def test_buildroot_copies_lgpio_runtime_libraries(tmp_path, monkeypatch): frame=SimpleNamespace(id=1), ) - monkeypatch.setattr("app.tasks.buildroot_image._lgpio_runtime_library_paths", lambda: [liblgpio, librgpio]) + monkeypatch.setattr("app.tasks.buildroot_image._lgpio_runtime_library_paths", lambda _target=FRAMEOS_BUILD_TARGET: [liblgpio, librgpio]) builder._copy_runtime_libraries(tmp_path / "overlay") assert (tmp_path / "overlay" / "usr" / "lib" / "liblgpio.so.1").read_bytes() == b"lgpio" @@ -569,7 +614,7 @@ def test_buildroot_requires_lgpio_runtime_libraries(tmp_path, monkeypatch): frame=SimpleNamespace(id=1), ) - monkeypatch.setattr("app.tasks.buildroot_image._lgpio_runtime_library_paths", lambda: []) + monkeypatch.setattr("app.tasks.buildroot_image._lgpio_runtime_library_paths", lambda _target=FRAMEOS_BUILD_TARGET: []) with pytest.raises(RuntimeError, match="requires lgpio runtime libraries"): builder._copy_runtime_libraries(tmp_path / "overlay") diff --git a/docs/buildroot-allwinner-t113.md b/docs/buildroot-allwinner-t113.md new file mode 100644 index 000000000..dc8be2859 --- /dev/null +++ b/docs/buildroot-allwinner-t113.md @@ -0,0 +1,87 @@ +# Buildroot Allwinner T113-S3/S4 Target + +## Status + +FrameOS has an experimental Buildroot target for Allwinner T113-S3 and T113-S4 compatible devices. The platform slug is wired into the backend SD-card image builder and the frontend platform selector as: + +- `allwinner-t113-s3` + +The frontend shows one option: `Allwinner T113-S3/S4 compatible`. There is no separate `allwinner-t113-s4` platform value; S4-compatible boards use the S3 target until a board-specific device tree or U-Boot configuration proves otherwise. + +## Image Layout + +The generated SD image uses the same FrameOS partition model as the Raspberry Pi Buildroot target, with a sunxi-specific bootloader placement: + +1. Hidden SPL/U-Boot payload at 8 KiB: `u-boot-sunxi-with-spl.bin` +2. `BOOT` FAT partition with `zImage`, the board DTB, and `extlinux/extlinux.conf` +3. root filesystem ext4 partition +4. `FRAMEOS` ext4 partition mounted at `/srv/frameos` +5. `ASSETS` FAT partition mounted at `/srv/assets` + +The boot command uses U-Boot extlinux and points Linux at `/dev/mmcblk0p2`, because the visible BOOT partition is first and the root filesystem is second. + +## Buildroot Baseline + +The target is based on Buildroot `2025.02.13`, which includes: + +- `configs/mangopi_mq1rdw2_defconfig` +- Linux `6.6.5` +- U-Boot `2024.01-rc4` +- `sun8i-t113s-mangopi-mq-r-t113.dtb` +- RTL8723DS package support for the MangoPi MQ-R WiFi module + +FrameOS overlays the baseline with systemd, NetworkManager, Dropbear, ImageMagick, FFmpeg, timezone data, FrameOS runtime files, the FrameOS agent, and the SD-card setup payload. + +## Compatibility Notes + +Expected to work: + +- ARMv7 hard-float FrameOS and agent binaries (`debian-bookworm-armhf` cross target) +- SD-card boot on MangoPi MQ-R compatible T113-S3 hardware +- Ethernet DHCP where exposed as `eth0` +- NetworkManager-based WiFi when the board uses supported RTL8723DS wiring/firmware +- FrameOS setup payload from the BOOT partition +- `/srv/frameos` and `/srv/assets` persistent partitions + +Needs device validation: + +- T113-S4 boot and memory initialization on the specific devboard +- The devboard's exact device tree, especially display, SPI, GPIO, USB host/OTG, and WiFi wiring +- UART console. The upstream board uses `ttyS3` at 115200 baud. +- FrameOS e-paper GPIO/SPI pin mapping. Existing display drivers still assume Raspberry Pi-style defaults unless the driver/runtime configuration is adjusted. +- LCD/RGB panel support. The baseline enables sunxi DRM pieces, but panel timings and connectors are board-specific. + +## Building The Cached Base Image + +Build the reusable base image locally: + +```bash +python tools/buildroot-images/buildroot_images.py --platform allwinner-t113-s3 build +``` + +Upload/publish the base image with the same CLI flow used for the Pi target: + +```bash +python tools/buildroot-images/buildroot_images.py --platform allwinner-t113-s3 upload --yes +``` + +The remote manifest only needs the S3 base image while S3 and S4 remain compatible. + +The backend SD-card download flow first tries to resolve cached base images by platform from `tools/buildroot-images/manifest.json` locally or from the archive manifest remotely. If no cached base image exists yet for the selected platform, the worker falls back to a full local Buildroot build and still produces the frame-specific SD image. + +## UI Flow + +When adding a frame with "Download SD card", pick `Allwinner T113-S3/S4 compatible`. The selected platform is stored in `frame.buildroot.platform` as `allwinner-t113-s3`. SD-card generation then: + +1. Builds FrameOS and the agent for `armhf`. +2. Downloads the matching cached Buildroot base image for the selected platform, or builds one locally if no cache entry exists. +3. Replaces the BOOT, FRAMEOS, and ASSETS payloads with frame-specific content when using a cached base image. +4. Returns a compressed `.img.gz` download. + +## Current Gaps + +- No board-specific T113-S4 DTS is included. +- No touchscreen/camera/audio connector configuration has been added. +- No visual/display validation has been run on physical hardware. +- No cached Allwinner base image is present in the checked-in manifest until the build/upload flow is run; first use may be slow because it can fall back to a full local Buildroot build. +- WiFi credentials are written for NetworkManager. Boards using a different WiFi module may need firmware/package changes. diff --git a/docs/buildroot-raspberry-pi-zero-2w.md b/docs/buildroot-raspberry-pi-zero-2w.md new file mode 100644 index 000000000..f81932044 --- /dev/null +++ b/docs/buildroot-raspberry-pi-zero-2w.md @@ -0,0 +1,92 @@ +# Buildroot Raspberry Pi Zero 2 W Target + +## Status + +FrameOS has a Buildroot target for Raspberry Pi Zero 2 W devices. The platform slug is wired into the backend SD-card image builder and the frontend platform selector as: + +- `raspberry-pi-zero-2-w` + +The frontend shows this as `Raspberry Pi Zero 2 W`. This is the established Buildroot target and is the reference implementation for the newer Allwinner T113 target. + +## Image Layout + +The generated SD image uses the FrameOS partition model with Raspberry Pi firmware boot files in the first FAT partition: + +1. `BOOT` FAT partition with Raspberry Pi firmware files, `config.txt`, `cmdline.txt`, kernel, and device trees +2. root filesystem ext4 partition +3. `FRAMEOS` ext4 partition mounted at `/srv/frameos` +4. `ASSETS` FAT partition mounted at `/srv/assets` + +Unlike sunxi boards, there is no hidden SPL/U-Boot payload. The Raspberry Pi firmware loads the configured kernel from the BOOT partition. + +## Buildroot Baseline + +The target is based on Buildroot `2025.02.13`, using: + +- `configs/raspberrypizero2w_64_defconfig` +- Raspberry Pi firmware boot flow +- ARM64 FrameOS and agent binaries (`debian-bookworm-arm64`) +- Broadcom/Cypress SDIO WiFi firmware packages for Raspberry Pi boards + +FrameOS overlays the baseline with systemd, NetworkManager, Dropbear, ImageMagick, FFmpeg, timezone data, FrameOS runtime files, the FrameOS agent, and the SD-card setup payload. + +## Boot Customization + +The post-image step adjusts the Buildroot Raspberry Pi firmware output before `genimage` assembles the SD image: + +- Ensures `cmdline.txt` contains `console=tty1`. +- Ensures `cmdline.txt` contains `fbcon=logo-count:1`. +- Normalizes Raspberry Pi GPU memory settings and applies `gpu_mem=32`. +- Copies Raspberry Pi firmware files, DTBs, and the configured kernel into the BOOT partition. + +Frame-specific image composition can later patch the BOOT partition without rebuilding the full base image. It merges `config.txt` and `firmware/config.txt`, including GPU memory overrides, and copies setup/network/SSH payload files into BOOT. + +## Compatibility Notes + +Tested and works: + +- SD-card boot on Raspberry Pi Zero 2 W +- ARM64 FrameOS and agent binaries +- HDMI framebuffer output with a small firmware GPU memory reserve +- NetworkManager-based WiFi using the Pi SDIO firmware packages +- Dropbear SSH after setup +- FrameOS setup payload from the BOOT partition +- `/srv/frameos` and `/srv/assets` persistent partitions + +Needs device validation when hardware changes: + +- Nonstandard displays and hats, especially SPI/e-paper pin mappings +- USB gadgets, USB Ethernet adapters, and nonstandard network paths +- Bluetooth-specific behavior, if needed by a deployment +- Any custom `config.txt` overlays required by attached hardware + +## Building The Cached Base Image + +Build the reusable base image locally: + +```bash +python tools/buildroot-images/buildroot_images.py --platform raspberry-pi-zero-2-w build +``` + +Upload/publish the base image: + +```bash +python tools/buildroot-images/buildroot_images.py --platform raspberry-pi-zero-2-w upload --yes +``` + +The backend SD-card download flow first tries to resolve cached base images by platform from `tools/buildroot-images/manifest.json` locally or from the archive manifest remotely. If no cached base image exists yet for the selected platform, the worker falls back to a full local Buildroot build and still produces the frame-specific SD image. + +## UI Flow + +When adding a frame with "Download SD card", pick `Raspberry Pi Zero 2 W`. The selected platform is stored in `frame.buildroot.platform` as `raspberry-pi-zero-2-w`. SD-card generation then: + +1. Builds FrameOS and the agent for `aarch64`. +2. Downloads the matching cached Buildroot base image for the selected platform, or builds one locally if no cache entry exists. +3. Replaces the BOOT, FRAMEOS, and ASSETS payloads with frame-specific content when using a cached base image. +4. Returns a compressed `.img.gz` download. + +## Current Gaps + +- Physical validation still depends on the exact display, hat, and GPIO/SPI wiring used by a frame. +- The default boot configuration is intentionally minimal; custom hardware may require additional `config.txt` overlays. +- No separate Pi Zero 2 W variants are modeled for carrier boards or hats. diff --git a/frontend/src/devices.ts b/frontend/src/devices.ts index 823920ca5..f242b7831 100644 --- a/frontend/src/devices.ts +++ b/frontend/src/devices.ts @@ -211,9 +211,11 @@ export const withCustomPalette: Record = { } export const BUILDROOT_RASPBERRY_PI_ZERO_2_W = 'raspberry-pi-zero-2-w' +export const BUILDROOT_ALLWINNER_T113_S3 = 'allwinner-t113-s3' export const buildrootPlatforms: Option[] = [ { value: BUILDROOT_RASPBERRY_PI_ZERO_2_W, label: 'Raspberry Pi Zero 2 W' }, + { value: BUILDROOT_ALLWINNER_T113_S3, label: 'Allwinner T113-S3/S4 compatible' }, ] export const rpiOSPlatforms: Option[] = [ diff --git a/tools/buildroot-images/buildroot_images.py b/tools/buildroot-images/buildroot_images.py index 0de3eb3ad..8637a4c87 100644 --- a/tools/buildroot-images/buildroot_images.py +++ b/tools/buildroot-images/buildroot_images.py @@ -27,20 +27,19 @@ from app.tasks.buildroot_image import ( # noqa: E402 BUILDROOT_ASSETS_PARTITION_SIZE, - BUILDROOT_DEFCONFIG, BUILDROOT_DOCKER_APT_DEPS_LINE, BUILDROOT_DOCKER_IMAGE, BUILDROOT_DOCKER_NOFILE_LIMIT, BUILDROOT_FRAMEOS_PARTITION_SIZE, BUILDROOT_VERSION, BuildrootImageBuilder, - FRAMEOS_BUILD_TARGET, SUPPORTED_BUILDROOT_PLATFORM, _mbr_partitions, copy_lgpio_runtime_libraries, ensure_buildroot_base_image, resolve_buildroot_base_entry, buildroot_base_cache_dir, + buildroot_platform_spec, _gzip_file, _sha256, normalize_buildroot_platform, @@ -80,12 +79,13 @@ def parse_args() -> argparse.Namespace: release = sub.add_parser("release-image", help="Build a release-ready SD image from precompiled artifacts") release.add_argument("--prebuilt-cross-dir", default=str(REPO_ROOT / "build" / "prebuilt-cross")) release.add_argument("--release-assets-dir", default=str(REPO_ROOT / "release-assets")) - release.add_argument("--target", default="debian-bookworm-arm64") + release.add_argument("--target", default=None) release.add_argument("--version", default=None) sub.add_parser("list", help="List manifest entries in R2") download = sub.add_parser("download", help="Download the manifest to the repo") download.add_argument("--force", action="store_true") args = parser.parse_args() + args.platform = normalize_buildroot_platform(args.platform) if args.manifest_key is None: args.manifest_key = f"{args.prefix}/manifest.json" return args @@ -286,7 +286,8 @@ def legacy_local_dir(platform: str) -> Path: return BUILD_DIR / platform / raw_frameos_version() -def write_base_bootstrap_overlay(overlay: Path) -> None: +def write_base_bootstrap_overlay(overlay: Path, platform: str = SUPPORTED_BUILDROOT_PLATFORM) -> None: + spec = buildroot_platform_spec(platform) systemd = overlay / "etc" / "systemd" / "system" wants = systemd / "multi-user.target.wants" wants.mkdir(parents=True, exist_ok=True) @@ -331,10 +332,11 @@ def write_base_bootstrap_overlay(overlay: Path) -> None: ) (overlay / "srv" / "frameos").mkdir(parents=True, exist_ok=True) (overlay / "srv" / "assets").mkdir(parents=True, exist_ok=True) - copy_lgpio_runtime_libraries(overlay) + copy_lgpio_runtime_libraries(overlay, spec.frameos_target) def build(args: argparse.Namespace) -> None: + spec = buildroot_platform_spec(args.platform) out_dir = local_dir(args.platform) out_dir.mkdir(parents=True, exist_ok=True) image_path = out_dir / "base.img" @@ -342,14 +344,14 @@ def build(args: argparse.Namespace) -> None: with tempfile.TemporaryDirectory(prefix="frameos-buildroot-base-") as tmp: tmp_path = Path(tmp) overlay = tmp_path / "overlay" - write_base_bootstrap_overlay(overlay) - BuildrootImageBuilder._write_buildroot_config(tmp_path / "frameos-buildroot.config") - BuildrootImageBuilder._write_kernel_config_fragment(tmp_path / "linux-fragment.config") + write_base_bootstrap_overlay(overlay, args.platform) + BuildrootImageBuilder._write_buildroot_config(tmp_path / "frameos-buildroot.config", spec) + BuildrootImageBuilder._write_kernel_config_fragment(tmp_path / "linux-fragment.config", spec) BuildrootImageBuilder._write_post_build_script(tmp_path / "post-build.sh") BuildrootImageBuilder._write_partition_post_build_script(tmp_path / "partition-post-build.sh") - BuildrootImageBuilder._write_post_image_script(tmp_path / "post-image.sh") + BuildrootImageBuilder._write_post_image_script(tmp_path / "post-image.sh", spec) BuildrootImageBuilder._write_boot_logo(tmp_path / "frameos-boot-logo.png") - BuildrootImageBuilder._write_build_script(tmp_path / "buildroot-build.sh", "base.img") + BuildrootImageBuilder._write_build_script(tmp_path / "buildroot-build.sh", "base.img", spec) container_name = f"frameos-buildroot-base-{uuid.uuid4().hex[:12]}" container_id = subprocess.check_output( [ @@ -377,7 +379,7 @@ def build(args: argparse.Namespace) -> None: "platform": args.platform, "frameos_version": frameos_version(), "buildroot_version": BUILDROOT_VERSION, - "defconfig": BUILDROOT_DEFCONFIG, + "defconfig": spec.defconfig, "docker_image": BUILDROOT_DOCKER_IMAGE, "buildroot_apt_deps": BUILDROOT_DOCKER_APT_DEPS_LINE, "frameos_partition_size": BUILDROOT_FRAMEOS_PARTITION_SIZE, @@ -515,11 +517,12 @@ def _copy_runtime_libraries(self, overlay_dir: Path) -> None: async def build_release_image(args: argparse.Namespace) -> None: platform = normalize_buildroot_platform(args.platform) + spec = buildroot_platform_spec(platform) version = safe_segment(args.version or release_version()) if not version: raise SystemExit("Unable to determine release version") - target = str(args.target) + target = str(args.target or f"{spec.frameos_target.distro}-{spec.frameos_target.version}-{spec.frameos_target.arch}") prebuilt_cross_dir = Path(args.prebuilt_cross_dir) release_assets_dir = Path(args.release_assets_dir) release_assets_dir.mkdir(parents=True, exist_ok=True) @@ -538,6 +541,7 @@ async def build_release_image(args: argparse.Namespace) -> None: metadata = json.loads((artifact_root / "metadata.json").read_text(encoding="utf-8")) frame = ReleaseImageFrame() + frame.buildroot = {"platform": platform} build_id = safe_segment(version) raw_output_path = release_assets_dir / f"frameos-{version}-{platform}-buildroot.img" output_path = release_assets_dir / f"{raw_output_path.name}.gz" @@ -547,11 +551,11 @@ async def build_release_image(args: argparse.Namespace) -> None: frameos_build = FrameBinaryBuildResult( build_id=build_id, target=TargetMetadata( - arch=FRAMEOS_BUILD_TARGET.arch, - distro=FRAMEOS_BUILD_TARGET.distro, - version=FRAMEOS_BUILD_TARGET.version, - platform="linux/arm64", - image="debian:bookworm", + arch=spec.frameos_target.arch, + distro=spec.frameos_target.distro, + version=spec.frameos_target.version, + platform=spec.frameos_target.platform, + image=spec.frameos_target.image, ), compilation_mode=str(metadata.get("compilation_mode") or "shared"), source_dir=str(artifact_root), From f7f851842a2fcce6d4d7a0bb0077377ee561cd10 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 3 Jun 2026 17:12:07 +0000 Subject: [PATCH 2/2] Publish Allwinner T113 Buildroot image --- tools/buildroot-images/buildroot_images.py | 17 ++++++++++- tools/buildroot-images/manifest.json | 33 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/tools/buildroot-images/buildroot_images.py b/tools/buildroot-images/buildroot_images.py index 8637a4c87..410a00858 100644 --- a/tools/buildroot-images/buildroot_images.py +++ b/tools/buildroot-images/buildroot_images.py @@ -426,6 +426,7 @@ def upload(args: argparse.Namespace) -> None: raise SystemExit(f"Re-run with --yes to upload {object_key}") client = s3_client() entry = {**metadata, "object_key": object_key, "updated_at": datetime.now(timezone.utc).isoformat()} + manifest = load_manifest(client, args.bucket, args.manifest_key) remote_exists = False if not args.force: try: @@ -443,7 +444,21 @@ def upload(args: argparse.Namespace) -> None: shutil.copyfileobj(source, output) client.upload_file(str(archive_path), args.bucket, object_key, ExtraArgs={"ContentType": "application/gzip"}) print(f"Uploaded s3://{args.bucket}/{object_key}") - save_manifest(client, args.bucket, args.manifest_key, {"entries": [entry]}) + entries = [ + existing + for existing in manifest.get("entries", []) + if not ( + existing.get("platform") == entry.get("platform") + and existing.get("frameos_version") == entry.get("frameos_version") + ) + ] + entries.append(entry) + save_manifest( + client, + args.bucket, + args.manifest_key, + {"entries": sorted(entries, key=lambda item: (str(item.get("platform") or ""), str(item.get("frameos_version") or "")))}, + ) def _safe_extract(tar: tarfile.TarFile, path: Path) -> None: diff --git a/tools/buildroot-images/manifest.json b/tools/buildroot-images/manifest.json index 16162ecb9..b13042ad4 100644 --- a/tools/buildroot-images/manifest.json +++ b/tools/buildroot-images/manifest.json @@ -1,5 +1,38 @@ { "entries": [ + { + "assets_partition_size": "512M", + "buildroot_apt_deps": "bc bison build-essential ca-certificates cpio curl file flex g++ gfortran genimage git dosfstools e2fsprogs libncurses-dev libssl-dev make mtools perl python3 rsync unzip wget xdg-utils xz-utils", + "buildroot_version": "2025.02.13", + "created_at": "2026-06-03T16:47:40.087447+00:00", + "defconfig": "mangopi_mq1rdw2_defconfig", + "docker_image": "debian:bookworm", + "frameos_partition_size": "1G", + "frameos_version": "2026.6.7", + "object_key": "buildroot-images/allwinner-t113-s3/2026.6.7/allwinner-t113-s3-2026.6.7-89c25c5e26058847.img.gz", + "partitions": [ + { + "size": 33554432, + "start": 1048576 + }, + { + "size": 805306368, + "start": 34603008 + }, + { + "size": 1073741824, + "start": 839909376 + }, + { + "size": 536870912, + "start": 1913651200 + } + ], + "platform": "allwinner-t113-s3", + "sha256": "89c25c5e2605884750a92548667c229f36a1e2d5a8a35d691e8e506fd921b576", + "size": 2450522112, + "updated_at": "2026-06-03T17:10:09.169849+00:00" + }, { "assets_partition_size": "512M", "buildroot_apt_deps": "bc bison build-essential ca-certificates cpio curl file flex g++ gfortran genimage git dosfstools e2fsprogs libncurses-dev libssl-dev make mtools perl python3 rsync unzip wget xdg-utils xz-utils",