diff --git a/install/devices.py b/install/devices.py new file mode 100644 index 00000000..419c4396 --- /dev/null +++ b/install/devices.py @@ -0,0 +1,380 @@ +"""Device and OS configuration models for SD card flashing. + +This module defines the hardware and operating system characteristics needed +for automated SD card preparation. +""" + +from dataclasses import dataclass +from enum import Enum + + +class ChipFamily(str, Enum): + """SoC chip families.""" + + BCM2711 = "bcm2711" # Raspberry Pi 4 + BCM2712 = "bcm2712" # Raspberry Pi 5 + RK3588 = "rk3588" # Rockchip (Orange Pi 5 series, ROCK 5B) + H618 = "h618" # Allwinner (Orange Pi Zero 2W) + S905X = "s905x" # Amlogic (Le Potato) + + +@dataclass(frozen=True) +class DeviceSpec: + """Hardware device specification.""" + + key: str # Internal identifier (e.g., "orange_pi_5_pro") + name: str # Human-readable name (e.g., "Orange Pi 5 Pro") + chip: ChipFamily + has_wifi: bool + has_spi: bool + + # OS-specific boot partition paths + # Key: os_key, Value: boot partition mount point + boot_partitions: dict[str, str] + + # SPI overlay names for enabling ePaper HAT + # Key: os_key, Value: overlay name + spi_overlays: dict[str, str | None] + + +@dataclass(frozen=True) +class ImageSource: + """OS image download source.""" + + url: str + sha256: str | None = None # Optional checksum + is_armbian: bool = False # Special handling for Armbian redirects + is_dietpi: bool = False # Special handling for DietPi + + +@dataclass(frozen=True) +class OSSpec: + """Operating system specification.""" + + key: str # Internal identifier (e.g., "dietpi", "raspbian") + name: str # Human-readable name (e.g., "DietPi", "Raspberry Pi OS") + + # Image URLs by device + # Key: device_key, Value: ImageSource + images: dict[str, ImageSource] + + +# Device definitions +DEVICES: dict[str, DeviceSpec] = { + # Raspberry Pi devices + "pi_zero2w": DeviceSpec( + key="pi_zero2w", + name="Raspberry Pi Zero 2 W", + chip=ChipFamily.BCM2711, + has_wifi=True, + has_spi=True, + boot_partitions={ + "raspbian": "/boot/firmware", + "dietpi": "/boot/firmware", + }, + spi_overlays={ + "raspbian": None, # dtparam=spi=on in config.txt + "dietpi": None, # dtparam=spi=on in config.txt + }, + ), + "pi_3": DeviceSpec( + key="pi_3", + name="Raspberry Pi 3", + chip=ChipFamily.BCM2711, + has_wifi=True, + has_spi=True, + boot_partitions={ + "raspbian": "/boot/firmware", + "dietpi": "/boot/firmware", + }, + spi_overlays={ + "raspbian": None, + "dietpi": None, + }, + ), + "pi_4": DeviceSpec( + key="pi_4", + name="Raspberry Pi 4", + chip=ChipFamily.BCM2711, + has_wifi=True, + has_spi=True, + boot_partitions={ + "raspbian": "/boot/firmware", + "dietpi": "/boot/firmware", + }, + spi_overlays={ + "raspbian": None, + "dietpi": None, + }, + ), + "pi_5": DeviceSpec( + key="pi_5", + name="Raspberry Pi 5", + chip=ChipFamily.BCM2712, + has_wifi=True, + has_spi=True, + boot_partitions={ + "raspbian": "/boot/firmware", + "dietpi": "/boot/firmware", + }, + spi_overlays={ + "raspbian": None, + "dietpi": None, + }, + ), + # Orange Pi devices + "orange_pi_0w2": DeviceSpec( + key="orange_pi_0w2", + name="Orange Pi Zero 2W", + chip=ChipFamily.H618, + has_wifi=True, + has_spi=True, + boot_partitions={ + "armbian": "/boot", + "dietpi": "/boot", + }, + spi_overlays={ + "armbian": "spi-spidev", # Allwinner H618 + "dietpi": "spi-spidev", + }, + ), + "orange_pi_5_plus": DeviceSpec( + key="orange_pi_5_plus", + name="Orange Pi 5 Plus", + chip=ChipFamily.RK3588, + has_wifi=True, + has_spi=True, + boot_partitions={ + "armbian": "/boot", + "dietpi": "/boot", + }, + spi_overlays={ + "armbian": "rk3588-spi4-m0-cs1-spidev", # RK3588 + "dietpi": "rk3588-spi4-m0-cs1-spidev", + }, + ), + "orange_pi_5_pro": DeviceSpec( + key="orange_pi_5_pro", + name="Orange Pi 5 Pro", + chip=ChipFamily.RK3588, + has_wifi=True, + has_spi=True, + boot_partitions={ + "armbian": "/boot", + "dietpi": "/boot", # DIETPISETUP partition at /boot, not /boot/firmware + }, + spi_overlays={ + "armbian": "rk3588-spi4-m0-cs1-spidev", + "dietpi": "rk3588-spi4-m0-cs1-spidev", + }, + ), + # Other devices + "le_potato": DeviceSpec( + key="le_potato", + name="Libre Computer Le Potato", + chip=ChipFamily.S905X, + has_wifi=False, + has_spi=True, + boot_partitions={ + "armbian": "/boot", + "dietpi": "/boot", + }, + spi_overlays={ + "armbian": None, # TODO: Verify SPI overlay for Amlogic + "dietpi": None, + }, + ), + "rock_5b": DeviceSpec( + key="rock_5b", + name="Radxa ROCK 5B", + chip=ChipFamily.RK3588, + has_wifi=False, + has_spi=True, + boot_partitions={ + "armbian": "/boot", + "dietpi": "/boot", + }, + spi_overlays={ + "armbian": "rk3588-spi1-m1-cs0-spidev", # Alternative: spi3-m1 + "dietpi": "rk3588-spi1-m1-cs0-spidev", + }, + ), +} + + +# Operating system definitions +OPERATING_SYSTEMS: dict[str, OSSpec] = { + "raspbian": OSSpec( + key="raspbian", + name="Raspberry Pi OS", + images={ + "pi_zero2w": ImageSource( + url="https://downloads.raspberrypi.org/raspios_lite_arm64/images/raspios_lite_arm64-2024-07-04/2024-07-04-raspios-bookworm-arm64-lite.img.xz", + sha256="3e8d1d7166aa832aded24e90484d83f4e8ad594b5a33bb4a9a1ff3ac0ac84d92", + ), + "pi_3": ImageSource( + url="https://downloads.raspberrypi.org/raspios_lite_arm64/images/raspios_lite_arm64-2024-07-04/2024-07-04-raspios-bookworm-arm64-lite.img.xz", + sha256="3e8d1d7166aa832aded24e90484d83f4e8ad594b5a33bb4a9a1ff3ac0ac84d92", + ), + "pi_4": ImageSource( + url="https://downloads.raspberrypi.org/raspios_lite_arm64/images/raspios_lite_arm64-2024-07-04/2024-07-04-raspios-bookworm-arm64-lite.img.xz", + sha256="3e8d1d7166aa832aded24e90484d83f4e8ad594b5a33bb4a9a1ff3ac0ac84d92", + ), + "pi_5": ImageSource( + url="https://downloads.raspberrypi.org/raspios_lite_arm64/images/raspios_lite_arm64-2024-07-04/2024-07-04-raspios-bookworm-arm64-lite.img.xz", + sha256="3e8d1d7166aa832aded24e90484d83f4e8ad594b5a33bb4a9a1ff3ac0ac84d92", + ), + }, + ), + "armbian": OSSpec( + key="armbian", + name="Armbian", + images={ + "orange_pi_0w2": ImageSource( + url="https://dl.armbian.com/orangepizero2w/Bookworm_current_minimal", + is_armbian=True, + ), + "orange_pi_5_plus": ImageSource( + url="https://dl.armbian.com/orangepi5-plus/Bookworm_current_minimal", + is_armbian=True, + ), + "orange_pi_5_pro": ImageSource( + url="https://dl.armbian.com/orangepi5pro/Trixie_vendor_minimal", + is_armbian=True, + ), + "rock_5b": ImageSource( + url="https://dl.armbian.com/rock-5b/Bookworm_current_minimal", + is_armbian=True, + ), + "le_potato": ImageSource( + url="https://dl.armbian.com/lepotato/Bookworm_current_minimal", + is_armbian=True, + ), + }, + ), + "dietpi": OSSpec( + key="dietpi", + name="DietPi", + images={ + "pi_zero2w": ImageSource( + url="https://dietpi.com/downloads/images/DietPi_RPiZero2W-ARMv8-Bookworm.img.xz", + is_dietpi=True, + ), + "pi_3": ImageSource( + url="https://dietpi.com/downloads/images/DietPi_RPi3-ARMv8-Bookworm.img.xz", + is_dietpi=True, + ), + "pi_4": ImageSource( + url="https://dietpi.com/downloads/images/DietPi_RPi4-ARMv8-Bookworm.img.xz", + is_dietpi=True, + ), + "pi_5": ImageSource( + url="https://dietpi.com/downloads/images/DietPi_RPi5-ARMv8-Bookworm.img.xz", + is_dietpi=True, + ), + "orange_pi_0w2": ImageSource( + url="https://dietpi.com/downloads/images/DietPi_OrangePiZero2W-ARMv8-Bookworm.img.xz", + is_dietpi=True, + ), + "orange_pi_5_plus": ImageSource( + url="https://dietpi.com/downloads/images/DietPi_OrangePi5Plus-ARMv8-Bookworm.img.xz", + is_dietpi=True, + ), + "orange_pi_5_pro": ImageSource( + url="https://dietpi.com/downloads/images/DietPi_OrangePi5Pro-ARMv8-Bookworm.img.xz", + is_dietpi=True, + ), + "rock_5b": ImageSource( + url="https://dietpi.com/downloads/images/DietPi_ROCK5B-ARMv8-Bookworm.img.xz", + is_dietpi=True, + ), + "le_potato": ImageSource( + url="https://dietpi.com/downloads/images/DietPi_LePotato-ARMv8-Bookworm.img.xz", + is_dietpi=True, + ), + }, + ), +} + + +def get_boot_partition(device_key: str, os_key: str) -> str: + """Get the boot partition mount point for a device/OS combination. + + Args: + device_key: Device identifier (e.g., "orange_pi_5_pro") + os_key: OS identifier (e.g., "dietpi") + + Returns: + Boot partition path (e.g., "/boot" or "/boot/firmware") + + Raises: + KeyError: If device or OS combination is not supported + """ + device = DEVICES[device_key] + return device.boot_partitions[os_key] + + +def get_spi_overlay(device_key: str, os_key: str) -> str | None: + """Get the SPI overlay name for a device/OS combination. + + Args: + device_key: Device identifier + os_key: OS identifier + + Returns: + SPI overlay name, or None if handled via config.txt dtparam + + Raises: + KeyError: If device or OS combination is not supported + """ + device = DEVICES[device_key] + return device.spi_overlays[os_key] + + +def get_capabilities(device_key: str) -> dict[str, bool]: + """Get hardware capabilities for a device. + + Args: + device_key: Device identifier + + Returns: + Dictionary with capability flags (has_wifi, has_spi) + + Raises: + KeyError: If device is not supported + """ + device = DEVICES[device_key] + return { + "has_wifi": device.has_wifi, + "has_spi": device.has_spi, + } + + +def build_os_images_dict() -> dict[str, dict]: + """Build legacy OS_IMAGES dictionary structure for TUI compatibility. + + Returns: + Dictionary in format: {os_key: {"name": str, "devices": {device_key: {...}}}} + """ + os_images = {} + + for os_key, os_spec in OPERATING_SYSTEMS.items(): + devices_dict = {} + for device_key in os_spec.images.keys(): + device_spec = DEVICES[device_key] + image_source = os_spec.images[device_key] + + devices_dict[device_key] = { + "name": device_spec.name, + "url": image_source.url, + "sha256": image_source.sha256, + "is_armbian": image_source.is_armbian, + "is_dietpi": image_source.is_dietpi, + } + + os_images[os_key] = { + "name": os_spec.name, + "devices": devices_dict, + } + + return os_images diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 50da7c69..f27fa071 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -151,21 +151,21 @@ def find_command(cmd: str, homebrew_paths: list[str] | None = None) -> str: "has_wifi": False, # No WiFi hardware "has_spi": True, # GPIO header supports SPI }, - "orangepi5": { + "orange_pi_0w2": { "has_wifi": True, # Built-in WiFi - "has_spi": True, + "has_spi": True, # Allwinner H618 supports SPI }, - "orangepi5plus": { + "orange_pi_5_plus": { "has_wifi": True, # Built-in WiFi - "has_spi": True, + "has_spi": True, # RK3588 supports SPI }, - "orangepi5pro": { + "orange_pi_5_pro": { "has_wifi": True, # Built-in WiFi - "has_spi": True, + "has_spi": True, # RK3588 supports SPI }, - "rock5b": { + "rock_5b": { "has_wifi": True, # M.2 WiFi module support - "has_spi": True, + "has_spi": True, # RK3588 supports SPI }, } @@ -208,7 +208,7 @@ def copy_installer_script( config: dict[str, Any], os_key: str, device_key: str, -) -> None: +) -> Path | None: """Copy install.sh to boot partition with OS-specific handling. Args: @@ -216,6 +216,9 @@ def copy_installer_script( config: Configuration dictionary with copy_installer flag os_key: OS type for capability lookup device_key: Device key for capability lookup + + Returns: + Path to the modified install.sh temp file, or None if not copied """ caps = get_combined_capabilities(os_key, device_key) @@ -223,18 +226,48 @@ def copy_installer_script( # because the boot partition will be deleted after first boot needs_preservation = caps.get("install_sh_needs_preservation", False) - if not config.get("copy_installer") and not needs_preservation: - return + console.print(f"[cyan]DEBUG: os={os_key}, device={device_key}[/cyan]") + copy_inst = config.get("copy_installer") + console.print(f"[cyan]DEBUG: copy_installer={copy_inst}, preserve={needs_preservation}[/cyan]") + + if not copy_inst and not needs_preservation: + console.print("[yellow]DEBUG: Skipping (not needed)[/yellow]") + return None install_script = Path(__file__).parent / "install.sh" if not install_script.exists(): console.print("[yellow]Warning: install.sh not found, skipping copy[/yellow]") - return + return None - install_dest = boot_mount / "install.sh" + # Read install.sh and substitute repo/branch defaults if configured + install_content = install_script.read_text() + + # Debug: show what's in config + console.print(f"[cyan]DEBUG: config keys = {list(config.keys())}[/cyan]") + console.print(f"[cyan]DEBUG: birdnet_branch = {config.get('birdnet_branch')}[/cyan]") + console.print(f"[cyan]DEBUG: birdnet_repo_url = {config.get('birdnet_repo_url')}[/cyan]") + + # Replace REPO_URL default if custom repo configured + if config.get("birdnet_repo_url"): + repo_url = config["birdnet_repo_url"] + install_content = install_content.replace( + 'REPO_URL="${BIRDNETPI_REPO_URL:-https://github.com/mverteuil/BirdNET-Pi.git}"', + f'REPO_URL="${{BIRDNETPI_REPO_URL:-{repo_url}}}"', + ) + + # Replace BRANCH default if custom branch configured + if config.get("birdnet_branch"): + branch = config["birdnet_branch"] + install_content = install_content.replace( + 'BRANCH="${BIRDNETPI_BRANCH:-main}"', f'BRANCH="${{BIRDNETPI_BRANCH:-{branch}}}"' + ) + + # Write modified install.sh to temporary location, then copy to boot + temp_install = Path("/tmp/install.sh") + temp_install.write_text(install_content) - # Copy install.sh to boot partition - subprocess.run(["sudo", "cp", str(install_script), str(install_dest)], check=True) + install_dest = boot_mount / "install.sh" + subprocess.run(["sudo", "cp", str(temp_install), str(install_dest)], check=True) subprocess.run(["sudo", "chmod", "+x", str(install_dest)], check=True) # For OSes that need preservation (DietPi), create wrapper script @@ -244,44 +277,75 @@ def copy_installer_script( # Preserve and execute install.sh before/after DIETPISETUP partition is deleted # This script runs during DietPi first boot automation +LOGFILE="/var/log/birdnetpi_preserve.log" +exec >> "$LOGFILE" 2>&1 + +echo "=== BirdNET-Pi Installer Preservation Script ===" +echo "Started at: $(date)" +echo "Running as: $(whoami)" +echo "Working directory: $(pwd)" + +# Debug: Show what's mounted +echo "" +echo "=== Mounted filesystems ===" +mount | grep -E '/boot|/root' + +# Debug: Show what's in /boot locations +echo "" +echo "=== /boot/firmware contents ===" +ls -la /boot/firmware/ 2>&1 || echo "/boot/firmware does not exist" + +echo "" +echo "=== /boot contents ===" +ls -la /boot/ 2>&1 || echo "/boot does not exist" + # Try /boot/firmware first (Raspberry Pi), then /boot (other boards) if [ -f /boot/firmware/install.sh ]; then + echo "" + echo "Found install.sh at /boot/firmware/install.sh" cp /boot/firmware/install.sh {final_path} chmod +x {final_path} echo "Preserved install.sh from /boot/firmware/ to {final_path}" elif [ -f /boot/install.sh ]; then + echo "" + echo "Found install.sh at /boot/install.sh" cp /boot/install.sh {final_path} chmod +x {final_path} echo "Preserved install.sh from /boot/ to {final_path}" +else + echo "" + echo "ERROR: Could not find install.sh in /boot/firmware/ or /boot/" + echo "Skipping installation - install.sh must be run manually" + exit 0 # Don't fail DietPi automation, just skip fi -if [ -f /boot/firmware/birdnetpi_config.txt ]; then - cp /boot/firmware/birdnetpi_config.txt /root/birdnetpi_config.txt - echo "Preserved birdnetpi_config.txt from /boot/firmware/ to /root/" -elif [ -f /boot/birdnetpi_config.txt ]; then - cp /boot/birdnetpi_config.txt /root/birdnetpi_config.txt - echo "Preserved birdnetpi_config.txt from /boot/ to /root/" +if [ -f /boot/firmware/birdnetpi_config.json ]; then + cp /boot/firmware/birdnetpi_config.json /root/birdnetpi_config.json + echo "Preserved birdnetpi_config.json from /boot/firmware/ to /root/" +elif [ -f /boot/birdnetpi_config.json ]; then + cp /boot/birdnetpi_config.json /root/birdnetpi_config.json + echo "Preserved birdnetpi_config.json from /boot/ to /root/" fi -# Execute the preserved install.sh -# DietPi automation runs as root, so install.sh will run as root -if [ -f {final_path} ]; then - echo "Executing preserved install.sh from {final_path}" - cd /root - - # Source environment variables from config if present - if [ -f /root/birdnetpi_config.txt ]; then - echo "Loading environment variables from /root/birdnetpi_config.txt" - # Source only the export lines - source <(grep "^export " /root/birdnetpi_config.txt || true) - fi - - # Run install.sh - bash {final_path} -else - echo "ERROR: Could not find preserved install.sh at {final_path}" - exit 1 +# Verify preservation was successful +if [ ! -f {final_path} ]; then + echo "" + echo "ERROR: Failed to preserve install.sh to {final_path}" + echo "Installation must be run manually" + exit 0 # Don't fail DietPi automation fi + +echo "" +echo "Successfully preserved install.sh to {final_path}" +echo "Installation will NOT run automatically - run manually with:" +echo " sudo bash {final_path}" +echo "" +echo "Preservation complete at: $(date)" +echo "Log saved to: $LOGFILE" + +# NOTE: We do NOT execute install.sh automatically anymore +# Users should run it manually after first boot to have control +exit 0 """ preserve_script_path = boot_mount / "preserve_installer.sh" temp_preserve = Path("/tmp/preserve_installer.sh") @@ -289,66 +353,223 @@ def copy_installer_script( subprocess.run(["sudo", "cp", str(temp_preserve), str(preserve_script_path)], check=True) subprocess.run(["sudo", "chmod", "+x", str(preserve_script_path)], check=True) temp_preserve.unlink() - console.print(f"[green]✓ Copied install.sh with preservation to {final_path}[/green]") + + # ALSO create DietPi-Automation pre-script (runs BEFORE partition cleanup) + # This is critical because DIETPISETUP partition gets deleted after first boot + # Automation_Custom_PreScript.sh runs BEFORE the cleanup + automation_script_content = f"""#!/bin/bash +# DietPi Pre-Automation Script - runs BEFORE DIETPISETUP partition deletion +# This preserves install.sh from /boot BEFORE it gets deleted + +LOGFILE="/var/log/birdnetpi_automation.log" +exec >> "$LOGFILE" 2>&1 + +echo "=== BirdNET-Pi DietPi Pre-Automation Script ===" +echo "Started at: $(date)" +echo "Running as: $(whoami)" +echo "PWD: $(pwd)" + +# Show what boot partitions exist +echo "" +echo "=== Available boot partitions ===" +mount | grep -E "/boot" +echo "" + +# Try to preserve install.sh from boot partition +# On Orange Pi 5 Pro and similar, DIETPISETUP is at /boot (not /boot/firmware) +if [ -f /boot/install.sh ]; then + echo "Found install.sh at /boot/install.sh" + cp -v /boot/install.sh {final_path} + chmod +x {final_path} + echo "Preserved install.sh to {final_path}" +elif [ -f /boot/firmware/install.sh ]; then + echo "Found install.sh at /boot/firmware/install.sh" + cp -v /boot/firmware/install.sh {final_path} + chmod +x {final_path} + echo "Preserved install.sh to {final_path}" +else + echo "ERROR: Could not find install.sh in /boot or /boot/firmware" + echo "" + echo "=== /boot contents ===" + ls -la /boot/ 2>&1 || echo "/boot not accessible" + echo "" + echo "=== /boot/firmware contents ===" + ls -la /boot/firmware/ 2>&1 || echo "/boot/firmware not accessible" + exit 1 +fi + +# Also preserve config if present +if [ -f /boot/birdnetpi_config.json ]; then + cp -v /boot/birdnetpi_config.json /root/birdnetpi_config.json + echo "Preserved birdnetpi_config.json from /boot" +elif [ -f /boot/firmware/birdnetpi_config.json ]; then + cp -v /boot/firmware/birdnetpi_config.json /root/birdnetpi_config.json + echo "Preserved birdnetpi_config.json from /boot/firmware" +fi + +# Verify preservation +if [ -f {final_path} ]; then + echo "" + echo "SUCCESS: install.sh preserved to {final_path}" + ls -lh {final_path} + echo "" + echo "================================================" + echo "BirdNET-Pi installer is ready!" + echo "After first boot, run: sudo bash {final_path}" + echo "================================================" +else + echo "" + echo "FAILURE: Could not preserve install.sh" + exit 1 +fi + +echo "" +echo "Pre-automation script completed at: $(date)" +echo "Log saved to: $LOGFILE" +exit 0 +""" + # Create BOTH PreScript (runs before cleanup) and regular Script (runs after) + # PreScript is what we need, but we'll create both for maximum compatibility + prescript_path = boot_mount / "Automation_Custom_PreScript.sh" + script_path = boot_mount / "Automation_Custom_Script.sh" + + temp_automation = Path("/tmp/Automation_Custom_PreScript.sh") + temp_automation.write_text(automation_script_content) + subprocess.run(["sudo", "cp", str(temp_automation), str(prescript_path)], check=True) + subprocess.run(["sudo", "chmod", "+x", str(prescript_path)], check=True) + + # Also copy as regular script for backwards compatibility + subprocess.run(["sudo", "cp", str(temp_automation), str(script_path)], check=True) + subprocess.run(["sudo", "chmod", "+x", str(script_path)], check=True) + temp_automation.unlink() + + # Create README with installation instructions + readme_content = f"""BirdNET-Pi Installation Instructions for DietPi +=============================================== + +Your SD card has been configured for BirdNET-Pi installation! + +AFTER FIRST BOOT: +----------------- +1. SSH into your device +2. Check if install.sh was preserved: + + ls -l {final_path} + +3. If the file exists, run the installer: + + sudo bash {final_path} + +TROUBLESHOOTING: +---------------- +If {final_path} doesn't exist, check the preservation logs: + + cat /var/log/birdnetpi_preserve.log # AUTO_SETUP_CUSTOM_SCRIPT_EXEC log + cat /var/log/birdnetpi_automation.log # Automation_Custom_Script.sh log + +These logs show what happened during the preservation process. +At least one of these methods should work on your device. + +MANUAL INSTALLATION: +-------------------- +If preservation failed, you can still install BirdNET-Pi manually: + +1. Clone the repository: + git clone https://github.com/your-repo/BirdNET-Pi.git + cd BirdNET-Pi + +2. Run the installer: + sudo bash install/install.sh + +For more help, visit: https://github.com/your-repo/BirdNET-Pi +""" + readme_path = boot_mount / "BIRDNETPI_README.txt" + temp_readme = Path("/tmp/BIRDNETPI_README.txt") + temp_readme.write_text(readme_content) + subprocess.run(["sudo", "cp", str(temp_readme), str(readme_path)], check=True) + temp_readme.unlink() + + console.print("[green]✓ Copied install.sh with triple preservation methods:[/green]") + console.print("[dim] - preserve_installer.sh (via AUTO_SETUP_CUSTOM_SCRIPT_EXEC)[/dim]") + console.print( + "[dim] - Automation_Custom_PreScript.sh (runs BEFORE partition cleanup)[/dim]" + ) + console.print("[dim] - Automation_Custom_Script.sh (runs AFTER partition cleanup)[/dim]") + console.print(f"[dim] - Target location: {final_path}[/dim]") + console.print("[green]✓ Created BIRDNETPI_README.txt on boot partition[/green]") else: final_path = caps.get("install_sh_path", "/boot/install.sh") console.print(f"[green]✓ Copied install.sh to {final_path}[/green]") + # Return the temp file path so it can be used for rootfs copy + return temp_install -def copy_birdnetpi_config( + +def copy_birdnetpi_config( # noqa: C901 boot_mount: Path, config: dict[str, Any], -) -> None: - """Copy birdnetpi_config.txt to boot partition for unattended install.sh. + os_key: str | None = None, + device_key: str | None = None, +) -> Path | None: + """Copy birdnetpi_config.json to boot partition for unattended install.sh. Args: boot_mount: Path to mounted boot partition config: Configuration dictionary with BirdNET-Pi settings + os_key: Operating system key (e.g., "dietpi", "raspbian") + device_key: Device key (e.g., "orange_pi_5_pro", "pi_5") + + Returns: + Path to temporary config file if created, None otherwise """ - # Build config lines from available settings - config_lines = ["# BirdNET-Pi boot configuration"] - has_config = False + import json - # Install-time environment variables (optional) - if config.get("birdnet_repo_url"): - config_lines.append(f"export BIRDNETPI_REPO_URL={config['birdnet_repo_url']}") - has_config = True + # Build JSON config from all available settings + boot_config: dict[str, Any] = {} + + # OS and device information + if os_key: + boot_config["os"] = os_key + if device_key: + boot_config["device"] = device_key + # Install-time settings + if config.get("birdnet_repo_url"): + boot_config["repo_url"] = config["birdnet_repo_url"] if config.get("birdnet_branch"): - config_lines.append(f"export BIRDNETPI_BRANCH={config['birdnet_branch']}") - has_config = True + boot_config["branch"] = config["birdnet_branch"] + + # WiFi settings + if config.get("wifi_ssid"): + boot_config["wifi_ssid"] = config["wifi_ssid"] + if config.get("wifi_password"): + boot_config["wifi_password"] = config["wifi_password"] + if config.get("wifi_auth"): + boot_config["wifi_auth"] = config["wifi_auth"] # Application settings if config.get("birdnet_device_name"): - config_lines.append(f"device_name={config['birdnet_device_name']}") - has_config = True - + boot_config["device_name"] = config["birdnet_device_name"] if config.get("birdnet_latitude"): - config_lines.append(f"latitude={config['birdnet_latitude']}") - has_config = True - + boot_config["latitude"] = config["birdnet_latitude"] if config.get("birdnet_longitude"): - config_lines.append(f"longitude={config['birdnet_longitude']}") - has_config = True - + boot_config["longitude"] = config["birdnet_longitude"] if config.get("birdnet_timezone"): - config_lines.append(f"timezone={config['birdnet_timezone']}") - has_config = True - + boot_config["timezone"] = config["birdnet_timezone"] if config.get("birdnet_language"): - config_lines.append(f"language={config['birdnet_language']}") - has_config = True + boot_config["language"] = config["birdnet_language"] # Only write if we have at least one setting - if has_config: - temp_config = Path("/tmp/birdnetpi_config.txt") - temp_config.write_text("\n".join(config_lines) + "\n") + if boot_config: + temp_config = Path("/tmp/birdnetpi_config.json") + temp_config.write_text(json.dumps(boot_config, indent=2) + "\n") subprocess.run( - ["sudo", "cp", str(temp_config), str(boot_mount / "birdnetpi_config.txt")], + ["sudo", "cp", str(temp_config), str(boot_mount / "birdnetpi_config.json")], check=True, ) - temp_config.unlink() console.print("[green]✓ BirdNET-Pi configuration written to boot partition[/green]") + return temp_config + return None # OS and device image URLs (Lite/Minimal versions for headless server) @@ -424,10 +645,10 @@ def copy_birdnetpi_config( }, ), ( - "orange_pi_5", + "orange_pi_0w2", { - "name": "Orange Pi 5", - "url": "https://dl.armbian.com/orangepi5/Bookworm_current_minimal", + "name": "Orange Pi Zero 2W", + "url": "https://dl.armbian.com/orangepizero2w/Bookworm_current_minimal", "is_armbian": True, }, ), @@ -502,10 +723,10 @@ def copy_birdnetpi_config( ), # Non-Pi devices in alphabetical order ( - "orange_pi_5", + "orange_pi_0w2", { - "name": "Orange Pi 5", - "url": "https://dietpi.com/downloads/images/DietPi_OrangePi5-ARMv8-Bookworm.img.xz", + "name": "Orange Pi Zero 2W", + "url": "https://dietpi.com/downloads/images/DietPi_OrangePiZero2W-ARMv8-Bookworm.img.xz", "is_dietpi": True, }, ), @@ -538,16 +759,6 @@ def copy_birdnetpi_config( }, } -# Legacy PI_IMAGES dict for backwards compatibility -PI_IMAGES = { - "Pi 5": OS_IMAGES["raspbian"]["devices"]["pi_5"], - "Pi 4": OS_IMAGES["raspbian"]["devices"]["pi_4"], - "Pi 3": OS_IMAGES["raspbian"]["devices"]["pi_3"], - "Pi Zero 2 W": OS_IMAGES["raspbian"]["devices"]["pi_zero_2w"], - "Le Potato (Raspbian)": OS_IMAGES["raspbian"]["devices"]["le_potato"], - "Le Potato (Armbian)": OS_IMAGES["armbian"]["devices"]["le_potato"], -} - CONFIG_DIR = Path.home() / ".config" / "birdnetpi" PROFILES_DIR = CONFIG_DIR / "profiles" @@ -752,213 +963,12 @@ def select_device(device_index: int | None = None) -> str: return selected_device["device"] -def select_pi_version( - saved_device_type: str | None = None, - device_type_override: str | None = None, - edit_mode: bool = False, -) -> str: - """Prompt user to select device model. - - Args: - saved_device_type: Device type from saved profile - device_type_override: CLI override for device type - edit_mode: If True, show prompts with defaults; if False, auto-use saved values - - Returns: - Selected device model name (e.g., "Pi 4", "Le Potato (Armbian)") - """ - # Use override if provided - if device_type_override: - if device_type_override not in PI_IMAGES: - console.print(f"[red]Invalid device type: {device_type_override}[/red]") - console.print(f"[yellow]Available types: {', '.join(PI_IMAGES.keys())}[/yellow]") - sys.exit(1) - console.print(f"[cyan]Using device type from CLI: {device_type_override}[/cyan]") - return device_type_override - - # Use saved value if not in edit mode - if saved_device_type and not edit_mode: - if saved_device_type in PI_IMAGES: - console.print(f"[dim]Using saved device type: {saved_device_type}[/dim]") - return saved_device_type - else: - console.print( - f"[yellow]Warning: Saved device type '{saved_device_type}' not found[/yellow]" - ) - - # Prompt user to select - console.print() - console.print("[bold cyan]Select Device Model:[/bold cyan]") - table = Table(show_header=True, header_style="bold cyan") - table.add_column("Index", style="dim") - table.add_column("Model", style="green") - table.add_column("Notes", style="dim") - - # Map version numbers to model names for intuitive selection - version_map = { - "5": "Pi 5", - "4": "Pi 4", - "3": "Pi 3", - "0": "Pi Zero 2 W", - "R": "Le Potato (Raspbian)", - "A": "Le Potato (Armbian)", - } - - # Display in order (0, 3, 4, 5, R, A) - display_order = [ - ("0", "Pi Zero 2 W", ""), - ("3", "Pi 3", ""), - ("4", "Pi 4", ""), - ("5", "Pi 5", ""), - ("R", "Le Potato (Raspbian)", "Two-step boot required"), - ("A", "Le Potato (Armbian)", "Native Armbian, direct boot"), - ] - - for version, model, notes in display_order: - table.add_row(version, model, notes) - - console.print(table) - console.print() - - # Use saved value as default in edit mode - default_choice = None - if edit_mode and saved_device_type: - # Find the key for the saved device type - for key, model in version_map.items(): - if model == saved_device_type: - default_choice = key - break - - choice = Prompt.ask( - "[bold]Select device model[/bold]", - choices=list(version_map.keys()), - default=default_choice, - show_default=bool(default_choice), - ) - - return version_map[choice] - - -def download_image(pi_version: str, download_dir: Path) -> Path: # noqa: C901 - """Download Raspberry Pi OS or Armbian image if not already cached. - - Args: - pi_version: Device model name (e.g., "Pi 4", "Le Potato (Armbian)") - download_dir: Directory to store downloaded images - - Returns: - Path to the downloaded image file - """ - image_info = PI_IMAGES[pi_version] - url = image_info["url"] - is_armbian = image_info.get("is_armbian", False) - - # For Armbian, follow redirects to get actual download URL - if is_armbian: - console.print(f"[cyan]Resolving Armbian image URL for {pi_version}...[/cyan]") - # HEAD request to follow redirects and get actual filename - # SSL verification is enabled - redirect should have valid cert - head_response = requests.head(url, allow_redirects=True, timeout=30) - head_response.raise_for_status() - - # Extract final URL and filename after redirect - final_url = head_response.url - url = final_url # Use the actual file URL for download - filename = final_url.split("/")[-1] - - console.print(f"[dim]Resolved to: {filename}[/dim]") - else: - filename = url.split("/")[-1] - - filepath = download_dir / filename - - if filepath.exists(): - console.print(f"[green]Using cached image: {filepath}[/green]") - return filepath - - console.print(f"[cyan]Downloading image for {pi_version}...[/cyan]") - - with Progress( - TextColumn("[bold blue]{task.description}"), - BarColumn(), - DownloadColumn(), - TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), - TimeElapsedColumn(), - console=console, - ) as progress: - # Download with SSL verification enabled - response = requests.get(url, stream=True, timeout=30) - response.raise_for_status() - - total = int(response.headers.get("content-length", 0)) - task = progress.add_task(f"Downloading {filename}", total=total) - - with open(filepath, "wb") as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) - progress.update(task, advance=len(chunk)) - - console.print(f"[green]Downloaded: {filepath}[/green]") - - # Verify SHA256 for Armbian (download .sha file from same location) - if is_armbian: - console.print("[cyan]Verifying image integrity...[/cyan]") - sha_url = f"{url}.sha" - try: - sha_response = requests.get(sha_url, timeout=30) - sha_response.raise_for_status() - - # Parse SHA file - handle different formats - sha_content = sha_response.text.strip() - - # Check if this looks like binary data (not a text SHA file) - if not sha_content.isprintable() or len(sha_content) < 64: - raise ValueError("SHA file does not contain valid text") - - # Try to extract hash - handle formats like: - # "hash filename" or just "hash" - parts = sha_content.split() - if parts: - expected_sha = parts[0] - # Validate it looks like a hex hash (64 chars for SHA256) - if not ( - len(expected_sha) == 64 - and all(c in "0123456789abcdefABCDEF" for c in expected_sha) - ): - raise ValueError(f"Invalid SHA256 hash format: {expected_sha[:20]}...") - else: - raise ValueError("Could not extract hash from SHA file") - - # Calculate actual SHA256 - import hashlib - - sha256_hash = hashlib.sha256() - with open(filepath, "rb") as f: - for byte_block in iter(lambda: f.read(8192), b""): - sha256_hash.update(byte_block) - actual_sha = sha256_hash.hexdigest() - - if actual_sha == expected_sha: - console.print("[green]✓ SHA256 verification passed[/green]") - else: - console.print("[red]✗ SHA256 verification failed![/red]") - console.print(f"[red]Expected: {expected_sha}[/red]") - console.print(f"[red]Got: {actual_sha}[/red]") - filepath.unlink() # Delete corrupted file - sys.exit(1) - except Exception as e: - console.print(f"[yellow]Warning: Could not verify SHA256: {e}[/yellow]") - console.print("[yellow]Proceeding anyway, but file integrity is not verified[/yellow]") - - return filepath - - def download_image_new(os_key: str, device_key: str, download_dir: Path) -> Path: # noqa: C901 """Download OS image for the selected OS and device. Args: os_key: Selected OS key (e.g., "raspbian", "armbian", "dietpi") - device_key: Selected device key (e.g., "pi_4", "orange_pi_5") + device_key: Selected device key (e.g., "pi_4", "orange_pi_5_pro") download_dir: Directory to store downloaded images Returns: @@ -1411,6 +1421,116 @@ def configure_armbian_with_anylinuxfs( # noqa: C901 console.print("[yellow]Warning: Could not unmount anylinuxfs[/yellow]") +def download_waveshare_library(boot_mount: Path) -> None: + """Download Waveshare ePaper library to boot partition for offline installation. + + Uses sparse-checkout to download only the Python subdirectory (~6MB transfer), + then creates a compressed tarball with only essential files (lib/ and setup.py). + Skips pic/ (example images) and examples/ (test scripts) which aren't needed. + + The compressed tarball saves significant boot partition space compared to the + uncompressed directory structure (~1-2MB vs ~5-6MB). + + Args: + boot_mount: Path to the mounted boot partition where tarball should be copied + """ + console.print() + waveshare_tarball = boot_mount / "waveshare-epd.tar.gz" + temp_waveshare = Path("/tmp/waveshare_clone") + temp_staging = Path("/tmp/waveshare_staging") + + # Remove old temp directories if they exist + # Use rm -rf because git clone may create files with restricted permissions + if temp_waveshare.exists(): + subprocess.run(["rm", "-rf", str(temp_waveshare)], check=True) + if temp_staging.exists(): + subprocess.run(["rm", "-rf", str(temp_staging)], check=True) + + # Clone only the Python subdirectory using sparse-checkout + # Then compress only essential files to save boot partition space + with console.status("[cyan]Downloading and compressing Waveshare ePaper library...[/cyan]"): + # Initialize sparse checkout + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "--filter=blob:none", + "--no-checkout", + "--quiet", + "https://github.com/waveshareteam/e-Paper.git", + str(temp_waveshare), + ], + check=True, + ) + + # Configure sparse checkout for Python subdirectory only + subprocess.run( + ["git", "-C", str(temp_waveshare), "sparse-checkout", "init", "--cone"], + check=True, + stdout=subprocess.DEVNULL, + ) + subprocess.run( + [ + "git", + "-C", + str(temp_waveshare), + "sparse-checkout", + "set", + "RaspberryPi_JetsonNano/python", + ], + check=True, + stdout=subprocess.DEVNULL, + ) + subprocess.run( + ["git", "-C", str(temp_waveshare), "checkout", "--quiet"], + check=True, + ) + + # Stage only essential files for compression + # Skip pic/ (example images) and examples/ (test scripts) to save space + python_dir = temp_waveshare / "RaspberryPi_JetsonNano" / "python" + temp_staging.mkdir(parents=True) + staging_dir = temp_staging / "waveshare-epd" + staging_dir.mkdir() + + # Copy lib directory (the actual library code) + lib_dir = python_dir / "lib" + if lib_dir.exists(): + shutil.copytree(lib_dir, staging_dir / "lib") + + # Copy setup.py (needed for pip installation) + setup_file = python_dir / "setup.py" + if setup_file.exists(): + shutil.copy2(setup_file, staging_dir / "setup.py") + + # Create compressed tarball + subprocess.run( + [ + "tar", + "-czf", + str(waveshare_tarball), + "-C", + str(temp_staging), + "waveshare-epd", + ], + check=True, + ) + + # Get compressed size for informational purposes + tarball_size_mb = waveshare_tarball.stat().st_size / (1024 * 1024) + + # Clean up temp directories (use rm -rf for git clones with restricted permissions) + subprocess.run(["rm", "-rf", str(temp_waveshare)], check=True) + subprocess.run(["rm", "-rf", str(temp_staging)], check=True) + + console.print( + f"[green]✓ Waveshare ePaper library compressed to boot partition " + f"({tarball_size_mb:.1f}MB)[/green]" + ) + + def configure_dietpi_boot( # noqa: C901 device: str, config: dict[str, Any], os_key: str, device_key: str ) -> None: @@ -1418,6 +1538,9 @@ def configure_dietpi_boot( # noqa: C901 console.print() console.print("[cyan]Configuring DietPi boot partition...[/cyan]") + # Initialize config_file to None (will be set by copy_birdnetpi_config if config exists) + config_file: Path | None = None + # Mount boot partition if platform.system() == "Darwin": # DietPi uses different partition numbers on different devices @@ -1494,6 +1617,7 @@ def configure_dietpi_boot( # noqa: C901 # Update configuration values updates = { "AUTO_SETUP_AUTOMATED": "1", # Enable automated first-run setup + "AUTO_SETUP_INSTALL_SOFTWARE": "1", # Required for Automation_Custom_Script.sh to run! "AUTO_SETUP_NET_HOSTNAME": config.get("hostname", "birdnetpi"), "AUTO_SETUP_GLOBAL_PASSWORD": config["admin_password"], "AUTO_SETUP_TIMEZONE": config.get("timezone", "UTC"), @@ -1642,7 +1766,7 @@ def configure_dietpi_boot( # noqa: C901 console.print("[green]✓ SPI enabled for ePaper HAT (Raspberry Pi)[/green]") elif dietpi_env_path.exists(): - # RK3588-based SBC (OrangePi 5/5+/5 Pro, ROCK 5B) - use device tree overlay + # SBC with dietpiEnv.txt - use device tree overlay result = subprocess.run( ["sudo", "cat", str(dietpi_env_path)], capture_output=True, @@ -1652,43 +1776,65 @@ def configure_dietpi_boot( # noqa: C901 env_content = result.stdout # Determine which SPI overlay to use based on device - # Orange Pi 5 series: SPI4-M0 is available on GPIO header - # ROCK 5B: SPI1-M1 or SPI3-M1 depending on configuration - spi_overlay = "rk3588-spi4-m0-cs1-spidev" - if device_key == "rock5b": - spi_overlay = "rk3588-spi1-m1-cs0-spidev" - - # Check if overlays line exists - overlays_added = False - new_lines = [] - for line in env_content.split("\n"): - if line.startswith("overlays="): - # Add SPI overlay to existing overlays line - if spi_overlay not in line: - line = line.rstrip() + f" {spi_overlay}" - overlays_added = True - new_lines.append(line) - - # If no overlays line exists, add it - if not overlays_added: - new_lines.append(f"overlays={spi_overlay}") - - # Add spidev bus parameter if not present - if "param_spidev_spi_bus=" not in env_content: - new_lines.append("param_spidev_spi_bus=0") - - env_content = "\n".join(new_lines) - - temp_env = Path("/tmp/dietpi_env_txt") - temp_env.write_text(env_content) - subprocess.run( - ["sudo", "cp", str(temp_env), str(dietpi_env_path)], - check=True, - ) - temp_env.unlink() - console.print( - f"[green]✓ SPI enabled for ePaper HAT (RK3588 overlay: {spi_overlay})[/green]" - ) + # Orange Pi Zero 2W: Allwinner H618 SPI1 + # Orange Pi 5 series: RK3588 SPI4-M2-CS0 (verified working) + # ROCK 5B: RK3588 SPI1-M1 or SPI3-M1 depending on configuration + # + # NOTE: DietPi automatically prepends overlay_prefix from dietpiEnv.txt + # so overlay names should NOT include the chip prefix + spi_overlay = None + if device_key == "orange_pi_0w2": + spi_overlay = "spi-spidev" # Allwinner H618 + elif device_key == "rock_5b": + spi_overlay = "spi1-m1-cs0-spidev" # RK3588 (prefix auto-prepended) + elif device_key in ["orange_pi_5_plus", "orange_pi_5_pro"]: + spi_overlay = "spi4-m2-cs0-spidev" # RK3588 M2-CS0 (prefix auto-prepended) + + if spi_overlay: + # Check if overlays line exists + overlays_added = False + new_lines = [] + for line in env_content.split("\n"): + if line.startswith("overlays="): + # Add SPI overlay to existing overlays line + if spi_overlay not in line: + line = line.rstrip() + f" {spi_overlay}" + overlays_added = True + new_lines.append(line) + + # If no overlays line exists, add it + if not overlays_added: + new_lines.append(f"overlays={spi_overlay}") + + # Add spidev bus parameter if not present + if "param_spidev_spi_bus=" not in env_content: + new_lines.append("param_spidev_spi_bus=0") + + # Add max frequency parameter for RK3588 platforms (required) + if device_key in ["orange_pi_5_plus", "orange_pi_5_pro", "rock_5b"]: + if "param_spidev_max_freq=" not in env_content: + new_lines.append("param_spidev_max_freq=100000000") + + env_content = "\n".join(new_lines) + + temp_env = Path("/tmp/dietpi_env_txt") + temp_env.write_text(env_content) + subprocess.run( + ["sudo", "cp", str(temp_env), str(dietpi_env_path)], + check=True, + ) + temp_env.unlink() + + # Determine chip description for message + chip_desc = "Allwinner H618" if device_key == "orange_pi_0w2" else "RK3588" + msg = f"✓ SPI enabled for ePaper HAT ({chip_desc} overlay: {spi_overlay})" + console.print(f"[green]{msg}[/green]") + + # Download Waveshare library for offline installation + download_waveshare_library(boot_mount) + else: + msg = "Note: SPI configuration for this device not yet implemented" + console.print(f"[yellow]{msg}[/yellow]") else: # Other SBC types not yet implemented @@ -1697,12 +1843,243 @@ def configure_dietpi_boot( # noqa: C901 ) # Copy installer script if requested (handles preservation for DietPi automatically) - copy_installer_script(boot_mount, config, os_key, device_key) + modified_install_script = copy_installer_script(boot_mount, config, os_key, device_key) # Copy BirdNET-Pi pre-configuration file if any settings provided - copy_birdnetpi_config(boot_mount, config) + config_file = copy_birdnetpi_config(boot_mount, config, os_key, device_key) + + # CRITICAL: Also copy install.sh and config to rootfs partition + # The DIETPISETUP partition (boot_mount) will be deleted after first boot + # So we must also place install.sh and config on the persistent rootfs partition + if config.get("copy_installer") and modified_install_script: + console.print("[cyan]Copying install.sh to rootfs partition...[/cyan]") + + # Mount rootfs partition (usually partition 2 on DietPi) + rootfs_mount = None + rootfs_partition = None + + try: + if platform.system() == "Darwin": + # On macOS, use anylinuxfs to mount the ext4 rootfs partition + # Check if anylinuxfs is installed + anylinuxfs_path = shutil.which("anylinuxfs") + mount_result = None + rootfs_partition_name = None + volumes_path = Path("/Volumes") + initial_volumes: set[Path] = set() + + if not anylinuxfs_path: + console.print( + "[yellow]anylinuxfs not found - skipping rootfs mount[/yellow]" + ) + console.print( + "[yellow]Automation scripts will preserve " + "install.sh during first boot[/yellow]" + ) + else: + # Find the Linux Filesystem partition (rootfs) + # On Orange Pi 5 Pro: partition 1 is rootfs, partition 2 is DIETPISETUP + rootfs_partition_name = f"{device}s1" + + console.print( + f"[cyan]Mounting {rootfs_partition_name} using anylinuxfs...[/cyan]" + ) + console.print( + "[dim]This may take 10-15 seconds to start the microVM...[/dim]" + ) + + # Unmount any existing anylinuxfs mount first + subprocess.run( + ["sudo", "anylinuxfs", "unmount"], + capture_output=True, + check=False, + timeout=10, + ) + time.sleep(2) + + # Get list of volumes BEFORE anylinuxfs mount + initial_volumes = ( + set(volumes_path.iterdir()) if volumes_path.exists() else set() + ) + + # Mount with anylinuxfs + mount_result = subprocess.run( + ["sudo", "anylinuxfs", rootfs_partition_name, "-w", "false"], + capture_output=False, # Allow password prompt + check=False, + ) + + if anylinuxfs_path and mount_result and mount_result.returncode == 0: + # Wait for mount to appear in /Volumes + console.print("[dim]Waiting for mount to appear...[/dim]") + + for attempt in range(60): + time.sleep(1) + + # Find new volumes that appeared after anylinuxfs mount + current_volumes = ( + set(volumes_path.iterdir()) if volumes_path.exists() else set() + ) + new_volumes = current_volumes - initial_volumes + + # Look for a new volume that looks like a Linux filesystem + for potential_mount in new_volumes: + if potential_mount.is_dir(): + # Check if it looks like a rootfs (has /etc, /root, /usr) + if ( + (potential_mount / "etc").exists() + and (potential_mount / "root").exists() + and (potential_mount / "usr").exists() + ): + rootfs_mount = potential_mount + rootfs_partition = rootfs_partition_name + break + + if rootfs_mount: + break + + if attempt % 5 == 0 and attempt > 0: + console.print(f"[dim]Still waiting... ({attempt}s)[/dim]") + + if rootfs_mount and rootfs_mount.exists(): + console.print(f"[green]✓ Mounted at {rootfs_mount}[/green]") + + # Ensure /root directory exists on rootfs + root_dir = rootfs_mount / "root" + subprocess.run(["sudo", "mkdir", "-p", str(root_dir)], check=True) + + # Copy modified install.sh to /root on rootfs + # Use dd to avoid extended attributes issues with macOS + install_dest = root_dir / "install.sh" + subprocess.run( + [ + "sudo", + "dd", + f"if={modified_install_script}", + f"of={install_dest}", + "bs=1m", + ], + check=True, + capture_output=True, + ) + subprocess.run(["sudo", "chmod", "+x", str(install_dest)], check=True) + + console.print( + "[green]✓ install.sh copied to rootfs:/root/install.sh[/green]" + ) + + # Also copy config file if it was created + if config_file and config_file.exists(): + config_dest = root_dir / "birdnetpi_config.json" + subprocess.run( + [ + "sudo", + "dd", + f"if={config_file}", + f"of={config_dest}", + "bs=1m", + ], + check=True, + capture_output=True, + ) + console.print( + "[green]✓ birdnetpi_config.json copied to " + "rootfs:/root/birdnetpi_config.json[/green]" + ) + + # Also copy Waveshare tarball if it was created + waveshare_tarball = boot_mount / "waveshare-epd.tar.gz" + if waveshare_tarball.exists(): + waveshare_dest = root_dir / "waveshare-epd.tar.gz" + subprocess.run( + [ + "sudo", + "dd", + f"if={waveshare_tarball}", + f"of={waveshare_dest}", + "bs=1m", + ], + check=True, + capture_output=True, + ) + console.print( + "[green]✓ waveshare-epd.tar.gz copied to " + "rootfs:/root/waveshare-epd.tar.gz[/green]" + ) + + console.print( + "[dim]Files persist after DIETPISETUP partition deletion[/dim]" + ) + else: + console.print( + "[yellow]Could not find anylinuxfs mount after 60s[/yellow]" + ) + console.print( + "[yellow]Automation scripts will preserve " + "install.sh during first boot[/yellow]" + ) + elif anylinuxfs_path: + console.print( + "[yellow]anylinuxfs mount failed - using boot partition only[/yellow]" + ) + console.print( + "[yellow]Automation scripts will preserve " + "install.sh during first boot[/yellow]" + ) + else: + # On Linux, mount partition 2 (rootfs) + rootfs_partition = f"{device}2" + rootfs_mount = Path("/mnt/dietpi_rootfs") + rootfs_mount.mkdir(parents=True, exist_ok=True) + + subprocess.run( + ["sudo", "mount", rootfs_partition, str(rootfs_mount)], check=True + ) + + # Ensure /root directory exists on rootfs + root_dir = rootfs_mount / "root" + subprocess.run(["sudo", "mkdir", "-p", str(root_dir)], check=True) + + # Copy modified install.sh to /root on rootfs + install_dest = root_dir / "install.sh" + subprocess.run( + ["sudo", "cp", str(modified_install_script), str(install_dest)], check=True + ) + subprocess.run(["sudo", "chmod", "+x", str(install_dest)], check=True) + + console.print("[green]✓ install.sh copied to rootfs:/root/install.sh[/green]") + console.print("[dim]Persists after DIETPISETUP partition deletion[/dim]") + + finally: + # Unmount rootfs if we mounted it + if rootfs_mount and rootfs_partition: + if platform.system() == "Darwin": + # Unmount anylinuxfs + console.print("[cyan]Unmounting anylinuxfs...[/cyan]") + try: + subprocess.run( + ["sudo", "anylinuxfs", "unmount"], + check=True, + timeout=10, + capture_output=True, + ) + console.print("[green]✓ anylinuxfs unmounted[/green]") + except (subprocess.TimeoutExpired, subprocess.CalledProcessError): + # Try force stop if unmount fails + subprocess.run( + ["sudo", "anylinuxfs", "stop"], + check=False, + timeout=5, + capture_output=True, + ) + else: + subprocess.run(["sudo", "umount", str(rootfs_mount)], check=False) finally: + # Clean up temporary config file if it exists + if config_file and config_file.exists(): + config_file.unlink() + # Unmount console.print("[cyan]Unmounting boot partition...[/cyan]") if platform.system() == "Darwin": @@ -1957,68 +2334,8 @@ def configure_boot_partition( # noqa: C901 temp_config.unlink() console.print("[green]✓ SPI enabled for ePaper HAT[/green]") - # Clone Waveshare ePaper library to boot partition for offline installation - console.print() - waveshare_dest = boot_mount / "waveshare-epd" - temp_waveshare = Path("/tmp/waveshare_clone") - - # Remove old temp clone if it exists - if temp_waveshare.exists(): - shutil.rmtree(temp_waveshare) - - # Clone only the Python subdirectory using sparse-checkout (~45MB vs full repo) - # This is small enough to fit on the boot partition - with console.status( - "[cyan]Downloading Waveshare ePaper library " - "(Python subdirectory, ~6MB transfer)...[/cyan]" - ): - # Initialize sparse checkout - subprocess.run( - [ - "git", - "clone", - "--depth", - "1", - "--filter=blob:none", - "--no-checkout", - "--quiet", - "https://github.com/waveshareteam/e-Paper.git", - str(temp_waveshare), - ], - check=True, - ) - - # Configure sparse checkout for Python subdirectory only - subprocess.run( - ["git", "-C", str(temp_waveshare), "sparse-checkout", "init", "--cone"], - check=True, - stdout=subprocess.DEVNULL, - ) - subprocess.run( - [ - "git", - "-C", - str(temp_waveshare), - "sparse-checkout", - "set", - "RaspberryPi_JetsonNano/python", - ], - check=True, - stdout=subprocess.DEVNULL, - ) - subprocess.run( - ["git", "-C", str(temp_waveshare), "checkout", "--quiet"], - check=True, - ) - - # Copy only the Python subdirectory to boot partition - python_dir = temp_waveshare / "RaspberryPi_JetsonNano" / "python" - subprocess.run( - ["sudo", "cp", "-r", str(python_dir), str(waveshare_dest)], - check=True, - ) - shutil.rmtree(temp_waveshare) - console.print("[green]✓ Waveshare ePaper library downloaded to boot partition[/green]") + # Download Waveshare library for offline installation + download_waveshare_library(boot_mount) # Copy installer script if requested copy_installer_script(boot_mount, config, os_key, device_key) @@ -2258,7 +2575,7 @@ def configure_boot_partition( # noqa: C901 def run_configuration_wizard() -> dict[str, Any] | None: """Run the Textual TUI wizard to gather configuration.""" - app = FlasherWizardApp(OS_IMAGES, DEVICE_PROPERTIES, OS_PROPERTIES) + app = FlasherWizardApp(OS_PROPERTIES) return app.run() @@ -2450,10 +2767,25 @@ def main(save_config_flag: bool, device_index: int | None, device_type: str | No os_label = "Armbian" if is_armbian else "DietPi" summary_parts.append(f"Native {os_label}: [green]Configured and ready[/green]\n") - # Check if anylinuxfs was used - if shutil.which("anylinuxfs"): + # DietPi has special installer preservation + if is_dietpi: + caps = get_combined_capabilities(os_key, device_key) + install_path = caps.get("install_sh_path", "/root/install.sh") + summary_parts.append( + f"[dim]Insert the SD card and power on your device.\n" + f"First boot will configure the system and preserve install.sh to:\n" + f" [cyan]{install_path}[/cyan]\n\n" + f"After first boot, SSH in and run:\n" + f" [cyan]sudo bash {install_path}[/cyan]\n\n" + f"[yellow]Troubleshooting (if install.sh is missing):[/yellow]\n" + f" • Check logs: [cyan]cat /var/log/birdnetpi_*.log[/cyan]\n" + f" • Read [cyan]BIRDNETPI_README.txt[/cyan] on boot partition\n" + f" • Manual installation instructions in README[/dim]" + ) + # Check if anylinuxfs was used for Armbian + elif shutil.which("anylinuxfs"): summary_parts.append( - "[dim]Insert the SD card into your Le Potato and power it on.\n" + "[dim]Insert the SD card into your device and power it on.\n" "First boot will apply pre-configuration automatically.\n\n" f"SSH in as [cyan]{config['admin_user']}[/cyan] and run:\n" " [cyan]bash /boot/install.sh[/cyan]\n\n" @@ -2462,7 +2794,7 @@ def main(save_config_flag: bool, device_index: int | None, device_type: str | No ) else: summary_parts.append( - "[dim]Insert the SD card into your Le Potato and power it on.\n" + "[dim]Insert the SD card into your device and power it on.\n" "[yellow]anylinuxfs not installed - using interactive setup:[/yellow]\n" " 1. Create a root password\n" " 2. Create a user account\n" diff --git a/install/flasher_tui.py b/install/flasher_tui.py index beda513e..333b2a1a 100644 --- a/install/flasher_tui.py +++ b/install/flasher_tui.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any +from devices import DEVICES, build_os_images_dict from textual import on # type: ignore[import-untyped] from textual.app import App, ComposeResult # type: ignore[import-untyped] from textual.containers import Container, Horizontal, Vertical # type: ignore[import-untyped] @@ -30,33 +31,28 @@ def get_combined_capabilities( - os_key: str, device_key: str, os_properties: dict[str, Any], device_properties: dict[str, Any] + os_key: str, device_key: str, os_properties: dict[str, Any] ) -> dict[str, Any]: """Calculate combined capabilities from OS and device properties. Args: os_key: OS type (e.g., "raspbian", "armbian", "dietpi") - device_key: Device key (e.g., "pi_4", "orangepi5") + device_key: Device key (e.g., "pi_4", "orange_pi_0w2") os_properties: OS properties dictionary - device_properties: Device properties dictionary Returns: Dictionary of combined capabilities """ os_props = os_properties.get(os_key, {}) - device_props = device_properties.get(device_key, {}) + device = DEVICES[device_key] return { # WiFi is supported if OS can configure it AND device has hardware - "supports_wifi": ( - os_props.get("wifi_config_method") is not None and device_props.get("has_wifi", False) - ), + "supports_wifi": (os_props.get("wifi_config_method") is not None and device.has_wifi), # Custom user supported if OS has a method other than root_only "supports_custom_user": os_props.get("user_config_method") not in [None, "root_only"], # SPI supported if OS can configure it AND device has hardware - "supports_spi": ( - os_props.get("spi_config_method") is not None and device_props.get("has_spi", False) - ), + "supports_spi": (os_props.get("spi_config_method") is not None and device.has_spi), # Pass through OS-specific properties "install_sh_path": os_props.get("install_sh_path", "/boot/install.sh"), "install_sh_needs_preservation": os_props.get("install_sh_needs_preservation", False), @@ -186,16 +182,13 @@ class FlasherWizardApp(App[dict | None]): def __init__( self, - os_images: dict[str, Any], - device_properties: dict[str, Any], - os_properties: dict[str, Any] | None = None, + os_properties: dict[str, Any], ) -> None: - """Initialize wizard with OS and device data.""" + """Initialize wizard with OS properties.""" super().__init__() self.config: dict[str, Any] = {} - self.os_images = os_images - self.device_properties = device_properties - self.os_properties = os_properties or {} + self.os_images = build_os_images_dict() # Build from devices module + self.os_properties = os_properties self.is_loaded_profile = False # Track if config is from loaded profile self.loaded_profile_name: str | None = None # Track original profile name when editing @@ -252,7 +245,7 @@ def handle_device_selection(self, result: dict[str, Any] | None) -> None: os_key = self.config["os_key"] device_key = result["device_key"] self.push_screen( - NetworkConfigScreen(os_key, device_key, self.device_properties, self.config), + NetworkConfigScreen(os_key, device_key, self.config), self.handle_network_config, ) else: @@ -281,9 +274,7 @@ def handle_system_config(self, result: dict[str, Any] | None) -> None: os_key = self.config["os_key"] device_key = self.config["device_key"] self.push_screen( - AdvancedConfigScreen( - os_key, device_key, self.device_properties, self.os_properties, self.config - ), + AdvancedConfigScreen(os_key, device_key, self.os_properties, self.config), self.handle_advanced_config, ) else: @@ -291,7 +282,7 @@ def handle_system_config(self, result: dict[str, Any] | None) -> None: os_key = self.config["os_key"] device_key = self.config["device_key"] self.push_screen( - NetworkConfigScreen(os_key, device_key, self.device_properties, self.config), + NetworkConfigScreen(os_key, device_key, self.config), self.handle_network_config, ) @@ -319,9 +310,7 @@ def handle_birdnet_config(self, result: dict[str, Any] | None) -> None: os_key = self.config["os_key"] device_key = self.config["device_key"] self.push_screen( - AdvancedConfigScreen( - os_key, device_key, self.device_properties, self.os_properties, self.config - ), + AdvancedConfigScreen(os_key, device_key, self.os_properties, self.config), self.handle_advanced_config, ) @@ -662,7 +651,7 @@ def __init__( super().__init__() self.os_key = os_key self.device_key = device_key - self.device_properties = device_properties + self.initial_config = initial_config or {} def compose(self) -> ComposeResult: @@ -853,7 +842,6 @@ def __init__( self, os_key: str, device_key: str, - device_properties: dict[str, Any], os_properties: dict[str, Any], initial_config: dict[str, Any] | None = None, ) -> None: @@ -861,7 +849,6 @@ def __init__( super().__init__() self.os_key = os_key self.device_key = device_key - self.device_properties = device_properties self.os_properties = os_properties self.initial_config = initial_config or {} @@ -869,9 +856,7 @@ def __init__( # This checks both: OS has SPI config method AND device has SPI hardware # For example: Pi 4 + Raspberry Pi OS = True (config_txt method + has_spi) # Pi 4 + Armbian = False (no SPI config method for Armbian yet) - capabilities = get_combined_capabilities( - os_key, device_key, os_properties, device_properties - ) + capabilities = get_combined_capabilities(os_key, device_key, os_properties) self.supports_spi = capabilities.get("supports_spi", False) def compose(self) -> ComposeResult: @@ -1165,7 +1150,7 @@ def compose(self) -> ComposeResult: # noqa: C901 # Build configuration summary with Vertical(classes="config-table"): - # OS and Device - use display names from OS_IMAGES + # OS and Device - use display names from devices module os_key = self.config.get("os_key", "") device_key = self.config.get("device_key", "") diff --git a/install/install.sh b/install/install.sh index 39dc2aa8..b0237a6f 100644 --- a/install/install.sh +++ b/install/install.sh @@ -12,6 +12,8 @@ set -e # Configuration +# NOTE: These defaults are substituted by flash_sdcard.py at flash time +# based on the configured repo URL and branch in the flasher wizard REPO_URL="${BIRDNETPI_REPO_URL:-https://github.com/mverteuil/BirdNET-Pi.git}" BRANCH="${BIRDNETPI_BRANCH:-main}" INSTALL_DIR="/opt/birdnetpi" @@ -87,19 +89,88 @@ echo "" # Enable SPI interface early (required for e-paper HAT detection) # Must reboot immediately for SPI devices to appear at /dev/spidev* -BOOT_CONFIG="/boot/firmware/config.txt" -if [ -f "$BOOT_CONFIG" ]; then - echo "Checking SPI interface..." - if grep -q "^dtparam=spi=on" "$BOOT_CONFIG"; then - echo "SPI already enabled" - else - echo "Enabling SPI interface..." - # Uncomment if commented, or add if missing - if grep -q "^#dtparam=spi=on" "$BOOT_CONFIG"; then - sudo sed -i 's/^#dtparam=spi=on/dtparam=spi=on/' "$BOOT_CONFIG" +echo "Checking SPI interface..." + +# Check if SPI devices already exist +if ls /dev/spidev* &>/dev/null; then + echo "SPI already enabled (devices found)" +else + SPI_ENABLED=false + + # Raspberry Pi OS: /boot/firmware/config.txt + BOOT_CONFIG="/boot/firmware/config.txt" + if [ -f "$BOOT_CONFIG" ]; then + echo "Detected Raspberry Pi OS, checking $BOOT_CONFIG..." + if grep -q "^dtparam=spi=on" "$BOOT_CONFIG"; then + SPI_ENABLED=true else - echo "dtparam=spi=on" | sudo tee -a "$BOOT_CONFIG" > /dev/null + echo "Enabling SPI in $BOOT_CONFIG..." + # Uncomment if commented, or add if missing + if grep -q "^#dtparam=spi=on" "$BOOT_CONFIG"; then + sudo sed -i 's/^#dtparam=spi=on/dtparam=spi=on/' "$BOOT_CONFIG" + else + echo "dtparam=spi=on" | sudo tee -a "$BOOT_CONFIG" > /dev/null + fi + SPI_ENABLED=true + fi + fi + + # DietPi/Armbian on Orange Pi: /boot/armbianEnv.txt or /boot/dietpiEnv.txt + for ARMBIAN_CONFIG in "/boot/armbianEnv.txt" "/boot/dietpiEnv.txt"; do + if [ -f "$ARMBIAN_CONFIG" ]; then + echo "Detected Armbian/DietPi, checking $ARMBIAN_CONFIG..." + + # Check for any SPI overlay (platform-specific like rk3588-spi* or generic spi-spidev) + if grep -q "^overlays=.*spi" "$ARMBIAN_CONFIG"; then + echo "SPI overlay found in $ARMBIAN_CONFIG" + + # Check if overlay has chip prefix (e.g., rk3588-) which needs to be removed + # DietPi/Armbian automatically prepend the prefix from overlay_prefix config + if grep -q "^overlays=.*rk3588-spi" "$ARMBIAN_CONFIG"; then + echo "Fixing RK3588 SPI overlay format (removing chip prefix)..." + # Replace rk3588-spi4-m0-cs1-spidev with spi4-m2-cs0-spidev (M2-CS0 is the working variant) + sudo sed -i 's/rk3588-spi4-[^ ]*/spi4-m2-cs0-spidev/' "$ARMBIAN_CONFIG" + SPI_ENABLED=true + fi + + # Verify param_spidev_spi_bus parameter exists + if ! grep -q "^param_spidev_spi_bus=" "$ARMBIAN_CONFIG"; then + echo "Adding param_spidev_spi_bus=0 to $ARMBIAN_CONFIG..." + echo "param_spidev_spi_bus=0" | sudo tee -a "$ARMBIAN_CONFIG" > /dev/null + SPI_ENABLED=true + fi + + # Verify param_spidev_max_freq parameter exists (required for RK3588) + if ! grep -q "^param_spidev_max_freq=" "$ARMBIAN_CONFIG"; then + echo "Adding param_spidev_max_freq=100000000 to $ARMBIAN_CONFIG..." + echo "param_spidev_max_freq=100000000" | sudo tee -a "$ARMBIAN_CONFIG" > /dev/null + SPI_ENABLED=true + fi + + if [ "$SPI_ENABLED" != true ]; then + echo "SPI already configured correctly in $ARMBIAN_CONFIG" + fi + else + echo "Enabling SPI in $ARMBIAN_CONFIG..." + # Check if overlays line exists + if grep -q "^overlays=" "$ARMBIAN_CONFIG"; then + # Add spi-spidev to existing overlays + sudo sed -i 's/^overlays=\(.*\)/overlays=\1 spi-spidev/' "$ARMBIAN_CONFIG" + else + # Create new overlays line + echo "overlays=spi-spidev" | sudo tee -a "$ARMBIAN_CONFIG" > /dev/null + fi + + # Add param_spidev_spi_bus parameter + echo "param_spidev_spi_bus=0" | sudo tee -a "$ARMBIAN_CONFIG" > /dev/null + + SPI_ENABLED=true + fi + break fi + done + + if [ "$SPI_ENABLED" = true ]; then echo "" echo "========================================" echo "SPI interface enabled!" @@ -114,13 +185,17 @@ if [ -f "$BOOT_CONFIG" ]; then read -r -p "Press Enter to reboot now, or Ctrl+C to cancel..." sudo reboot exit 0 + else + echo "WARNING: Could not detect system type to enable SPI" + echo "SPI may need to be enabled manually for e-paper HAT support" fi fi # Bootstrap the environment echo "Installing prerequisites..." sudo apt-get update -sudo apt-get install -y git python3.11 python3.11-venv python3-pip build-essential python3.11-dev +# Minimal build dependencies (no perl, make, or other build-essential bloat) +sudo apt-get install -y git python3.11 python3.11-venv python3-pip gcc libc6-dev python3.11-dev libportaudio2 libsndfile1 # Wait for DNS to settle after apt operations sleep 2 @@ -148,13 +223,53 @@ else # User doesn't exist - create with /opt/birdnetpi as home (no -m since dir exists) sudo useradd -d "$INSTALL_DIR" -s /bin/bash birdnetpi fi + +# Create spi and gpio groups if they don't exist (needed for DietPi/Orange Pi) +getent group spi >/dev/null || sudo groupadd spi +getent group gpio >/dev/null || sudo groupadd gpio + sudo usermod -aG audio,video,dialout,spi,gpio birdnetpi sudo chown birdnetpi:birdnetpi "$INSTALL_DIR" +# Grant birdnetpi user limited sudo access for systemctl commands +# This allows the web UI to query and control services without root access +echo "Configuring sudoers for service management..." +cat <<'EOF' | sudo tee /etc/sudoers.d/birdnetpi-systemctl > /dev/null +# Allow birdnetpi user to query and control birdnetpi services +# This is needed for the web UI to show service status +birdnetpi ALL=(root) NOPASSWD: /usr/bin/systemctl show birdnetpi-* *, \ + /usr/bin/systemctl is-active birdnetpi-*, \ + /usr/bin/systemctl start birdnetpi-*, \ + /usr/bin/systemctl stop birdnetpi-*, \ + /usr/bin/systemctl restart birdnetpi-*, \ + /usr/bin/systemctl enable birdnetpi-*, \ + /usr/bin/systemctl disable birdnetpi-*, \ + /usr/bin/systemctl daemon-reload, \ + /usr/bin/systemctl show caddy *, \ + /usr/bin/systemctl is-active caddy, \ + /usr/bin/systemctl start caddy, \ + /usr/bin/systemctl stop caddy, \ + /usr/bin/systemctl restart caddy, \ + /usr/bin/systemctl show redis *, \ + /usr/bin/systemctl is-active redis, \ + /usr/bin/systemctl start redis, \ + /usr/bin/systemctl restart redis, \ + /usr/bin/systemctl reboot +EOF +sudo chmod 0440 /etc/sudoers.d/birdnetpi-systemctl + # Clone repository directly to installation directory as birdnetpi user echo "Cloning repository..." sudo -u birdnetpi git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" +# Copy config file from /root to installation directory (if it exists) +# This makes it accessible to the birdnetpi user +if [ -f /root/birdnetpi_config.json ]; then + echo "Copying configuration file..." + sudo cp /root/birdnetpi_config.json "$INSTALL_DIR/birdnetpi_config.json" + sudo chown birdnetpi:birdnetpi "$INSTALL_DIR/birdnetpi_config.json" +fi + # Install uv package manager system-wide to /opt/uv echo "Installing uv package manager..." sudo mkdir -p /opt/uv @@ -195,33 +310,87 @@ done # Give DNS resolver a moment to stabilize sleep 2 -# If Waveshare library was downloaded to boot partition, copy to writable location -WAVESHARE_BOOT_PATH="/boot/firmware/waveshare-epd" +# Create cache directory for uv in tmpfs +# Using /tmp instead of /dev/shm as /dev/shm is often too small (512MB default) +# /tmp is larger and still avoids excessive SD card writes on most systems +UV_CACHE_DIR="/tmp/uv-cache" +sudo mkdir -p "$UV_CACHE_DIR" +sudo chown birdnetpi:birdnetpi "$UV_CACHE_DIR" + +# If Waveshare library was downloaded to boot partition, extract/copy to writable location +# Check multiple possible locations as boot partition mount varies by system +WAVESHARE_TARBALL_LOCATIONS=( + "/boot/firmware/waveshare-epd.tar.gz" + "/boot/waveshare-epd.tar.gz" + "/root/waveshare-epd.tar.gz" # Fallback location from rootfs copy +) +WAVESHARE_DIR_LOCATIONS=( + "/boot/firmware/waveshare-epd" + "/boot/waveshare-epd" + "/root/waveshare-epd" +) WAVESHARE_LIB_PATH="/opt/birdnetpi/waveshare-epd" -if [ -d "$WAVESHARE_BOOT_PATH" ] && [ -n "$EPAPER_EXTRAS" ]; then - echo "Using pre-downloaded Waveshare library from boot partition..." +WAVESHARE_FOUND="" - # Copy from boot partition (FAT32, root-owned) to writable location - # This is needed because uv needs write access to build the package - sudo cp -r "$WAVESHARE_BOOT_PATH" "$WAVESHARE_LIB_PATH" - sudo chown -R birdnetpi:birdnetpi "$WAVESHARE_LIB_PATH" +if [ -n "$EPAPER_EXTRAS" ]; then + # Try to find tarball first (preferred) + for tarball_path in "${WAVESHARE_TARBALL_LOCATIONS[@]}"; do + if [ -f "$tarball_path" ]; then + echo "Extracting pre-downloaded Waveshare library from $tarball_path..." + sudo mkdir -p /opt/birdnetpi + sudo tar -xzf "$tarball_path" -C /opt/birdnetpi + sudo chown -R birdnetpi:birdnetpi "$WAVESHARE_LIB_PATH" + WAVESHARE_FOUND="yes" + break + fi + done - cd "$INSTALL_DIR" + # Fall back to uncompressed directory (backward compatibility) + if [ -z "$WAVESHARE_FOUND" ]; then + for dir_path in "${WAVESHARE_DIR_LOCATIONS[@]}"; do + if [ -d "$dir_path" ]; then + echo "Copying pre-downloaded Waveshare library from $dir_path..." + sudo cp -r "$dir_path" "$WAVESHARE_LIB_PATH" + sudo chown -R birdnetpi:birdnetpi "$WAVESHARE_LIB_PATH" + WAVESHARE_FOUND="yes" + break + fi + done + fi + + if [ -n "$WAVESHARE_FOUND" ]; then + cd "$INSTALL_DIR" - # Patch pyproject.toml to use the copied local path instead of git URL - sudo -u birdnetpi sed -i 's|waveshare-epd = {git = "https://github.com/waveshareteam/e-Paper.git", subdirectory = "RaspberryPi_JetsonNano/python"}|waveshare-epd = {path = "/opt/birdnetpi/waveshare-epd"}|' pyproject.toml + # Patch pyproject.toml to use the local path instead of git URL + # Use a temp script to avoid quote escaping issues with su -c wrapper + cat > /tmp/patch_pyproject.sh << 'SEDEOF' +#!/bin/sh +cd /opt/birdnetpi +sed -i 's|waveshare-epd = {git = "https://github.com/waveshareteam/e-Paper.git", subdirectory = "RaspberryPi_JetsonNano/python"}|waveshare-epd = {path = "/opt/birdnetpi/waveshare-epd"}|' pyproject.toml +SEDEOF + chmod +x /tmp/patch_pyproject.sh + sudo -u birdnetpi /tmp/patch_pyproject.sh + rm -f /tmp/patch_pyproject.sh - # Regenerate lockfile since we changed the source - echo "Regenerating lockfile for local Waveshare library..." - sudo -u birdnetpi UV_HTTP_TIMEOUT=300 /opt/uv/uv lock --quiet + # Regenerate lockfile with the local path (respects the patched pyproject.toml) + sudo -u birdnetpi UV_CACHE_DIR="$UV_CACHE_DIR" /opt/uv/uv lock - echo "✓ Configured to use local Waveshare library" + # Patch Waveshare library to support Orange Pi (uses same GPIO pinout as Raspberry Pi) + if [ -f "$WAVESHARE_LIB_PATH/lib/waveshare_epd/epdconfig.py" ]; then + echo "Patching Waveshare library for Orange Pi support..." + python3 "$INSTALL_DIR/install/patch_waveshare_orangepi.py" "$WAVESHARE_LIB_PATH/lib/waveshare_epd/epdconfig.py" + fi + + echo "✓ Configured to use local Waveshare library" + else + echo "Note: Pre-downloaded Waveshare library not found, will download from GitHub" + fi fi # Install Python dependencies with retry mechanism (for network issues) echo "Installing Python dependencies..." cd "$INSTALL_DIR" -UV_CMD="sudo -u birdnetpi UV_HTTP_TIMEOUT=300 UV_EXTRA_INDEX_URL=https://www.piwheels.org/simple /opt/uv/uv sync --locked --no-dev --quiet" +UV_CMD="sudo -u birdnetpi UV_CACHE_DIR=$UV_CACHE_DIR UV_HTTP_TIMEOUT=300 UV_EXTRA_INDEX_URL=https://www.piwheels.org/simple /opt/uv/uv sync --locked --no-dev" if [ -n "$EPAPER_EXTRAS" ]; then UV_CMD="$UV_CMD $EPAPER_EXTRAS" fi @@ -267,4 +436,18 @@ fi # (uv sync ran as birdnetpi, but setup_app.py needs sudo for system operations) echo "" echo "Starting installation..." + +# Pass config values as environment variables if they were set +export BIRDNETPI_OS_KEY="${os_key:-}" +export BIRDNETPI_DEVICE_KEY="${device_key:-}" +export BIRDNETPI_DEVICE_NAME="${device_name:-}" +export BIRDNETPI_LATITUDE="${latitude:-}" +export BIRDNETPI_LONGITUDE="${longitude:-}" +export BIRDNETPI_TIMEZONE="${timezone:-}" +export BIRDNETPI_LANGUAGE="${language:-}" + "$INSTALL_DIR/.venv/bin/python" "$INSTALL_DIR/install/setup_app.py" + +# Clean up uv cache from tmpfs to free RAM +echo "Cleaning up temporary cache..." +sudo rm -rf /dev/shm/uv-cache diff --git a/install/patch_waveshare_orangepi.py b/install/patch_waveshare_orangepi.py new file mode 100644 index 00000000..f2623f90 --- /dev/null +++ b/install/patch_waveshare_orangepi.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Patch Waveshare ePaper library to support Orange Pi. + +This script modifies the epdconfig.py file to: +1. Add Orange Pi detection via /proc/device-tree/model +2. Add OrangePi class using lgpio for GPIO control +3. Update hardware detection logic to use OrangePi class when detected +""" + +import sys +from pathlib import Path + +ORANGEPI_CLASS = ''' +class OrangePi: + """Orange Pi GPIO implementation using lgpio. + + Uses the same pin definitions as Raspberry Pi for 40-pin header compatibility. + """ + # Pin definition (same as Raspberry Pi for 40-pin header compatibility) + RST_PIN = 17 + DC_PIN = 25 + CS_PIN = 8 + BUSY_PIN = 24 + PWR_PIN = 18 + MOSI_PIN = 10 + SCLK_PIN = 11 + + def __init__(self): + import spidev + import lgpio + + # Use gpiochip4 for Orange Pi 5 (40-pin header) + self.chip = lgpio.gpiochip_open(4) + + # Free pins if already claimed from previous tests + for pin in [self.RST_PIN, self.DC_PIN, self.PWR_PIN, self.BUSY_PIN]: + try: + lgpio.gpio_free(self.chip, pin) + except: + pass + + lgpio.gpio_claim_output(self.chip, self.RST_PIN) + lgpio.gpio_claim_output(self.chip, self.DC_PIN) + lgpio.gpio_claim_output(self.chip, self.PWR_PIN) + lgpio.gpio_claim_input(self.chip, self.BUSY_PIN) + + self.SPI = spidev.SpiDev() + + def digital_write(self, pin, value): + import lgpio + lgpio.gpio_write(self.chip, pin, 1 if value else 0) + + def digital_read(self, pin): + import lgpio + return lgpio.gpio_read(self.chip, pin) + + def delay_ms(self, delaytime): + import time + time.sleep(delaytime / 1000.0) + + def spi_writebyte(self, data): + self.SPI.writebytes(data) + + def spi_writebyte2(self, data): + self.SPI.writebytes2(data) + + def module_init(self, cleanup=False): + import lgpio + lgpio.gpio_write(self.chip, self.PWR_PIN, 1) + + # SPI device, bus = 4, device = 0 (Orange Pi 5 uses SPI4) + self.SPI.open(4, 0) + self.SPI.max_speed_hz = 4000000 + self.SPI.mode = 0b00 + return 0 + + def module_exit(self, cleanup=False): + import lgpio + import logging + logger = logging.getLogger(__name__) + logger.debug("spi end") + self.SPI.close() + + lgpio.gpio_write(self.chip, self.RST_PIN, 0) + lgpio.gpio_write(self.chip, self.DC_PIN, 0) + lgpio.gpio_write(self.chip, self.PWR_PIN, 0) + logger.debug("close 5V, Module enters 0 power consumption ...") + + if cleanup: + lgpio.gpiochip_close(self.chip) + +''' + + +def patch_epdconfig(filepath: Path) -> bool: + """Patch epdconfig.py to add Orange Pi support. + + Args: + filepath: Path to epdconfig.py + + Returns: + True if patching succeeded, False otherwise + """ + if not filepath.exists(): + print(f"Error: {filepath} not found") + return False + + # Read the file + content = filepath.read_text() + + # Check if already patched + if "class OrangePi:" in content: + print("Already patched - skipping") + return True + + # 1. Add device tree model check for Orange Pi detection + old_detection = """output, _ = process.communicate() +if sys.version_info[0] == 2: + output = output.decode(sys.stdout.encoding) + +if "Raspberry" in output:""" + + new_detection = """output, _ = process.communicate() +if sys.version_info[0] == 2: + output = output.decode(sys.stdout.encoding) + +# Also check device tree model for Orange Pi +if os.path.exists("/proc/device-tree/model"): + with open("/proc/device-tree/model", "r") as f: + output += f.read() + +if "Raspberry" in output:""" + + if old_detection in content: + content = content.replace(old_detection, new_detection) + print("✓ Added Orange Pi detection") + else: + print("Warning: Could not find detection code to patch") + + # 2. Add OrangePi class before RaspberryPi class + content = content.replace("class RaspberryPi:", ORANGEPI_CLASS + "class RaspberryPi:") + print("✓ Added OrangePi class") + + # 3. Update hardware selection logic + old_selection = """if "Raspberry" in output: + implementation = RaspberryPi()""" + + new_selection = """if "Raspberry" in output: + implementation = RaspberryPi() +elif "Orange Pi" in output: + implementation = OrangePi()""" + + if old_selection in content: + content = content.replace(old_selection, new_selection) + print("✓ Updated hardware selection logic") + else: + print("Warning: Could not find hardware selection code to patch") + + # Write back + filepath.write_text(content) + print(f"✓ Successfully patched {filepath}") + return True + + +def main() -> int: + """Run the Waveshare ePaper Orange Pi patcher.""" + if len(sys.argv) != 2: + print("Usage: patch_waveshare_orangepi.py ") + print("Example: patch_waveshare_orangepi.py /epdconfig.py") + return 1 + + filepath = Path(sys.argv[1]) + success = patch_epdconfig(filepath) + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/birdnetpi/cli/setup_system.py b/src/birdnetpi/cli/setup_system.py index bccb5ab0..00b53a0a 100644 --- a/src/birdnetpi/cli/setup_system.py +++ b/src/birdnetpi/cli/setup_system.py @@ -114,30 +114,34 @@ def detect_gps() -> tuple[float | None, float | None, str | None]: def get_boot_config() -> dict[str, str]: - """Load pre-configuration from boot volume. + """Load pre-configuration from boot volume or install directory. - Checks /boot/firmware/birdnetpi_config.txt for pre-configured values. + Checks for birdnetpi_config.json in multiple locations: + - /root/ (copied during installation by flash_sdcard.py) + - /boot/firmware/ (Raspberry Pi OS boot partition) + - /boot/ (DietPi/Armbian boot partition) Returns: Dict of pre-configured values (empty if not found) """ - boot_config_path = Path("/boot/firmware/birdnetpi_config.txt") - if not boot_config_path.exists(): - return {} - - config = {} - try: - for line in boot_config_path.read_text().splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - if "=" in line: - key, value = line.split("=", 1) - config[key.strip()] = value.strip() - except Exception as e: - click.echo(f"Warning: Could not read boot config: {e}", err=True) + import json + + # Check multiple possible config file locations + config_locations = [ + Path("/opt/birdnetpi/birdnetpi_config.json"), # Primary location + Path("/root/birdnetpi_config.json"), # Fallback (root-only) + Path("/boot/firmware/birdnetpi_config.json"), # Raspberry Pi OS + Path("/boot/birdnetpi_config.json"), # DietPi, Armbian + ] + + for config_path in config_locations: + if config_path.exists(): + try: + return json.loads(config_path.read_text()) + except Exception as e: + click.echo(f"Warning: Could not read boot config: {e}", err=True) - return config + return {} # No config file found in any location def is_attended_install() -> bool: @@ -173,6 +177,7 @@ def get_supported_devices() -> dict[str, str]: "pi_3b": "Raspberry Pi 3B/3B+", "pi_4b": "Raspberry Pi 4B", "pi_5": "Raspberry Pi 5", + "orange_pi_0w2": "Orange Pi Zero 2W", "orange_pi_5": "Orange Pi 5", "orange_pi_5_plus": "Orange Pi 5 Plus", "orange_pi_5_pro": "Orange Pi 5 Pro", diff --git a/src/birdnetpi/system/service_strategies.py b/src/birdnetpi/system/service_strategies.py index 190750d5..f6b5868f 100644 --- a/src/birdnetpi/system/service_strategies.py +++ b/src/birdnetpi/system/service_strategies.py @@ -121,7 +121,7 @@ def get_service_status(self, service_name: str) -> str: """Return the status of a specified system service.""" try: result = subprocess.run( - ["systemctl", "is-active", service_name], + ["sudo", "systemctl", "is-active", service_name], capture_output=True, text=True, check=False, @@ -180,7 +180,7 @@ def get_service_details(self, service_name: str) -> dict[str, Any]: try: # Get detailed service info using systemctl show result = subprocess.run( - ["systemctl", "show", service_name, "--no-pager"], + ["sudo", "systemctl", "show", service_name, "--no-pager"], capture_output=True, text=True, check=False, diff --git a/tests/birdnetpi/system/test_service_strategies.py b/tests/birdnetpi/system/test_service_strategies.py index 988b70a2..3c04da63 100644 --- a/tests/birdnetpi/system/test_service_strategies.py +++ b/tests/birdnetpi/system/test_service_strategies.py @@ -105,7 +105,7 @@ def test_get_service_status(self, mock_run, stdout, returncode, expected_status) assert status == expected_status mock_run.assert_called_once_with( - ["systemctl", "is-active", "test_service"], + ["sudo", "systemctl", "is-active", "test_service"], capture_output=True, text=True, check=False, @@ -167,6 +167,13 @@ def test_get_service_details_with_full_info(self, mock_run): assert details["uptime_seconds"] == 3600.0 # 1 hour difference assert details["sub_state"] == "running" + mock_run.assert_called_once_with( + ["sudo", "systemctl", "show", "test_service", "--no-pager"], + capture_output=True, + text=True, + check=False, + ) + @pytest.mark.parametrize( "stdout,expected_pid,expected_uptime", [