From 9a0258dc3d44f2cfff746a3fecee6d4b2530fae5 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 15:05:29 -0500 Subject: [PATCH 01/40] fix: Enable DietPi automation scripts for install.sh preservation Root cause: DietPi automation scripts only execute when AUTO_SETUP_INSTALL_SOFTWARE=1 is set. Without this, scripts are created but never run, leaving no install.sh after first boot. Changes: - Add AUTO_SETUP_INSTALL_SOFTWARE=1 to dietpi.txt config - Create Automation_Custom_PreScript.sh (before partition cleanup) - Create Automation_Custom_Script.sh (after cleanup, fallback) - Enhanced logging to /var/log/birdnetpi_automation.log - Add BIRDNETPI_README.txt with troubleshooting guide - Update summary messages for all preservation methods Implements quadruple redundancy for install.sh preservation: 1. AUTO_SETUP_CUSTOM_SCRIPT_EXEC (traditional) 2. preserve_installer.sh (custom script) 3. Automation_Custom_PreScript.sh (key fix - before cleanup) 4. Automation_Custom_Script.sh (fallback - after cleanup) Fixes install.sh not appearing on DietPi + Orange Pi 5 Pro where DIETPISETUP partition is deleted after first boot. --- install/flash_sdcard.py | 236 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 213 insertions(+), 23 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 50da7c69..cd105547 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -244,15 +244,46 @@ 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 @@ -263,25 +294,25 @@ def copy_installer_script( echo "Preserved birdnetpi_config.txt 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,7 +320,150 @@ 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.txt ]; then + cp -v /boot/birdnetpi_config.txt /root/birdnetpi_config.txt + echo "Preserved birdnetpi_config.txt from /boot" +elif [ -f /boot/firmware/birdnetpi_config.txt ]; then + cp -v /boot/firmware/birdnetpi_config.txt /root/birdnetpi_config.txt + echo "Preserved birdnetpi_config.txt 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]") @@ -1494,6 +1668,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"), @@ -2450,10 +2625,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 +2652,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" From 2999bc360b454d7c8b7fff28d039ee0b9c4baad1 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 15:21:38 -0500 Subject: [PATCH 02/40] fix: Copy install.sh to rootfs partition to survive DIETPISETUP deletion The DIETPISETUP partition is deleted after DietPi first boot, removing install.sh. While automation scripts attempt to preserve it, they run during first boot when the partition may already be gone. Solution: Also copy install.sh directly to /root on the rootfs partition during SD card flashing. This ensures install.sh persists regardless of when automation scripts execute. - Mount rootfs partition (partition 2) during configure_dietpi_boot - Copy install.sh to rootfs:/root/install.sh - Keeps automation scripts as fallback for macOS users (ext4 limitations) - Linux users get install.sh directly on persistent storage --- install/flash_sdcard.py | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index cd105547..e2c5add0 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1877,6 +1877,57 @@ def configure_dietpi_boot( # noqa: C901 # Copy BirdNET-Pi pre-configuration file if any settings provided copy_birdnetpi_config(boot_mount, config) + # CRITICAL: Also copy install.sh to rootfs partition + # The DIETPISETUP partition (boot_mount) will be deleted after first boot + # So we must also place install.sh on the persistent rootfs partition + if config.get("copy_installer"): + install_script = Path(__file__).parent / "install.sh" + if install_script.exists(): + 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, try to find and mount the ext4 rootfs partition + # Typically partition 2, but we need ext4fuse or similar + console.print( + "[yellow]Note: macOS cannot write to ext4 - " + "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 + ) + + # Copy install.sh to /root on rootfs + install_dest = rootfs_mount / "root" / "install.sh" + subprocess.run( + ["sudo", "cp", str(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: + subprocess.run(["sudo", "umount", str(rootfs_mount)], check=False) + finally: # Unmount console.print("[cyan]Unmounting boot partition...[/cyan]") From 259b4d882ae298366c8ec9a0b1faf2cdbe8864ba Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 15:41:08 -0500 Subject: [PATCH 03/40] feat: Enable macOS ext4 rootfs mounting via anylinuxfs macOS can write to ext4 filesystems using anylinuxfs. Update the configure_dietpi_boot function to attempt mounting the rootfs partition on macOS using diskutil. - Try to mount partition 2 (rootfs) using diskutil mount - anylinuxfs automatically handles ext4 filesystem access - Falls back to boot partition + automation scripts if mount fails - Unmounts rootfs after copying install.sh This ensures install.sh is written directly to persistent storage on macOS, not just relying on first-boot preservation scripts. --- install/flash_sdcard.py | 74 +++++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index e2c5add0..09b78312 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1891,16 +1891,65 @@ def configure_dietpi_boot( # noqa: C901 try: if platform.system() == "Darwin": - # On macOS, try to find and mount the ext4 rootfs partition - # Typically partition 2, but we need ext4fuse or similar - console.print( - "[yellow]Note: macOS cannot write to ext4 - " - "using boot partition only[/yellow]" - ) - console.print( - "[yellow]Automation scripts will preserve " - "install.sh during first boot[/yellow]" + # On macOS, use diskutil to mount the ext4 rootfs partition + # anylinuxfs allows writing to ext4 on macOS + rootfs_partition = f"{device}s2" + + # Try to mount with diskutil (anylinuxfs handles ext4) + mount_result = subprocess.run( + ["diskutil", "mount", rootfs_partition], + capture_output=True, + text=True, + check=False, ) + + if mount_result.returncode == 0: + # Find where it mounted + time.sleep(1) + mount_info = subprocess.run( + ["diskutil", "info", rootfs_partition], + capture_output=True, + text=True, + check=True, + ) + for line in mount_info.stdout.splitlines(): + if "Mount Point:" in line: + mount_path = line.split(":", 1)[1].strip() + if ( + mount_path + and mount_path != "Not applicable (no file system)" + ): + rootfs_mount = Path(mount_path) + rootfs_partition = rootfs_partition # Save for unmount + + # Copy install.sh to /root on rootfs + install_dest = rootfs_mount / "root" / "install.sh" + subprocess.run( + ["sudo", "cp", str(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]" + ) + break + else: + console.print( + "[yellow]Note: Could not mount rootfs partition - " + "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" @@ -1926,7 +1975,12 @@ def configure_dietpi_boot( # noqa: C901 finally: # Unmount rootfs if we mounted it if rootfs_mount and rootfs_partition: - subprocess.run(["sudo", "umount", str(rootfs_mount)], check=False) + if platform.system() == "Darwin": + subprocess.run( + ["diskutil", "unmount", "force", str(rootfs_mount)], check=False + ) + else: + subprocess.run(["sudo", "umount", str(rootfs_mount)], check=False) finally: # Unmount From e5e32c7a5a2cbc9595df03172c9024ce5994f2a8 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 15:45:51 -0500 Subject: [PATCH 04/40] fix: Use partition 3 for rootfs and create /root directory Fixed two issues with macOS rootfs mounting: 1. DietPi uses partition 3 for rootfs (1=BIOS, 2=DIETPISETUP, 3=ROOTFS) 2. /root directory doesn't exist on fresh rootfs - create it with mkdir -p Changes: - Changed from disk4s2 to disk4s3 for rootfs partition on macOS - Added 'sudo mkdir -p /root' before copying install.sh - Applied same fix to Linux path for consistency --- install/flash_sdcard.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 09b78312..04594ce1 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1893,11 +1893,12 @@ def configure_dietpi_boot( # noqa: C901 if platform.system() == "Darwin": # On macOS, use diskutil to mount the ext4 rootfs partition # anylinuxfs allows writing to ext4 on macOS - rootfs_partition = f"{device}s2" + # DietPi uses partition 3 for rootfs (1=BIOS, 2=DIETPISETUP, 3=ROOTFS) + rootfs_partition_name = f"{device}s3" # Try to mount with diskutil (anylinuxfs handles ext4) mount_result = subprocess.run( - ["diskutil", "mount", rootfs_partition], + ["diskutil", "mount", rootfs_partition_name], capture_output=True, text=True, check=False, @@ -1907,7 +1908,7 @@ def configure_dietpi_boot( # noqa: C901 # Find where it mounted time.sleep(1) mount_info = subprocess.run( - ["diskutil", "info", rootfs_partition], + ["diskutil", "info", rootfs_partition_name], capture_output=True, text=True, check=True, @@ -1920,10 +1921,16 @@ def configure_dietpi_boot( # noqa: C901 and mount_path != "Not applicable (no file system)" ): rootfs_mount = Path(mount_path) - rootfs_partition = rootfs_partition # Save for unmount + rootfs_partition = rootfs_partition_name + + # Ensure /root directory exists on rootfs + root_dir = rootfs_mount / "root" + subprocess.run( + ["sudo", "mkdir", "-p", str(root_dir)], check=True + ) # Copy install.sh to /root on rootfs - install_dest = rootfs_mount / "root" / "install.sh" + install_dest = root_dir / "install.sh" subprocess.run( ["sudo", "cp", str(install_script), str(install_dest)], check=True, @@ -1960,8 +1967,12 @@ def configure_dietpi_boot( # noqa: C901 ["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 install.sh to /root on rootfs - install_dest = rootfs_mount / "root" / "install.sh" + install_dest = root_dir / "install.sh" subprocess.run( ["sudo", "cp", str(install_script), str(install_dest)], check=True ) From d7f1e659355f56e7a1c98a2338b2231a541805c6 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 16:02:10 -0500 Subject: [PATCH 05/40] debug: Add partition discovery output for troubleshooting Added debug output to show: - All available partitions on the device - Which partition we're trying to mount - Error message when mount fails This will help diagnose why rootfs partition mounting is failing on macOS. --- install/flash_sdcard.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 04594ce1..3d7f361f 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1893,8 +1893,21 @@ def configure_dietpi_boot( # noqa: C901 if platform.system() == "Darwin": # On macOS, use diskutil to mount the ext4 rootfs partition # anylinuxfs allows writing to ext4 on macOS + + # Debug: List all partitions on the device + console.print("[dim]Checking available partitions...[/dim]") + list_result = subprocess.run( + ["diskutil", "list", device], + capture_output=True, + text=True, + check=False, + ) + if list_result.returncode == 0: + console.print(f"[dim]{list_result.stdout}[/dim]") + # DietPi uses partition 3 for rootfs (1=BIOS, 2=DIETPISETUP, 3=ROOTFS) rootfs_partition_name = f"{device}s3" + console.print(f"[dim]Attempting to mount {rootfs_partition_name}...[/dim]") # Try to mount with diskutil (anylinuxfs handles ext4) mount_result = subprocess.run( @@ -1950,9 +1963,10 @@ def configure_dietpi_boot( # noqa: C901 break else: console.print( - "[yellow]Note: Could not mount rootfs partition - " + f"[yellow]Note: Could not mount {rootfs_partition_name} - " "using boot partition only[/yellow]" ) + console.print(f"[dim]Mount error: {mount_result.stderr}[/dim]") console.print( "[yellow]Automation scripts will preserve " "install.sh during first boot[/yellow]" From 1fbbce72dd873d305494fa32a728ab8ba5ffe98d Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 16:08:04 -0500 Subject: [PATCH 06/40] fix: Use partition 1 for rootfs on DietPi Orange Pi 5 Pro Discovered via debug output that DietPi Orange Pi 5 Pro uses: - Partition 1: Linux Filesystem (rootfs) - 1.3 GB - Partition 2: DIETPISETUP (FAT32) - 1.0 MB Changed from disk4s3 to disk4s1 for macOS rootfs mounting. This matches the actual partition layout on the SD card. --- install/flash_sdcard.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 3d7f361f..cd60e9a1 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1905,8 +1905,10 @@ def configure_dietpi_boot( # noqa: C901 if list_result.returncode == 0: console.print(f"[dim]{list_result.stdout}[/dim]") - # DietPi uses partition 3 for rootfs (1=BIOS, 2=DIETPISETUP, 3=ROOTFS) - rootfs_partition_name = f"{device}s3" + # Find the Linux Filesystem partition (rootfs) + # On Orange Pi 5 Pro: partition 1 is rootfs, partition 2 is DIETPISETUP + # Try partition 1 first (most common for DietPi) + rootfs_partition_name = f"{device}s1" console.print(f"[dim]Attempting to mount {rootfs_partition_name}...[/dim]") # Try to mount with diskutil (anylinuxfs handles ext4) From 50c844cfab7f7c4028d4e8e8f682751c9cd1a6b8 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 17:59:01 -0500 Subject: [PATCH 07/40] feat: Use anylinuxfs to mount DietPi rootfs on macOS Implemented proper anylinuxfs integration for DietPi similar to Armbian: - Check if anylinuxfs is installed - Unmount any existing anylinuxfs mounts - Run 'sudo anylinuxfs disk4s1 -w false' to mount rootfs - Wait for mount point to appear in /Volumes - Copy install.sh to /root on the mounted rootfs - Unmount with 'sudo anylinuxfs unmount' when done This should finally enable rootfs mounting on macOS with anylinuxfs. --- install/flash_sdcard.py | 174 +++++++++++++++++++++++----------------- 1 file changed, 101 insertions(+), 73 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index cd60e9a1..0e2dc703 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1891,84 +1891,105 @@ def configure_dietpi_boot( # noqa: C901 try: if platform.system() == "Darwin": - # On macOS, use diskutil to mount the ext4 rootfs partition - # anylinuxfs allows writing to ext4 on macOS - - # Debug: List all partitions on the device - console.print("[dim]Checking available partitions...[/dim]") - list_result = subprocess.run( - ["diskutil", "list", device], - capture_output=True, - text=True, - check=False, - ) - if list_result.returncode == 0: - console.print(f"[dim]{list_result.stdout}[/dim]") - - # Find the Linux Filesystem partition (rootfs) - # On Orange Pi 5 Pro: partition 1 is rootfs, partition 2 is DIETPISETUP - # Try partition 1 first (most common for DietPi) - rootfs_partition_name = f"{device}s1" - console.print(f"[dim]Attempting to mount {rootfs_partition_name}...[/dim]") - - # Try to mount with diskutil (anylinuxfs handles ext4) - mount_result = subprocess.run( - ["diskutil", "mount", rootfs_partition_name], - capture_output=True, - text=True, - check=False, - ) + # 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 + + 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]" + ) - if mount_result.returncode == 0: - # Find where it mounted - time.sleep(1) - mount_info = subprocess.run( - ["diskutil", "info", rootfs_partition_name], + # Unmount any existing anylinuxfs mount first + subprocess.run( + ["sudo", "anylinuxfs", "unmount"], capture_output=True, - text=True, - check=True, + check=False, + timeout=10, ) - for line in mount_info.stdout.splitlines(): - if "Mount Point:" in line: - mount_path = line.split(":", 1)[1].strip() - if ( - mount_path - and mount_path != "Not applicable (no file system)" - ): - rootfs_mount = Path(mount_path) - rootfs_partition = rootfs_partition_name + time.sleep(2) - # Ensure /root directory exists on rootfs - root_dir = rootfs_mount / "root" - subprocess.run( - ["sudo", "mkdir", "-p", str(root_dir)], check=True - ) - - # Copy install.sh to /root on rootfs - install_dest = root_dir / "install.sh" - subprocess.run( - ["sudo", "cp", str(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]" - ) + # 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]") + possible_mount_names = ["dietpi_root", "DIETPI", "Linux"] + + for attempt in range(60): + time.sleep(1) + volumes_path = Path("/Volumes") + for mount_name in possible_mount_names: + potential_mount = volumes_path / mount_name + if potential_mount.exists() and potential_mount.is_dir(): + rootfs_mount = potential_mount + rootfs_partition = rootfs_partition_name break - else: + + 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 install.sh to /root on rootfs + install_dest = root_dir / "install.sh" + subprocess.run( + ["sudo", "cp", str(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]" + ) + 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( - f"[yellow]Note: Could not mount {rootfs_partition_name} - " + "[yellow]anylinuxfs mount failed - " "using boot partition only[/yellow]" ) - console.print(f"[dim]Mount error: {mount_result.stderr}[/dim]") console.print( "[yellow]Automation scripts will preserve " "install.sh during first boot[/yellow]" @@ -2003,9 +2024,16 @@ def configure_dietpi_boot( # noqa: C901 # Unmount rootfs if we mounted it if rootfs_mount and rootfs_partition: if platform.system() == "Darwin": - subprocess.run( - ["diskutil", "unmount", "force", str(rootfs_mount)], check=False - ) + # Unmount anylinuxfs + console.print("[cyan]Unmounting anylinuxfs...[/cyan]") + try: + subprocess.run( + ["sudo", "anylinuxfs", "unmount"], check=True, timeout=10 + ) + except subprocess.TimeoutExpired: + subprocess.run( + ["sudo", "anylinuxfs", "stop"], check=False, timeout=5 + ) else: subprocess.run(["sudo", "umount", str(rootfs_mount)], check=False) From 54828d4a1ed5879644f619e265f2ec14de140bb5 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 18:09:09 -0500 Subject: [PATCH 08/40] fix: Detect anylinuxfs mount by comparing /Volumes before/after anylinuxfs creates a mount using the partition name (e.g., disk4s1) rather than a filesystem label. The previous code looked for specific mount names like "dietpi_root", "DIETPI", or "Linux", which didn't exist. Now captures the list of volumes before running anylinuxfs, then finds the new volume by comparing before/after and checking for Linux filesystem markers (/etc, /root, /usr). This correctly detects the mount regardless of what name anylinuxfs uses. --- install/flash_sdcard.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 0e2dc703..29e81176 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1896,6 +1896,8 @@ def configure_dietpi_boot( # noqa: C901 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( @@ -1926,6 +1928,11 @@ def configure_dietpi_boot( # noqa: C901 ) 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"], @@ -1936,17 +1943,28 @@ def configure_dietpi_boot( # noqa: C901 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]") - possible_mount_names = ["dietpi_root", "DIETPI", "Linux"] for attempt in range(60): time.sleep(1) - volumes_path = Path("/Volumes") - for mount_name in possible_mount_names: - potential_mount = volumes_path / mount_name - if potential_mount.exists() and potential_mount.is_dir(): - rootfs_mount = potential_mount - rootfs_partition = rootfs_partition_name - break + + # 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 From 9caebdf3b55e2cac7afc00bccb5f89a216de8669 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 19:40:49 -0500 Subject: [PATCH 09/40] fix: Use dd instead of cp to avoid macOS extended attributes error When copying to ext4 filesystem via anylinuxfs, macOS cp tries to copy extended attributes which are not supported on ext4. This causes: "could not copy extended attributes: Operation not permitted" Using dd instead bypasses extended attributes entirely and just copies the raw file content. Also improved anylinuxfs unmount error handling to catch CalledProcessError in addition to TimeoutExpired. --- install/flash_sdcard.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 29e81176..c84385c6 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1980,10 +1980,18 @@ def configure_dietpi_boot( # noqa: C901 subprocess.run(["sudo", "mkdir", "-p", str(root_dir)], check=True) # Copy install.sh to /root on rootfs + # Use dd to avoid extended attributes issues with macOS install_dest = root_dir / "install.sh" subprocess.run( - ["sudo", "cp", str(install_script), str(install_dest)], + [ + "sudo", + "dd", + f"if={install_script}", + f"of={install_dest}", + "bs=1m", + ], check=True, + capture_output=True, ) subprocess.run( ["sudo", "chmod", "+x", str(install_dest)], check=True @@ -2046,11 +2054,19 @@ def configure_dietpi_boot( # noqa: C901 console.print("[cyan]Unmounting anylinuxfs...[/cyan]") try: subprocess.run( - ["sudo", "anylinuxfs", "unmount"], check=True, timeout=10 + ["sudo", "anylinuxfs", "unmount"], + check=True, + timeout=10, + capture_output=True, ) - except subprocess.TimeoutExpired: + 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 + ["sudo", "anylinuxfs", "stop"], + check=False, + timeout=5, + capture_output=True, ) else: subprocess.run(["sudo", "umount", str(rootfs_mount)], check=False) From 551019f5a534318e178abf451cd02e6dc54a79fe Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 22:19:13 -0500 Subject: [PATCH 10/40] fix: Create spi and gpio groups on DietPi/Orange Pi DietPi and Orange Pi systems don't have spi/gpio groups by default like Raspberry Pi OS does. This was causing usermod errors: usermod: group 'spi' does not exist usermod: group 'gpio' does not exist Now create these groups if they don't exist before adding the birdnetpi user to them. Uses getent to check group existence safely. --- install/install.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/install/install.sh b/install/install.sh index 39dc2aa8..950cfe12 100644 --- a/install/install.sh +++ b/install/install.sh @@ -148,6 +148,11 @@ 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" From 953b9ad74951fef0942f1a1ca1dfadf904b3ca8b Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 22:42:29 -0500 Subject: [PATCH 11/40] feat: Copy birdnetpi_config.txt to rootfs and add OS/device pre-configuration - Copy birdnetpi_config.txt to rootfs partition (persists after DIETPISETUP deletion) - Add os_key and device_key to config file for unattended setup - Update install.sh to source config from /root, /boot/firmware, or /boot - Clean up temp config file after flashing - Initialize config_file variable to fix pyright warning --- install/flash_sdcard.py | 58 ++++++++++++++++++++++++++++++++++++----- install/install.sh | 21 ++++++++++++++- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index c84385c6..d34d4381 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -469,15 +469,22 @@ def copy_installer_script( console.print(f"[green]✓ Copied install.sh to {final_path}[/green]") -def copy_birdnetpi_config( +def copy_birdnetpi_config( # noqa: C901 boot_mount: Path, config: dict[str, Any], -) -> None: + os_key: str | None = None, + device_key: str | None = None, +) -> Path | None: """Copy birdnetpi_config.txt 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"] @@ -492,6 +499,15 @@ def copy_birdnetpi_config( config_lines.append(f"export BIRDNETPI_BRANCH={config['birdnet_branch']}") has_config = True + # OS and device information + if os_key: + config_lines.append(f"os_key={os_key}") + has_config = True + + if device_key: + config_lines.append(f"device_key={device_key}") + has_config = True + # Application settings if config.get("birdnet_device_name"): config_lines.append(f"device_name={config['birdnet_device_name']}") @@ -521,8 +537,9 @@ def copy_birdnetpi_config( ["sudo", "cp", str(temp_config), str(boot_mount / "birdnetpi_config.txt")], 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) @@ -1592,6 +1609,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 @@ -1875,11 +1895,11 @@ def configure_dietpi_boot( # noqa: C901 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 to rootfs partition + # 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 on the persistent rootfs partition + # So we must also place install.sh and config on the persistent rootfs partition if config.get("copy_installer"): install_script = Path(__file__).parent / "install.sh" if install_script.exists(): @@ -2000,8 +2020,28 @@ def configure_dietpi_boot( # noqa: C901 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.txt" + subprocess.run( + [ + "sudo", + "dd", + f"if={config_file}", + f"of={config_dest}", + "bs=1m", + ], + check=True, + capture_output=True, + ) + console.print( + "[green]✓ birdnetpi_config.txt copied to " + "rootfs:/root/birdnetpi_config.txt[/green]" + ) + console.print( - "[dim]Persists after DIETPISETUP partition deletion[/dim]" + "[dim]Files persist after DIETPISETUP partition deletion[/dim]" ) else: console.print( @@ -2072,6 +2112,10 @@ def configure_dietpi_boot( # noqa: C901 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": diff --git a/install/install.sh b/install/install.sh index 950cfe12..3ae15899 100644 --- a/install/install.sh +++ b/install/install.sh @@ -11,7 +11,18 @@ # bash install.sh --test-epaper set -e -# Configuration +# Load config from boot partition or /root if it exists +# Check multiple locations (for DietPi, DIETPISETUP is deleted after first boot) +for config_location in "/root/birdnetpi_config.txt" "/boot/firmware/birdnetpi_config.txt" "/boot/birdnetpi_config.txt"; do + if [ -f "$config_location" ]; then + echo "Loading configuration from $config_location" + # shellcheck disable=SC1090 + source "$config_location" + break + fi +done + +# Configuration (can be overridden by birdnetpi_config.txt) REPO_URL="${BIRDNETPI_REPO_URL:-https://github.com/mverteuil/BirdNET-Pi.git}" BRANCH="${BIRDNETPI_BRANCH:-main}" INSTALL_DIR="/opt/birdnetpi" @@ -272,4 +283,12 @@ 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_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" From de9ec5fe791e0b6bc5a5be7fa536899861fef8a9 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 23:02:26 -0500 Subject: [PATCH 12/40] feat: Add Orange Pi Zero 2W and remove Orange Pi 5 - Add orange_pi_0w2 to Armbian and DietPi device lists - Remove orange_pi_5 from both OS image configurations - Keep Orange Pi 5 Plus and Pro variants - Update Armbian URL: orangepizero2w - Update DietPi URL: DietPi_OrangePiZero2W-ARMv8-Bookworm.img.xz - Update docstring example to use orange_pi_5_pro --- install/flash_sdcard.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index d34d4381..27e26608 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -615,10 +615,10 @@ def copy_birdnetpi_config( # noqa: C901 }, ), ( - "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, }, ), @@ -693,10 +693,10 @@ def copy_birdnetpi_config( # noqa: C901 ), # 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, }, ), @@ -1149,7 +1149,7 @@ def download_image_new(os_key: str, device_key: str, download_dir: Path) -> Path 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: From 3cfcd85f4ad2d2e3cc0d10dde90d17b00af03c53 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Mon, 3 Nov 2025 11:06:30 -0500 Subject: [PATCH 13/40] fix: Export OS and device keys as environment variables - Add BIRDNETPI_OS_KEY and BIRDNETPI_DEVICE_KEY to environment exports - Enables setup_system.py to use pre-configuration when config file is missing - Fixes issue where OS/device prompts appeared despite config file being created - Works around DietPi deleting DIETPISETUP partition after first boot --- install/install.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/install/install.sh b/install/install.sh index 3ae15899..daaf07dc 100644 --- a/install/install.sh +++ b/install/install.sh @@ -285,6 +285,8 @@ 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:-}" From 4d246ec47b34359799328ab29a9141936234366f Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Mon, 3 Nov 2025 11:23:45 -0500 Subject: [PATCH 14/40] feat: Add SPI support for Orange Pi Zero 2W - Add orange_pi_0w2 to DEVICE_PROPERTIES with SPI capability - Fix DEVICE_PROPERTIES keys to match actual device keys (use underscores) - Add Allwinner H618 SPI overlay (spi-spidev) for Orange Pi Zero 2W - Update SPI configuration to handle Orange Pi Zero 2W specifically - Improve error handling when SPI overlay is not configured - Add chip description to SPI enabled messages (Allwinner H618 vs RK3588) --- install/flash_sdcard.py | 103 ++++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 46 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 27e26608..44e2fbdf 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 }, } @@ -1837,7 +1837,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, @@ -1847,43 +1847,54 @@ 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-M0 is available on GPIO header + # ROCK 5B: RK3588 SPI1-M1 or SPI3-M1 depending on configuration + spi_overlay = None + if device_key == "orange_pi_0w2": + spi_overlay = "spi-spidev" # Allwinner H618 + elif device_key == "rock_5b": + spi_overlay = "rk3588-spi1-m1-cs0-spidev" # RK3588 + elif device_key in ["orange_pi_5_plus", "orange_pi_5_pro"]: + spi_overlay = "rk3588-spi4-m0-cs1-spidev" # RK3588 + + 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") + + 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]") + else: + msg = "Note: SPI configuration for this device not yet implemented" + console.print(f"[yellow]{msg}[/yellow]") else: # Other SBC types not yet implemented From 0a52dfefa4bf7e563a06e087a2f9cca6c811a8d3 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Mon, 3 Nov 2025 12:08:46 -0500 Subject: [PATCH 15/40] fix: Export os_key and device_key in config file The config file needs to export os_key and device_key so they're available to install.sh when it sources the file. Without export, the variables are set in the config file's scope but not passed to the parent shell. --- install/flash_sdcard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 44e2fbdf..adb30ea3 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -501,11 +501,11 @@ def copy_birdnetpi_config( # noqa: C901 # OS and device information if os_key: - config_lines.append(f"os_key={os_key}") + config_lines.append(f"export os_key={os_key}") has_config = True if device_key: - config_lines.append(f"device_key={device_key}") + config_lines.append(f"export device_key={device_key}") has_config = True # Application settings From 755b89fa787998b82cc200733959c22bd516ad25 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 4 Nov 2025 23:17:44 -0500 Subject: [PATCH 16/40] refactor: Remove dead legacy code from flash_sdcard.py - Remove PI_IMAGES dictionary (was only referenced by dead functions) - Remove select_pi_version() function (never called) - Remove download_image() function (never called, superseded by download_image_new) - Update FlasherWizardApp instantiation to use only OS_PROPERTIES argument The TUI wizard (flasher_tui.py) now builds os_images internally from the devices module, so flash_sdcard.py no longer needs to pass it. Total cleanup: 212 lines removed --- install/devices.py | 380 ++++++++++++++++++++++++++++++ install/flash_sdcard.py | 213 +---------------- install/flasher_tui.py | 49 ++-- src/birdnetpi/cli/setup_system.py | 37 +-- 4 files changed, 418 insertions(+), 261 deletions(-) create mode 100644 install/devices.py 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 adb30ea3..96158b34 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -729,16 +729,6 @@ def copy_birdnetpi_config( # noqa: C901 }, } -# 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" @@ -943,207 +933,6 @@ 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. @@ -2682,7 +2471,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() 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/src/birdnetpi/cli/setup_system.py b/src/birdnetpi/cli/setup_system.py index bccb5ab0..d1d527db 100644 --- a/src/birdnetpi/cli/setup_system.py +++ b/src/birdnetpi/cli/setup_system.py @@ -114,30 +114,33 @@ 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 {} + import json - 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) + # Check multiple possible config file locations + config_locations = [ + Path("/root/birdnetpi_config.json"), # Installation location + 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: From 199071e4e9422e6a6351b33333f9ba7da63f228b Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 4 Nov 2025 23:22:44 -0500 Subject: [PATCH 17/40] fix: Save WiFi and git branch settings to JSON config The copy_birdnetpi_config function was still writing shell script format to birdnetpi_config.txt instead of JSON format to birdnetpi_config.json. This meant WiFi settings and git branch were not being persisted to the boot configuration. Changes: - Rewrite copy_birdnetpi_config to output JSON format - Add wifi_ssid, wifi_password, wifi_auth to saved config - Change output filename from birdnetpi_config.txt to birdnetpi_config.json - Use json.dumps() instead of building shell script lines - Update docstring to reflect JSON format --- install/flash_sdcard.py | 64 +++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 96158b34..04eb8d8a 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -475,7 +475,7 @@ def copy_birdnetpi_config( # noqa: C901 os_key: str | None = None, device_key: str | None = None, ) -> Path | None: - """Copy birdnetpi_config.txt to boot partition for unattended install.sh. + """Copy birdnetpi_config.json to boot partition for unattended install.sh. Args: boot_mount: Path to mounted boot partition @@ -486,55 +486,49 @@ def copy_birdnetpi_config( # noqa: C901 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 - - if config.get("birdnet_branch"): - config_lines.append(f"export BIRDNETPI_BRANCH={config['birdnet_branch']}") - has_config = True + # Build JSON config from all available settings + boot_config: dict[str, Any] = {} # OS and device information if os_key: - config_lines.append(f"export os_key={os_key}") - has_config = True - + boot_config["os"] = os_key if device_key: - config_lines.append(f"export device_key={device_key}") - has_config = True + 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"): + 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, ) console.print("[green]✓ BirdNET-Pi configuration written to boot partition[/green]") From 33ce5528348b69fc79450436dc8fc749a6c00c88 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 09:43:01 -0500 Subject: [PATCH 18/40] fix: Update install.sh to parse JSON config instead of shell script The install.sh script was still looking for birdnetpi_config.txt and trying to source it as a shell script, but we now write JSON format to birdnetpi_config.json. Changes: - Update config file paths from .txt to .json - Parse JSON using python3 instead of sourcing shell script - Extract repo_url and branch fields from JSON - Gracefully handle missing python3 or parse errors - Update comment to reference .json format --- install/install.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/install/install.sh b/install/install.sh index daaf07dc..aaffecc3 100644 --- a/install/install.sh +++ b/install/install.sh @@ -13,16 +13,20 @@ set -e # Load config from boot partition or /root if it exists # Check multiple locations (for DietPi, DIETPISETUP is deleted after first boot) -for config_location in "/root/birdnetpi_config.txt" "/boot/firmware/birdnetpi_config.txt" "/boot/birdnetpi_config.txt"; do +for config_location in "/root/birdnetpi_config.json" "/boot/firmware/birdnetpi_config.json" "/boot/birdnetpi_config.json"; do if [ -f "$config_location" ]; then echo "Loading configuration from $config_location" - # shellcheck disable=SC1090 - source "$config_location" + # Parse JSON config using python3 (available on all target systems) + if command -v python3 >/dev/null 2>&1; then + BIRDNETPI_REPO_URL=$(python3 -c "import json; print(json.load(open('$config_location')).get('repo_url', ''))" 2>/dev/null || echo "") + BIRDNETPI_BRANCH=$(python3 -c "import json; print(json.load(open('$config_location')).get('branch', ''))" 2>/dev/null || echo "") + export BIRDNETPI_REPO_URL BIRDNETPI_BRANCH + fi break fi done -# Configuration (can be overridden by birdnetpi_config.txt) +# Configuration (can be overridden by birdnetpi_config.json) REPO_URL="${BIRDNETPI_REPO_URL:-https://github.com/mverteuil/BirdNET-Pi.git}" BRANCH="${BIRDNETPI_BRANCH:-main}" INSTALL_DIR="/opt/birdnetpi" From 74cdaab223c57e6b304ce6c403557219dff79bc3 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 10:22:17 -0500 Subject: [PATCH 19/40] fix: Update DietPi preservation scripts to use .json config files The DietPi preservation scripts embedded in flash_sdcard.py were still referencing birdnetpi_config.txt instead of the new .json format. This caused fresh flashes to write .txt files instead of .json files, which broke the JSON parsing in install.sh and setup_system.py. Changes: - Update first preservation script to copy birdnetpi_config.json - Update second preservation script to copy birdnetpi_config.json - Update direct rootfs copy to use birdnetpi_config.json - Update all echo messages to reference .json files All three code paths that preserve config for DietPi now use .json format. --- install/flash_sdcard.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 04eb8d8a..19ccf480 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -286,12 +286,12 @@ def copy_installer_script( 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 # Verify preservation was successful @@ -366,12 +366,12 @@ def copy_installer_script( fi # Also preserve config if present -if [ -f /boot/birdnetpi_config.txt ]; then - cp -v /boot/birdnetpi_config.txt /root/birdnetpi_config.txt - echo "Preserved birdnetpi_config.txt from /boot" -elif [ -f /boot/firmware/birdnetpi_config.txt ]; then - cp -v /boot/firmware/birdnetpi_config.txt /root/birdnetpi_config.txt - echo "Preserved birdnetpi_config.txt from /boot/firmware" +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 @@ -1817,7 +1817,7 @@ def configure_dietpi_boot( # noqa: C901 # Also copy config file if it was created if config_file and config_file.exists(): - config_dest = root_dir / "birdnetpi_config.txt" + config_dest = root_dir / "birdnetpi_config.json" subprocess.run( [ "sudo", @@ -1830,8 +1830,8 @@ def configure_dietpi_boot( # noqa: C901 capture_output=True, ) console.print( - "[green]✓ birdnetpi_config.txt copied to " - "rootfs:/root/birdnetpi_config.txt[/green]" + "[green]✓ birdnetpi_config.json copied to " + "rootfs:/root/birdnetpi_config.json[/green]" ) console.print( From 4706871752a75cd2e685a234edcfdfbb4b7304e1 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 11:41:13 -0500 Subject: [PATCH 20/40] fix: Substitute repo URL and branch defaults in install.sh at flash time Instead of trying to parse JSON before Python is installed, we now: 1. Read install.sh during flash 2. Substitute the REPO_URL and BRANCH defaults with configured values 3. Write the modified install.sh to boot partition This ensures the branch setting is used even before Python is available, eliminating the need for JSON parsing in early boot stages. Simplifies install.sh by removing the JSON parsing logic that ran before Python was installed (which always failed silently). --- install/flash_sdcard.py | 26 +++++++++++++++++++++++--- install/install.sh | 19 +++---------------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 19ccf480..55602589 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -231,10 +231,30 @@ def copy_installer_script( console.print("[yellow]Warning: install.sh not found, skipping copy[/yellow]") return - install_dest = boot_mount / "install.sh" + # Read install.sh and substitute repo/branch defaults if configured + install_content = install_script.read_text() - # Copy install.sh to boot partition - subprocess.run(["sudo", "cp", str(install_script), str(install_dest)], check=True) + # 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) + + 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 diff --git a/install/install.sh b/install/install.sh index aaffecc3..c7fbfea2 100644 --- a/install/install.sh +++ b/install/install.sh @@ -11,22 +11,9 @@ # bash install.sh --test-epaper set -e -# Load config from boot partition or /root if it exists -# Check multiple locations (for DietPi, DIETPISETUP is deleted after first boot) -for config_location in "/root/birdnetpi_config.json" "/boot/firmware/birdnetpi_config.json" "/boot/birdnetpi_config.json"; do - if [ -f "$config_location" ]; then - echo "Loading configuration from $config_location" - # Parse JSON config using python3 (available on all target systems) - if command -v python3 >/dev/null 2>&1; then - BIRDNETPI_REPO_URL=$(python3 -c "import json; print(json.load(open('$config_location')).get('repo_url', ''))" 2>/dev/null || echo "") - BIRDNETPI_BRANCH=$(python3 -c "import json; print(json.load(open('$config_location')).get('branch', ''))" 2>/dev/null || echo "") - export BIRDNETPI_REPO_URL BIRDNETPI_BRANCH - fi - break - fi -done - -# Configuration (can be overridden by birdnetpi_config.json) +# 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" From 8c0d8fc8b552cff3d98c9a230388fe490459af0c Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 12:34:48 -0500 Subject: [PATCH 21/40] debug: Add logging to show config values during install.sh substitution --- install/flash_sdcard.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 55602589..a86bf95e 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -234,6 +234,11 @@ def copy_installer_script( # 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"] From 551192f5512dc9de1bec85f6a776e72ec9edfeef Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 13:15:49 -0500 Subject: [PATCH 22/40] fix: Use modified install.sh for rootfs copy instead of original The root cause was that copy_installer_script() correctly substituted the branch/repo defaults in install.sh and copied it to the boot partition, but then the DietPi rootfs copy code was copying the ORIGINAL install.sh again, overwriting the modified one. Now copy_installer_script() returns the path to the modified temp file, and the rootfs copy uses that instead of reading the original file again. --- install/flash_sdcard.py | 368 ++++++++++++++++++++-------------------- 1 file changed, 186 insertions(+), 182 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index a86bf95e..2ea02824 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -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,13 +226,18 @@ 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 # Read install.sh and substitute repo/branch defaults if configured install_content = install_script.read_text() @@ -493,6 +501,9 @@ def copy_installer_script( 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( # noqa: C901 boot_mount: Path, @@ -1711,7 +1722,7 @@ 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 config_file = copy_birdnetpi_config(boot_mount, config, os_key, device_key) @@ -1719,216 +1730,209 @@ def configure_dietpi_boot( # noqa: C901 # 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"): - install_script = Path(__file__).parent / "install.sh" - if install_script.exists(): - console.print("[cyan]Copying install.sh to rootfs partition...[/cyan]") + 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 + # 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) + 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" - # Get list of volumes BEFORE anylinuxfs mount - initial_volumes = ( - set(volumes_path.iterdir()) if volumes_path.exists() else set() - ) + 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]" + ) - # Mount with anylinuxfs - mount_result = subprocess.run( - ["sudo", "anylinuxfs", rootfs_partition_name, "-w", "false"], - capture_output=False, # Allow password prompt - check=False, - ) + # Unmount any existing anylinuxfs mount first + subprocess.run( + ["sudo", "anylinuxfs", "unmount"], + capture_output=True, + check=False, + timeout=10, + ) + time.sleep(2) - 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]") + # Get list of volumes BEFORE anylinuxfs mount + initial_volumes = ( + set(volumes_path.iterdir()) if volumes_path.exists() else set() + ) - for attempt in range(60): - time.sleep(1) + # Mount with anylinuxfs + mount_result = subprocess.run( + ["sudo", "anylinuxfs", rootfs_partition_name, "-w", "false"], + capture_output=False, # Allow password prompt + check=False, + ) - # 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 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]") - if attempt % 5 == 0 and attempt > 0: - console.print(f"[dim]Still waiting... ({attempt}s)[/dim]") + for attempt in range(60): + time.sleep(1) - if rootfs_mount and rootfs_mount.exists(): - console.print(f"[green]✓ Mounted at {rootfs_mount}[/green]") + # 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) - # Ensure /root directory exists on rootfs - root_dir = rootfs_mount / "root" - subprocess.run(["sudo", "mkdir", "-p", str(root_dir)], check=True) + console.print( + "[green]✓ install.sh copied to rootfs:/root/install.sh[/green]" + ) - # Copy install.sh to /root on rootfs - # Use dd to avoid extended attributes issues with macOS - install_dest = root_dir / "install.sh" + # 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={install_script}", - f"of={install_dest}", + f"if={config_file}", + f"of={config_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]" + "[green]✓ birdnetpi_config.json copied to " + "rootfs:/root/birdnetpi_config.json[/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]" - ) - - 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]" + "[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]" ) - 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 + 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) + # Ensure /root directory exists on rootfs + root_dir = rootfs_mount / "root" + subprocess.run(["sudo", "mkdir", "-p", str(root_dir)], check=True) - # Copy install.sh to /root on rootfs - install_dest = root_dir / "install.sh" - subprocess.run( - ["sudo", "cp", str(install_script), str(install_dest)], check=True - ) - subprocess.run(["sudo", "chmod", "+x", str(install_dest)], 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) + 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 From d0663b7cbd8346f17fcfa69d9b473db2e0930c6a Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 14:55:58 -0500 Subject: [PATCH 23/40] fix: Show progress when installing Python dependencies Remove --quiet flags from uv lock and uv sync commands to provide feedback during the dependency installation process, which can take several minutes on slower devices. --- install/install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install/install.sh b/install/install.sh index c7fbfea2..7a5c77bd 100644 --- a/install/install.sh +++ b/install/install.sh @@ -220,7 +220,7 @@ if [ -d "$WAVESHARE_BOOT_PATH" ] && [ -n "$EPAPER_EXTRAS" ]; then # 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 + sudo -u birdnetpi UV_HTTP_TIMEOUT=300 /opt/uv/uv lock echo "✓ Configured to use local Waveshare library" fi @@ -228,7 +228,7 @@ 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_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 From 7755eb8348cfce827c8564cd912fc189f5f7e6be Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 15:05:54 -0500 Subject: [PATCH 24/40] fix: Copy config to install dir for birdnetpi user access The birdnetpi user cannot read /root/birdnetpi_config.json due to directory permissions (700). Copy the config file to /opt/birdnetpi/ during installation where the birdnetpi user has access. Changes: - install.sh: Copy config from /root to /opt/birdnetpi after cloning - setup_system.py: Check /opt/birdnetpi first for config file --- install/install.sh | 8 ++++++++ src/birdnetpi/cli/setup_system.py | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/install/install.sh b/install/install.sh index 7a5c77bd..f8762cd2 100644 --- a/install/install.sh +++ b/install/install.sh @@ -162,6 +162,14 @@ sudo chown birdnetpi:birdnetpi "$INSTALL_DIR" 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 diff --git a/src/birdnetpi/cli/setup_system.py b/src/birdnetpi/cli/setup_system.py index d1d527db..d9c00eed 100644 --- a/src/birdnetpi/cli/setup_system.py +++ b/src/birdnetpi/cli/setup_system.py @@ -128,7 +128,8 @@ def get_boot_config() -> dict[str, str]: # Check multiple possible config file locations config_locations = [ - Path("/root/birdnetpi_config.json"), # Installation location + 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 ] From ea303b7192a5d2622e70f0935200ad7adf078a7c Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 17:32:51 -0500 Subject: [PATCH 25/40] fix: Add Orange Pi Zero 2W to supported devices The orange_pi_0w2 device key was not in the supported devices list, causing a KeyError during setup. Add it with proper display name. --- src/birdnetpi/cli/setup_system.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/birdnetpi/cli/setup_system.py b/src/birdnetpi/cli/setup_system.py index d9c00eed..00b53a0a 100644 --- a/src/birdnetpi/cli/setup_system.py +++ b/src/birdnetpi/cli/setup_system.py @@ -177,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", From e7f8264941ebc539a0f68e36945bb1960c062418 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 17:41:19 -0500 Subject: [PATCH 26/40] perf: Enable uv package cache for faster retries Configure UV_CACHE_DIR=/var/cache/uv to cache downloaded packages. This speeds up retries when installation fails and avoids re-downloading packages that were already fetched successfully. --- install/install.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/install/install.sh b/install/install.sh index f8762cd2..4f57dd2b 100644 --- a/install/install.sh +++ b/install/install.sh @@ -210,6 +210,10 @@ done # Give DNS resolver a moment to stabilize sleep 2 +# Create cache directory for uv (speeds up downloads and retries) +sudo mkdir -p /var/cache/uv +sudo chown birdnetpi:birdnetpi /var/cache/uv + # If Waveshare library was downloaded to boot partition, copy to writable location WAVESHARE_BOOT_PATH="/boot/firmware/waveshare-epd" WAVESHARE_LIB_PATH="/opt/birdnetpi/waveshare-epd" @@ -228,7 +232,7 @@ if [ -d "$WAVESHARE_BOOT_PATH" ] && [ -n "$EPAPER_EXTRAS" ]; then # 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 + sudo -u birdnetpi UV_CACHE_DIR=/var/cache/uv UV_HTTP_TIMEOUT=300 /opt/uv/uv lock echo "✓ Configured to use local Waveshare library" fi @@ -236,7 +240,7 @@ 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" +UV_CMD="sudo -u birdnetpi UV_CACHE_DIR=/var/cache/uv 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 From 1d0a48f8b8bd69cb282071f60f62f604da6be85a Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 17:47:10 -0500 Subject: [PATCH 27/40] perf: Use tmpfs for uv cache and clean up after install - Cache packages in /dev/shm (RAM) instead of SD card - Speeds up retries without wearing SD card - Clean up cache at end to free RAM for runtime --- install/install.sh | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/install/install.sh b/install/install.sh index 4f57dd2b..881aaf1a 100644 --- a/install/install.sh +++ b/install/install.sh @@ -210,9 +210,10 @@ done # Give DNS resolver a moment to stabilize sleep 2 -# Create cache directory for uv (speeds up downloads and retries) -sudo mkdir -p /var/cache/uv -sudo chown birdnetpi:birdnetpi /var/cache/uv +# Create cache directory for uv in tmpfs (speeds up downloads and retries) +# Using /dev/shm avoids writing cache to SD card +sudo mkdir -p /dev/shm/uv-cache +sudo chown birdnetpi:birdnetpi /dev/shm/uv-cache # If Waveshare library was downloaded to boot partition, copy to writable location WAVESHARE_BOOT_PATH="/boot/firmware/waveshare-epd" @@ -232,7 +233,7 @@ if [ -d "$WAVESHARE_BOOT_PATH" ] && [ -n "$EPAPER_EXTRAS" ]; then # Regenerate lockfile since we changed the source echo "Regenerating lockfile for local Waveshare library..." - sudo -u birdnetpi UV_CACHE_DIR=/var/cache/uv UV_HTTP_TIMEOUT=300 /opt/uv/uv lock + sudo -u birdnetpi UV_CACHE_DIR=/dev/shm/uv-cache UV_HTTP_TIMEOUT=300 /opt/uv/uv lock echo "✓ Configured to use local Waveshare library" fi @@ -240,7 +241,7 @@ fi # Install Python dependencies with retry mechanism (for network issues) echo "Installing Python dependencies..." cd "$INSTALL_DIR" -UV_CMD="sudo -u birdnetpi UV_CACHE_DIR=/var/cache/uv UV_HTTP_TIMEOUT=300 UV_EXTRA_INDEX_URL=https://www.piwheels.org/simple /opt/uv/uv sync --locked --no-dev" +UV_CMD="sudo -u birdnetpi UV_CACHE_DIR=/dev/shm/uv-cache 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 @@ -297,3 +298,7 @@ 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 From 8d3a169df04654ba7b321dc378b884c28568d2cd Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 19:06:22 -0500 Subject: [PATCH 28/40] fix: Add sudo access for systemd service status on DietPi/Orange Pi Fix service status detection showing "unknown" for all services on DietPi/Orange Pi platforms by adding sudo to systemctl commands. On DietPi, the birdnetpi user cannot access systemd D-Bus without sudo privileges, resulting in "Failed to connect to bus" errors. Changes: - Add sudo prefix to systemctl commands in EmbeddedSystemdStrategy - Create /etc/sudoers.d/birdnetpi-systemctl during installation - Grant NOPASSWD sudo access for service management commands - Update test expectations to match new command format The sudoers file uses wildcards to allow flags like --no-pager and includes permissions for birdnetpi services, caddy, redis, and reboot. --- install/install.sh | 27 +++++++++++++++++++ src/birdnetpi/system/service_strategies.py | 4 +-- .../system/test_service_strategies.py | 9 ++++++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/install/install.sh b/install/install.sh index 881aaf1a..e1d7b17e 100644 --- a/install/install.sh +++ b/install/install.sh @@ -158,6 +158,33 @@ 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" 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", [ From 02e0d9e64ae0ba25e793c5491f879ca8d508997e Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 19:45:59 -0500 Subject: [PATCH 29/40] feat: Enable SPI on DietPi/Armbian platforms for e-paper HAT support Add support for enabling SPI on DietPi/Armbian (Orange Pi) platforms by detecting and configuring /boot/armbianEnv.txt. Changes: - Check for existing SPI devices first (skip if already enabled) - Support Raspberry Pi OS via /boot/firmware/config.txt (existing) - Support DietPi/Armbian via /boot/armbianEnv.txt (new) - Add spi-spidev to overlays parameter - Handle both new and existing overlays configurations - Provide warning if platform cannot be detected This ensures e-paper HAT detection works on all supported platforms. --- install/install.sh | 59 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/install/install.sh b/install/install.sh index e1d7b17e..49ed083e 100644 --- a/install/install.sh +++ b/install/install.sh @@ -89,19 +89,53 @@ 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 "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 + ARMBIAN_CONFIG="/boot/armbianEnv.txt" + if [ -f "$ARMBIAN_CONFIG" ]; then + echo "Detected Armbian/DietPi, checking $ARMBIAN_CONFIG..." + if grep -q "^overlays=.*spi-spidev" "$ARMBIAN_CONFIG"; then + SPI_ENABLED=true else - echo "dtparam=spi=on" | sudo tee -a "$BOOT_CONFIG" > /dev/null + 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 + SPI_ENABLED=true fi + fi + + if [ "$SPI_ENABLED" = true ]; then echo "" echo "========================================" echo "SPI interface enabled!" @@ -116,6 +150,9 @@ 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 From 8ecfd99c1028d4522ab4cfeef3154dd6f7113233 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 20:46:28 -0500 Subject: [PATCH 30/40] fix: Check for both armbianEnv.txt and dietpiEnv.txt for SPI config DietPi uses /boot/dietpiEnv.txt instead of /boot/armbianEnv.txt on some platforms. Update SPI detection to check for both files. This fixes SPI enablement on Orange Pi 5 Pro running DietPi. --- install/install.sh | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/install/install.sh b/install/install.sh index 49ed083e..cb31878c 100644 --- a/install/install.sh +++ b/install/install.sh @@ -115,25 +115,27 @@ else fi fi - # DietPi/Armbian on Orange Pi: /boot/armbianEnv.txt - ARMBIAN_CONFIG="/boot/armbianEnv.txt" - if [ -f "$ARMBIAN_CONFIG" ]; then - echo "Detected Armbian/DietPi, checking $ARMBIAN_CONFIG..." - if grep -q "^overlays=.*spi-spidev" "$ARMBIAN_CONFIG"; then - SPI_ENABLED=true - 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" + # 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..." + if grep -q "^overlays=.*spi-spidev" "$ARMBIAN_CONFIG"; then + SPI_ENABLED=true else - # Create new overlays line - echo "overlays=spi-spidev" | sudo tee -a "$ARMBIAN_CONFIG" > /dev/null + 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 + SPI_ENABLED=true fi - SPI_ENABLED=true + break fi - fi + done if [ "$SPI_ENABLED" = true ]; then echo "" From 8425853159079a88252e54b438692ccfc2f6fc93 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 21:00:44 -0500 Subject: [PATCH 31/40] fix: Respect platform-specific SPI overlays on Armbian/DietPi On RK3588-based boards like Orange Pi 5 Pro, the platform comes with pre-configured SPI overlays (e.g., rk3588-spi4-m0-cs1-spidev). The installer was incorrectly adding the generic spi-spidev overlay on top of these, which could cause conflicts. Changes: - Detect any existing SPI overlay (platform-specific or generic) - If SPI overlay exists, only verify/add param_spidev_spi_bus=0 - Don't add spi-spidev if platform-specific overlay already present - Only add spi-spidev on systems without any SPI overlay configured This ensures we respect platform-specific device tree configurations while still enabling SPI on boards that need the generic overlay. --- install/install.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/install/install.sh b/install/install.sh index cb31878c..9ea137f5 100644 --- a/install/install.sh +++ b/install/install.sh @@ -119,7 +119,17 @@ else for ARMBIAN_CONFIG in "/boot/armbianEnv.txt" "/boot/dietpiEnv.txt"; do if [ -f "$ARMBIAN_CONFIG" ]; then echo "Detected Armbian/DietPi, checking $ARMBIAN_CONFIG..." - if grep -q "^overlays=.*spi-spidev" "$ARMBIAN_CONFIG"; then + + # 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" + + # 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 + fi + SPI_ENABLED=true else echo "Enabling SPI in $ARMBIAN_CONFIG..." @@ -131,6 +141,10 @@ 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 From a01251b38ff67ba749250946e15db91e49b43cfe Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 21:06:36 -0500 Subject: [PATCH 32/40] fix: Only trigger reboot when boot config is modified When SPI overlay and param_spidev_spi_bus are both already configured, no reboot is needed since nothing was changed. Only set SPI_ENABLED=true (which triggers reboot) when we actually modify the boot configuration. This prevents unnecessary reboots when re-running the installer on a system that already has SPI properly configured. --- install/install.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/install/install.sh b/install/install.sh index 9ea137f5..0495fbe2 100644 --- a/install/install.sh +++ b/install/install.sh @@ -128,9 +128,10 @@ else 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 + else + echo "SPI already configured in $ARMBIAN_CONFIG" fi - - SPI_ENABLED=true else echo "Enabling SPI in $ARMBIAN_CONFIG..." # Check if overlays line exists From cdf552942af4cf6739b20f58a7467ad4bef5dbc4 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 21:26:10 -0500 Subject: [PATCH 33/40] fix: Handle RK3588 SPI overlay format in installer - Detect RK3588 overlays with incorrect chip prefix - Replace with correct format (spi4-m2-cs0-spidev without prefix) - DietPi automatically prepends overlay_prefix, so overlay names should not include it - Add param_spidev_max_freq=100000000 for RK3588 platforms - Only trigger reboot when configuration is actually modified Tested on Orange Pi 5 Pro - /dev/spidev4.0 now appears correctly after reboot --- install/install.sh | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/install/install.sh b/install/install.sh index 0495fbe2..59f92079 100644 --- a/install/install.sh +++ b/install/install.sh @@ -124,13 +124,31 @@ else 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 - else - echo "SPI already configured in $ARMBIAN_CONFIG" + 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..." From a170e91c62b0669e5dac6b97aa1a6f2934bc8164 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 21:37:31 -0500 Subject: [PATCH 34/40] fix: Configure correct SPI overlay at flash time for RK3588 platforms - Use spi4-m2-cs0-spidev overlay (M2-CS0 variant is verified working) - Remove chip prefix from overlay names (DietPi auto-prepends from overlay_prefix) - Add param_spidev_max_freq=100000000 for RK3588 platforms (required) - Also fixed ROCK 5B overlay format for consistency This eliminates the need for the installer to reboot when SPI is enabled since the SD card boots with correct SPI configuration from the start. --- install/flash_sdcard.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 2ea02824..e31ca8b1 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1667,15 +1667,18 @@ def configure_dietpi_boot( # noqa: C901 # Determine which SPI overlay to use based on device # Orange Pi Zero 2W: Allwinner H618 SPI1 - # Orange Pi 5 series: RK3588 SPI4-M0 is available on GPIO header + # 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 = "rk3588-spi1-m1-cs0-spidev" # RK3588 + spi_overlay = "spi1-m1-cs0-spidev" # RK3588 (prefix auto-prepended) elif device_key in ["orange_pi_5_plus", "orange_pi_5_pro"]: - spi_overlay = "rk3588-spi4-m0-cs1-spidev" # RK3588 + spi_overlay = "spi4-m2-cs0-spidev" # RK3588 M2-CS0 (prefix auto-prepended) if spi_overlay: # Check if overlays line exists @@ -1697,6 +1700,11 @@ def configure_dietpi_boot( # noqa: C901 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") From 23c07d1f3bb5e74cd099002808a0f3f84dec11db Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 21:43:44 -0500 Subject: [PATCH 35/40] feat: Download Waveshare library at flash time for DietPi The configure_dietpi_boot() function now downloads the Waveshare ePaper library to the boot partition when SPI is enabled, matching the behavior of configure_boot_partition_new() for Raspberry Pi OS. This eliminates the massive git clone during install.sh which was failing due to the repository containing 20k+ STM32 firmware files. The flasher uses sparse-checkout to download only the Python subdirectory (~6MB). The install.sh script can then use the pre-downloaded library from /boot/firmware/waveshare-epd (or /boot/waveshare-epd on DietPi). --- install/flash_sdcard.py | 72 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index e31ca8b1..6b57da6b 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1719,6 +1719,78 @@ def configure_dietpi_boot( # noqa: C901 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]") + + # 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]" + ) else: msg = "Note: SPI configuration for this device not yet implemented" console.print(f"[yellow]{msg}[/yellow]") From af17a557a665eb53005e2e45103e615b48e9c772 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 21:46:20 -0500 Subject: [PATCH 36/40] refactor: Extract Waveshare download into common helper function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created download_waveshare_library() helper to eliminate code duplication between configure_dietpi_boot() and configure_boot_partition_new(). The helper function: - Uses sparse-checkout to download only Python subdirectory (~6MB) - Avoids cloning 20k+ STM32 firmware files from the full repository - Provides clear documentation and consistent behavior across platforms This reduces the code from ~130 lines (65 lines × 2) to ~70 lines (65 lines helper + 2 simple calls), improving maintainability. --- install/flash_sdcard.py | 208 +++++++++++++++------------------------- 1 file changed, 75 insertions(+), 133 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 6b57da6b..cf4120c8 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1421,6 +1421,77 @@ 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) instead of + the full repository which includes 20k+ STM32 firmware files. + + Args: + boot_mount: Path to the mounted boot partition where library should be copied + """ + 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]") + + def configure_dietpi_boot( # noqa: C901 device: str, config: dict[str, Any], os_key: str, device_key: str ) -> None: @@ -1720,77 +1791,8 @@ def configure_dietpi_boot( # noqa: C901 msg = f"✓ SPI enabled for ePaper HAT ({chip_desc} overlay: {spi_overlay})" console.print(f"[green]{msg}[/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) else: msg = "Note: SPI configuration for this device not yet implemented" console.print(f"[yellow]{msg}[/yellow]") @@ -2273,68 +2275,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) From e00d25e3082398db54192437981d794930e0fe7e Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 5 Nov 2025 22:26:21 -0500 Subject: [PATCH 37/40] fix(flasher): Copy only essential Waveshare files to boot partition - Copy only lib/ directory and setup.py (essential for installation) - Skip pic/ (example images, several MB) and examples/ (test scripts) - Prevents 'No space left on device' error on ~200MB boot partitions - Update docstring and comments to reflect space-saving approach --- install/flash_sdcard.py | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index cf4120c8..03019fbd 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1424,8 +1424,9 @@ def configure_armbian_with_anylinuxfs( # noqa: C901 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) instead of - the full repository which includes 20k+ STM32 firmware files. + Uses sparse-checkout to download only the Python subdirectory (~6MB transfer), + then copies only essential files (lib/ and setup.py) to save boot partition space. + Skips pic/ (example images) and examples/ (test scripts) which aren't needed. Args: boot_mount: Path to the mounted boot partition where library should be copied @@ -1438,10 +1439,10 @@ def download_waveshare_library(boot_mount: Path) -> None: 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 + # Clone only the Python subdirectory using sparse-checkout + # Then copy only essential files (lib/ and setup.py) to save boot partition space with console.status( - "[cyan]Downloading Waveshare ePaper library (Python subdirectory, ~6MB transfer)...[/cyan]" + "[cyan]Downloading Waveshare ePaper library (essential files only)...[/cyan]" ): # Initialize sparse checkout subprocess.run( @@ -1482,12 +1483,29 @@ def download_waveshare_library(boot_mount: Path) -> None: check=True, ) - # Copy only the Python subdirectory to boot partition + # Copy only essential files (lib/ and setup.py) to boot partition + # Skip pic/ (example images) and examples/ (test scripts) to save space python_dir = temp_waveshare / "RaspberryPi_JetsonNano" / "python" - subprocess.run( - ["sudo", "cp", "-r", str(python_dir), str(waveshare_dest)], - check=True, - ) + + # Create destination directory + subprocess.run(["sudo", "mkdir", "-p", str(waveshare_dest)], check=True) + + # Copy lib directory (the actual library code) + lib_dir = python_dir / "lib" + if lib_dir.exists(): + subprocess.run( + ["sudo", "cp", "-r", str(lib_dir), str(waveshare_dest / "lib")], + check=True, + ) + + # Copy setup.py (needed for pip installation) + setup_file = python_dir / "setup.py" + if setup_file.exists(): + subprocess.run( + ["sudo", "cp", str(setup_file), str(waveshare_dest / "setup.py")], + check=True, + ) + shutil.rmtree(temp_waveshare) console.print("[green]✓ Waveshare ePaper library downloaded to boot partition[/green]") From 35dd7db6c2ddea778f8b54f9b513639b23acb1a2 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Fri, 7 Nov 2025 21:43:17 -0500 Subject: [PATCH 38/40] fix: Resolve Waveshare library installation issues on DietPi The previous implementation had several issues preventing the Waveshare library from being installed from the pre-downloaded tarball: 1. Boot partition mount location was hardcoded to /boot/firmware, but DietPi uses different mount points and deletes DIETPISETUP after first boot 2. The 'uv lock' command was regenerating the lockfile by accessing Git, even with the patched pyproject.toml, causing /dev/shm to run out of space 3. Tarball was only on boot partition which gets deleted on DietPi first boot Changes: - **flash_sdcard.py**: Copy Waveshare tarball to rootfs:/root/ for persistence - **install.sh**: Check multiple locations for tarball (boot partition variants + rootfs) - **install.sh**: Remove 'uv lock' command - let 'uv sync' handle the local path - **install.sh**: Add fallback message when tarball not found This ensures the compressed Waveshare library is available even after the DIETPISETUP partition is deleted, preventing the 20,805-file git clone that exhausts /dev/shm memory. --- install/flash_sdcard.py | 87 ++++++++++++++++++++++++++++++----------- install/install.sh | 63 ++++++++++++++++++++++------- 2 files changed, 112 insertions(+), 38 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 03019fbd..f27fa071 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1425,25 +1425,30 @@ 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 copies only essential files (lib/ and setup.py) to save boot partition space. + 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 library should be copied + boot_mount: Path to the mounted boot partition where tarball should be copied """ console.print() - waveshare_dest = boot_mount / "waveshare-epd" + waveshare_tarball = boot_mount / "waveshare-epd.tar.gz" temp_waveshare = Path("/tmp/waveshare_clone") + temp_staging = Path("/tmp/waveshare_staging") - # Remove old temp clone if it exists + # Remove old temp directories if they exist + # Use rm -rf because git clone may create files with restricted permissions if temp_waveshare.exists(): - shutil.rmtree(temp_waveshare) + 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 copy only essential files (lib/ and setup.py) to save boot partition space - with console.status( - "[cyan]Downloading Waveshare ePaper library (essential files only)...[/cyan]" - ): + # 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( [ @@ -1483,31 +1488,47 @@ def download_waveshare_library(boot_mount: Path) -> None: check=True, ) - # Copy only essential files (lib/ and setup.py) to boot partition + # Stage only essential files for compression # Skip pic/ (example images) and examples/ (test scripts) to save space python_dir = temp_waveshare / "RaspberryPi_JetsonNano" / "python" - - # Create destination directory - subprocess.run(["sudo", "mkdir", "-p", str(waveshare_dest)], check=True) + 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(): - subprocess.run( - ["sudo", "cp", "-r", str(lib_dir), str(waveshare_dest / "lib")], - check=True, - ) + shutil.copytree(lib_dir, staging_dir / "lib") # Copy setup.py (needed for pip installation) setup_file = python_dir / "setup.py" if setup_file.exists(): - subprocess.run( - ["sudo", "cp", str(setup_file), str(waveshare_dest / "setup.py")], - check=True, - ) + shutil.copy2(setup_file, staging_dir / "setup.py") - shutil.rmtree(temp_waveshare) - console.print("[green]✓ Waveshare ePaper library downloaded to boot partition[/green]") + # 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 @@ -1966,6 +1987,26 @@ def configure_dietpi_boot( # noqa: C901 "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]" ) diff --git a/install/install.sh b/install/install.sh index 59f92079..f83806bf 100644 --- a/install/install.sh +++ b/install/install.sh @@ -314,27 +314,60 @@ sleep 2 sudo mkdir -p /dev/shm/uv-cache sudo chown birdnetpi:birdnetpi /dev/shm/uv-cache -# If Waveshare library was downloaded to boot partition, copy to writable location -WAVESHARE_BOOT_PATH="/boot/firmware/waveshare-epd" +# 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 + + # 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 - cd "$INSTALL_DIR" + 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 + 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 - # Regenerate lockfile since we changed the source - echo "Regenerating lockfile for local Waveshare library..." - sudo -u birdnetpi UV_CACHE_DIR=/dev/shm/uv-cache UV_HTTP_TIMEOUT=300 /opt/uv/uv lock + # IMPORTANT: Do NOT run 'uv lock' - it will try to access git even with patched pyproject.toml + # The lockfile will be regenerated automatically during 'uv sync' with the local path - echo "✓ Configured to use local Waveshare library" + 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) From 88220486234ee26558656be2a47f90b28622d5b8 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Fri, 7 Nov 2025 21:52:03 -0500 Subject: [PATCH 39/40] fix: Use /tmp for uv cache instead of /dev/shm /dev/shm is often limited to 512MB on many systems, which is insufficient for uv's cache when building packages. This causes 'No space left on device' errors even for small packages. Switch to /tmp which is typically larger and still avoids excessive SD card writes on most systems (often tmpfs-backed or similar). --- install/install.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/install/install.sh b/install/install.sh index f83806bf..47022480 100644 --- a/install/install.sh +++ b/install/install.sh @@ -309,10 +309,12 @@ done # Give DNS resolver a moment to stabilize sleep 2 -# Create cache directory for uv in tmpfs (speeds up downloads and retries) -# Using /dev/shm avoids writing cache to SD card -sudo mkdir -p /dev/shm/uv-cache -sudo chown birdnetpi:birdnetpi /dev/shm/uv-cache +# 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 @@ -373,7 +375,7 @@ fi # Install Python dependencies with retry mechanism (for network issues) echo "Installing Python dependencies..." cd "$INSTALL_DIR" -UV_CMD="sudo -u birdnetpi UV_CACHE_DIR=/dev/shm/uv-cache UV_HTTP_TIMEOUT=300 UV_EXTRA_INDEX_URL=https://www.piwheels.org/simple /opt/uv/uv sync --locked --no-dev" +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 From bec3cd739e1d762039b10f59dc2ece6aec3761a0 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sat, 8 Nov 2025 17:12:53 -0500 Subject: [PATCH 40/40] feat: Add Orange Pi 5 + DietPi support for e-Paper HAT - Add patch_waveshare_orangepi.py to automatically add Orange Pi support to Waveshare library during installation - Replace build-essential with minimal dependencies (gcc, libc6-dev) to reduce installation size (removes perl, make, g++, dpkg-dev) - Add libportaudio2 and libsndfile1 for audio dependencies - Fix pyproject.toml patching using heredoc to avoid DietPi su wrapper quote mangling issues - OrangePi class uses lgpio with gpiochip4 and SPI bus 4 - Detects Orange Pi via /proc/device-tree/model - Idempotent patching (can run multiple times safely) --- install/install.sh | 25 +++- install/patch_waveshare_orangepi.py | 178 ++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 install/patch_waveshare_orangepi.py diff --git a/install/install.sh b/install/install.sh index 47022480..b0237a6f 100644 --- a/install/install.sh +++ b/install/install.sh @@ -194,7 +194,8 @@ 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 @@ -361,10 +362,24 @@ if [ -n "$EPAPER_EXTRAS" ]; then cd "$INSTALL_DIR" # Patch pyproject.toml to use the 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 - - # IMPORTANT: Do NOT run 'uv lock' - it will try to access git even with patched pyproject.toml - # The lockfile will be regenerated automatically during 'uv sync' with the local path + # 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 with the local path (respects the patched pyproject.toml) + sudo -u birdnetpi UV_CACHE_DIR="$UV_CACHE_DIR" /opt/uv/uv lock + + # 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 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())