From be3ebdb7fa13062507325c8811a7822b367235bf Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Thu, 23 Oct 2025 23:19:24 -0400 Subject: [PATCH 01/48] feat: Auto-enable SPI interface for e-paper HAT during installation - Add enable_spi_interface() to check and enable SPI in boot config - Prompt user to reboot when SPI is enabled - Run SPI check early in installer before hardware detection - After reboot, SPI devices will be available for HAT detection This ensures the Waveshare e-paper HAT is properly detected even on fresh installations where SPI is disabled by default. --- install/setup_app.py | 72 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/install/setup_app.py b/install/setup_app.py index d94c4a24..1504cf3b 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -210,6 +210,52 @@ def install_uv() -> None: ) +def enable_spi_interface() -> bool: + """Enable SPI interface in Raspberry Pi boot configuration. + + Waveshare e-paper HATs require SPI to be enabled. This function checks if + SPI is enabled and enables it if needed. + + Returns: + bool: True if a reboot is required to apply changes + """ + boot_config = Path("/boot/firmware/config.txt") + if not boot_config.exists(): + # Not a Raspberry Pi or config not found + return False + + try: + content = boot_config.read_text() + + # Check if SPI is already enabled + if "dtparam=spi=on" in content and not content.startswith("#dtparam=spi=on"): + return False # Already enabled, no reboot needed + + # Enable SPI by uncommenting the line + updated_content = content.replace("#dtparam=spi=on", "dtparam=spi=on") + + # If the line doesn't exist at all, add it + if "dtparam=spi=on" not in updated_content: + updated_content += "\n# Enable SPI for e-paper HAT\ndtparam=spi=on\n" + + # Write the updated config + subprocess.run( + ["sudo", "tee", str(boot_config)], + input=updated_content, + text=True, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + log("✓", "SPI interface enabled in boot config (reboot required)") + return True # Reboot required + + except Exception as e: + log("✗", f"Failed to enable SPI: {e}") + return False + + def has_waveshare_epaper_hat() -> bool: """Detect if a Waveshare e-paper HAT is connected. @@ -619,6 +665,32 @@ def main() -> None: print("=" * 60) print() + # Check and enable SPI if needed (must be before any hardware detection) + print() + log("→", "Checking SPI interface for e-paper HAT support") + needs_reboot = enable_spi_interface() + if needs_reboot: + print() + print("=" * 60) + print("REBOOT REQUIRED") + print("=" * 60) + print() + print("SPI interface has been enabled for e-paper HAT support.") + print("The system needs to reboot to apply this change.") + print() + print("After reboot, please run the installer again:") + print(" cd /opt/birdnetpi && sudo -u birdnetpi .venv/bin/python install/setup_app.py") + print() + print("=" * 60) + print() + response = input("Reboot now? (y/n): ").strip().lower() + if response == "y": + subprocess.run(["sudo", "reboot"], check=True) + else: + print("Please reboot manually and re-run the installer.") + sys.exit(0) + log("✓", "SPI interface check complete") + try: # Wave 1: System setup (parallel - apt-update already done in install.sh) print() From 5d13266f067435bfaae067a225b06386463484c3 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Thu, 23 Oct 2025 23:24:18 -0400 Subject: [PATCH 02/48] feat: Add retry logic for network failures during dependency installation - Retry up to 3 times with 5-second delay between attempts - Specifically handles GitHub network errors when installing waveshare-epd - Improves reliability when installing epaper extras on unstable networks Fixes intermittent 'Could not resolve host: github.com' errors during waveshare-epd installation from Git repository. --- install/setup_app.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/install/setup_app.py b/install/setup_app.py index 1504cf3b..2dd4d6e1 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -299,16 +299,30 @@ def install_python_dependencies() -> None: else: log("ℹ", "No e-paper HAT detected, skipping epaper extras") # noqa: RUF001 - # uv is installed to /opt/uv/bin/uv - result = subprocess.run( - cmd, - cwd="/opt/birdnetpi", - check=False, - stdin=subprocess.DEVNULL, - capture_output=True, - text=True, - ) - if result.returncode != 0: + # Retry up to 3 times for network failures (GitHub access for waveshare-epd) + max_retries = 3 + for attempt in range(1, max_retries + 1): + result = subprocess.run( + cmd, + cwd="/opt/birdnetpi", + check=False, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + ) + if result.returncode == 0: + break + + # Check if it's a network error + if "Could not resolve host" in result.stderr or "failed to fetch" in result.stderr: + if attempt < max_retries: + log("⚠", f"Network error, retrying ({attempt}/{max_retries})...") + import time + + time.sleep(5) # Wait 5 seconds before retry + continue + + # Non-network error or final retry failed raise RuntimeError(f"Failed to install Python dependencies: {result.stderr}") From 83bef17b700140a1c9cec5917a6ba440ad526106 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Thu, 23 Oct 2025 23:33:03 -0400 Subject: [PATCH 03/48] fix: Use sudo for Redis config file permission checks - Replace Path.exists() with sudo test -f to check files - Avoids Permission denied errors on /etc/redis/redis.conf - Use cp -n flag to avoid overwriting existing backup Fixes: [Errno 13] Permission denied: '/etc/redis/redis.conf' --- install/setup_app.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/install/setup_app.py b/install/setup_app.py index 2dd4d6e1..785f455a 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -353,14 +353,21 @@ def configure_redis() -> None: script_dir = Path(__file__).parent repo_root = script_dir.parent - redis_conf = Path("/etc/redis/redis.conf") - redis_conf_backup = Path("/etc/redis/redis.conf.original") + redis_conf = "/etc/redis/redis.conf" + redis_conf_backup = "/etc/redis/redis.conf.original" # Backup original redis.conf if it exists and hasn't been backed up yet - if redis_conf.exists() and not redis_conf_backup.exists(): + # Use test -f to check file existence with sudo permissions + backup_check = subprocess.run( + ["sudo", "test", "-f", redis_conf_backup], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if backup_check.returncode != 0: # Backup doesn't exist subprocess.run( - ["sudo", "cp", str(redis_conf), str(redis_conf_backup)], - check=True, + ["sudo", "cp", "-n", redis_conf, redis_conf_backup], + check=False, # Don't fail if source doesn't exist stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -368,14 +375,14 @@ def configure_redis() -> None: # Copy our optimized Redis configuration subprocess.run( - ["sudo", "cp", str(repo_root / "config_templates" / "redis.conf"), str(redis_conf)], + ["sudo", "cp", str(repo_root / "config_templates" / "redis.conf"), redis_conf], check=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) subprocess.run( - ["sudo", "chown", "redis:redis", str(redis_conf)], + ["sudo", "chown", "redis:redis", redis_conf], check=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, From a579034fe6a6cb143d0fd75d43ee2becb27bcc58 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Thu, 23 Oct 2025 23:48:56 -0400 Subject: [PATCH 04/48] fix: Correct SPI detection logic to check all lines properly The previous logic incorrectly checked if the entire file content started with '#dtparam=spi=on', which would never be true. Changed to properly check if any line in the file equals 'dtparam=spi=on' (uncommented). --- install/setup_app.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/install/setup_app.py b/install/setup_app.py index 785f455a..bea835dd 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -227,8 +227,11 @@ def enable_spi_interface() -> bool: try: content = boot_config.read_text() - # Check if SPI is already enabled - if "dtparam=spi=on" in content and not content.startswith("#dtparam=spi=on"): + # Check if SPI is already enabled (uncommented dtparam=spi=on exists) + lines = content.split("\n") + spi_enabled = any(line.strip() == "dtparam=spi=on" for line in lines) + + if spi_enabled: return False # Already enabled, no reboot needed # Enable SPI by uncommenting the line From f7fb0f8bda1b6cb254add9e12de13c654bc212df Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Fri, 24 Oct 2025 20:49:58 -0400 Subject: [PATCH 05/48] feat: Auto-detect device specs for Redis memory limits - Add detect_device_specs() to detect RAM and device type - Create redis.conf.j2 Jinja2 template for dynamic configuration - Update configure_redis() to render template based on device: - 512MB (Pi Zero 2W): 32MB Redis limit - 1GB (Pi 3B): 64MB Redis limit - 2GB (Pi 4B 2GB): 128MB Redis limit - 4GB+ (Pi 4B/5): 256MB Redis limit - Display detected device info during installation --- config_templates/redis.conf.j2 | 56 ++++++++++++++++ install/setup_app.py | 119 ++++++++++++++++++++++++++++----- 2 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 config_templates/redis.conf.j2 diff --git a/config_templates/redis.conf.j2 b/config_templates/redis.conf.j2 new file mode 100644 index 00000000..426add07 --- /dev/null +++ b/config_templates/redis.conf.j2 @@ -0,0 +1,56 @@ +# Redis configuration for BirdNET-Pi +# Memory-only mode - no persistence to protect SD card +# Generated for: {{ device_type }} ({{ total_ram_mb }}MB RAM) + +# Network settings +bind 127.0.0.1 ::1 +protected-mode yes +port 6379 +tcp-backlog 511 +tcp-keepalive 300 +timeout 0 + +# Memory management +# {{ memory_comment }} +maxmemory {{ maxmemory }} +maxmemory-policy allkeys-lru + +# Persistence - DISABLED for memory-only mode +save "" +stop-writes-on-bgsave-error no +rdbcompression no +rdbchecksum no +appendonly no + +# Logging +loglevel notice +logfile "" +syslog-enabled no +databases 1 + +# Performance tuning for small devices +slowlog-log-slower-than 10000 +slowlog-max-len 128 +latency-monitor-threshold 0 +notify-keyspace-events "" +hash-max-ziplist-entries 512 +hash-max-ziplist-value 64 +list-max-ziplist-size -2 +list-compress-depth 0 +set-max-intset-entries 512 +zset-max-ziplist-entries 128 +zset-max-ziplist-value 64 +hll-sparse-max-bytes 3000 +stream-node-max-bytes 4096 +stream-node-max-entries 100 +activerehashing yes +client-output-buffer-limit normal 0 0 0 +client-output-buffer-limit replica 256mb 64mb 60 +client-output-buffer-limit pubsub 32mb 8mb 60 +hz 10 +dynamic-hz yes + +# Disable dangerous commands in production +rename-command FLUSHDB "" +rename-command FLUSHALL "" +rename-command CONFIG "CONFIG_birdnetpi_2024" diff --git a/install/setup_app.py b/install/setup_app.py index bea835dd..6929189f 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -10,6 +10,8 @@ from datetime import datetime from pathlib import Path +from jinja2 import Template + # Thread-safe logging _log_lock = threading.Lock() @@ -69,6 +71,68 @@ def get_ip_address() -> str: return "unknown" +def detect_device_specs() -> dict[str, str | int]: + """Detect device type and memory specifications. + + Returns: + dict: Device specifications including: + - device_type: Detected device name or 'Unknown' + - total_ram_mb: Total RAM in MB + - maxmemory: Redis memory limit (e.g., '32mb', '64mb', '128mb') + - memory_comment: Explanation for the memory limit + """ + # Get total RAM + try: + meminfo = Path("/proc/meminfo").read_text() + for line in meminfo.split("\n"): + if line.startswith("MemTotal:"): + total_kb = int(line.split()[1]) + total_mb = total_kb // 1024 + break + else: + total_mb = 512 # Default fallback + except Exception: + total_mb = 512 # Default fallback + + # Detect device type + device_type = "Unknown" + try: + model_info = Path("/proc/device-tree/model").read_text().strip("\x00") + device_type = model_info + except Exception: + pass + + # Determine Redis memory limits based on total RAM + # Leave sufficient room for: + # - System (kernel, system services): ~100-150MB + # - Python daemons (audio/analysis/web): ~150-200MB + # - Buffer for peaks and filesystem cache: ~100MB + if total_mb <= 512: + # Pi Zero 2W or similar: 512MB total + # Very tight - minimal Redis, consider display-only mode + maxmemory = "32mb" + memory_comment = "Minimal limit for 512MB devices (display-only recommended)" + elif total_mb <= 1024: + # Pi 3B or similar: 1GB total + maxmemory = "64mb" + memory_comment = "Conservative limit for 1GB devices" + elif total_mb <= 2048: + # Pi 4B 2GB + maxmemory = "128mb" + memory_comment = "Moderate limit for 2GB devices" + else: + # Pi 4B 4GB+ or Pi 5 + maxmemory = "256mb" + memory_comment = "Standard limit for 4GB+ devices" + + return { + "device_type": device_type, + "total_ram_mb": total_mb, + "maxmemory": maxmemory, + "memory_comment": memory_comment, + } + + def install_system_packages() -> None: """Install system-level package dependencies.""" dependencies = [ @@ -352,7 +416,7 @@ def install_assets() -> None: def configure_redis() -> None: - """Configure Redis with memory limits optimized for small devices.""" + """Configure Redis with memory limits optimized for device specs.""" script_dir = Path(__file__).parent repo_root = script_dir.parent @@ -376,21 +440,46 @@ def configure_redis() -> None: stderr=subprocess.DEVNULL, ) - # Copy our optimized Redis configuration - subprocess.run( - ["sudo", "cp", str(repo_root / "config_templates" / "redis.conf"), redis_conf], - check=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - subprocess.run( - ["sudo", "chown", "redis:redis", redis_conf], - check=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + # Detect device specifications + device_specs = detect_device_specs() + log( + "ℹ", # noqa: RUF001 + f"Detected: {device_specs['device_type']} ({device_specs['total_ram_mb']}MB RAM)", ) + log("ℹ", f"Redis memory limit: {device_specs['maxmemory']}") # noqa: RUF001 + + # Render Redis configuration from template + template_path = repo_root / "config_templates" / "redis.conf.j2" + template_content = template_path.read_text() + template = Template(template_content) + rendered_config = template.render(**device_specs) + + # Write rendered configuration to temporary file + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".conf") as tmp: + tmp.write(rendered_config) + tmp_path = tmp.name + + try: + # Copy rendered config to system location + subprocess.run( + ["sudo", "cp", tmp_path, redis_conf], + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["sudo", "chown", "redis:redis", redis_conf], + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + finally: + # Clean up temporary file + Path(tmp_path).unlink(missing_ok=True) def configure_caddy() -> None: From db3fbcef03530a12bfe9e8acf483dbf96f0078dc Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Fri, 24 Oct 2025 21:05:40 -0400 Subject: [PATCH 06/48] feat: Disable unnecessary services on low-memory devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DeviceSpecs TypedDict for type-safe device detection - Add disable_unnecessary_services() to free ~12MB RAM on 512MB devices - Disable ModemManager, Bluetooth, triggerhappy, avahi-daemon on Pi Zero 2W - Integrate into Wave 4.5 of installation process - Services only disabled if device has ≤512MB RAM --- install/setup_app.py | 78 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/install/setup_app.py b/install/setup_app.py index 6929189f..8ce0c951 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -9,9 +9,20 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from pathlib import Path +from typing import TypedDict from jinja2 import Template + +class DeviceSpecs(TypedDict): + """Device specifications returned by detect_device_specs().""" + + device_type: str + total_ram_mb: int + maxmemory: str + memory_comment: str + + # Thread-safe logging _log_lock = threading.Lock() @@ -71,15 +82,15 @@ def get_ip_address() -> str: return "unknown" -def detect_device_specs() -> dict[str, str | int]: +def detect_device_specs() -> DeviceSpecs: """Detect device type and memory specifications. Returns: - dict: Device specifications including: - - device_type: Detected device name or 'Unknown' - - total_ram_mb: Total RAM in MB - - maxmemory: Redis memory limit (e.g., '32mb', '64mb', '128mb') - - memory_comment: Explanation for the memory limit + DeviceSpecs: Device specifications including: + - device_type: Detected device name or 'Unknown' (str) + - total_ram_mb: Total RAM in MB (int) + - maxmemory: Redis memory limit (e.g., '32mb', '64mb', '128mb') (str) + - memory_comment: Explanation for the memory limit (str) """ # Get total RAM try: @@ -533,6 +544,54 @@ def configure_caddy() -> None: ) +def disable_unnecessary_services(total_ram_mb: int) -> None: + """Disable unnecessary system services on low-memory devices. + + Args: + total_ram_mb: Total RAM in MB from device detection + """ + # Only disable services on very low-memory devices (512MB or less) + if total_ram_mb > 512: + return + + # Services safe to disable on headless Pi Zero 2W + # Saves ~12MB RAM total + services_to_disable = [ + "ModemManager", # ~3.3MB - cellular modem support not needed + "bluetooth", # ~1.9MB - Bluetooth not needed for BirdNET-Pi + "triggerhappy", # ~1.6MB - hotkey daemon not needed headless + "avahi-daemon", # ~2.8MB - mDNS/Bonjour nice-to-have but not essential + ] + + log( + "ℹ", # noqa: RUF001 + f"Low memory detected ({total_ram_mb}MB) - disabling unnecessary services", + ) + + for service in services_to_disable: + try: + # Check if service exists before trying to disable + result = subprocess.run( + ["systemctl", "is-enabled", service], + check=False, + capture_output=True, + text=True, + ) + # Only disable if service exists and is enabled + if result.returncode == 0: + subprocess.run( + ["sudo", "systemctl", "disable", "--now", service], + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + log("✓", f"Disabled {service}") + except Exception as e: + # Don't fail installation if we can't disable a service + log("⚠", f"Could not disable {service}: {e}") + + def install_systemd_services() -> None: """Install and enable systemd services without starting them.""" systemd_dir = "/etc/systemd/system/" @@ -845,6 +904,13 @@ def main() -> None: log("✓", "Completed: web/cache configuration, systemd services, asset download") # Wave 4.5: System configuration (sequential, before starting services) + print() + log("→", "Optimizing system for device") + # Disable unnecessary services on low-memory devices + device_specs = detect_device_specs() + disable_unnecessary_services(device_specs["total_ram_mb"]) + log("✓", "Optimizing system for device") + print() log("→", "Configuring system settings") setup_cmd = [ From cede465e904bf68aa1a973f36fb59b1e9ee9f826 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Fri, 24 Oct 2025 21:07:44 -0400 Subject: [PATCH 07/48] feat: Disable swap on low-memory devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add swap disabling to disable_unnecessary_services() - Run dphys-swapfile swapoff, uninstall, and disable service - Prevents SD card wear from swap thrashing - Only applies to devices with ≤512MB RAM (Pi Zero 2W) --- install/setup_app.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/install/setup_app.py b/install/setup_app.py index 8ce0c951..97e5887a 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -545,7 +545,7 @@ def configure_caddy() -> None: def disable_unnecessary_services(total_ram_mb: int) -> None: - """Disable unnecessary system services on low-memory devices. + """Disable unnecessary system services and swap on low-memory devices. Args: total_ram_mb: Total RAM in MB from device detection @@ -565,9 +565,36 @@ def disable_unnecessary_services(total_ram_mb: int) -> None: log( "ℹ", # noqa: RUF001 - f"Low memory detected ({total_ram_mb}MB) - disabling unnecessary services", + f"Low memory detected ({total_ram_mb}MB) - optimizing system", ) + # Disable swap to prevent SD card wear and thrashing + try: + subprocess.run( + ["sudo", "dphys-swapfile", "swapoff"], + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["sudo", "dphys-swapfile", "uninstall"], + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["sudo", "systemctl", "disable", "dphys-swapfile"], + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + log("✓", "Disabled swap (prevents SD card wear)") + except Exception as e: + log("⚠", f"Could not disable swap: {e}") + for service in services_to_disable: try: # Check if service exists before trying to disable From 152d3b1bac55c2834671e348ff6f27ed40c058ec Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Fri, 24 Oct 2025 21:20:17 -0400 Subject: [PATCH 08/48] refactor: Move uv and dependency installation to install.sh - Install uv in install.sh before running setup_app.py - Run uv sync in install.sh (makes Jinja2 available for Redis config) - Detect epaper HAT in install.sh and conditionally install extras - Remove install_uv() and install_python_dependencies() from setup_app.py - Conditionally install epaper display service based on hardware detection - Execute setup_app.py via 'uv run' so Jinja2 is available This allows setup_app.py to use Jinja2 for rendering the Redis config template with device-specific memory limits. --- install/install.sh | 28 +++++++- install/setup_app.py | 163 +++++-------------------------------------- 2 files changed, 42 insertions(+), 149 deletions(-) diff --git a/install/install.sh b/install/install.sh index 2d6b8746..e836e5f9 100644 --- a/install/install.sh +++ b/install/install.sh @@ -67,8 +67,30 @@ sudo chown birdnetpi:birdnetpi "$INSTALL_DIR" echo "Cloning repository..." sudo -u birdnetpi git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" -# Execute the main setup script +# Install uv package manager system-wide to /opt/uv +echo "Installing uv package manager..." +sudo mkdir -p /opt/uv +sudo curl -LsSf https://astral.sh/uv/install.sh | sudo INSTALLER_NO_MODIFY_PATH=1 UV_INSTALL_DIR=/opt/uv sh + +# Detect Waveshare e-paper HAT via SPI devices +EPAPER_EXTRAS="" +if ls /dev/spidev* &>/dev/null; then + echo "Waveshare e-paper HAT detected (SPI devices found)" + EPAPER_EXTRAS="--extra epaper" +else + echo "No e-paper HAT detected, skipping epaper extras" +fi + +# Install Python dependencies (makes Jinja2 available for setup_app.py) +echo "Installing Python dependencies..." +cd "$INSTALL_DIR" +UV_CMD="sudo -u birdnetpi /opt/uv/bin/uv sync --locked --no-dev --quiet" +if [ -n "$EPAPER_EXTRAS" ]; then + UV_CMD="$UV_CMD $EPAPER_EXTRAS" +fi +eval "$UV_CMD" + +# Execute the main setup script with uv run (dependencies now available) echo "" echo "Starting installation..." -cd "$INSTALL_DIR" -python3.11 "$INSTALL_DIR/install/setup_app.py" +/opt/uv/bin/uv run python "$INSTALL_DIR/install/setup_app.py" diff --git a/install/setup_app.py b/install/setup_app.py index 97e5887a..b8ce404c 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -212,79 +212,6 @@ def create_directories() -> None: ) -def install_uv() -> None: - """Install uv package manager using official installer. - - Uses the standalone installer which doesn't require pip. - Installs to /opt/uv for consistency across installations. - UV will automatically create and manage the virtual environment - when we run 'uv sync' later. - """ - import pwd - - # Get birdnetpi user's home directory - birdnetpi_home = pwd.getpwnam("birdnetpi").pw_dir - - # Create /opt/uv directory - subprocess.run( - ["sudo", "mkdir", "-p", "/opt/uv"], - check=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - subprocess.run( - ["sudo", "chown", "-R", "birdnetpi:birdnetpi", "/opt/uv"], - check=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - # Download and run the official uv installer - # The installer installs to $HOME/.local/bin by default - result = subprocess.run( - [ - "sudo", - "-u", - "birdnetpi", - "sh", - "-c", - "curl -LsSf https://astral.sh/uv/install.sh | sh", - ], - check=False, - stdin=subprocess.DEVNULL, - capture_output=True, - text=True, - ) - if result.returncode != 0: - raise RuntimeError(f"Failed to install uv: {result.stderr}") - - # Move uv binary to /opt/uv/bin for consistency - subprocess.run( - ["sudo", "mkdir", "-p", "/opt/uv/bin"], - check=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - uv_source = f"{birdnetpi_home}/.local/bin/uv" - subprocess.run( - ["sudo", "mv", uv_source, "/opt/uv/bin/uv"], - check=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - subprocess.run( - ["sudo", "chown", "-R", "birdnetpi:birdnetpi", "/opt/uv"], - check=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - def enable_spi_interface() -> bool: """Enable SPI interface in Raspberry Pi boot configuration. @@ -351,59 +278,6 @@ def has_waveshare_epaper_hat() -> bool: return False -def install_python_dependencies() -> None: - """Install Python dependencies with uv. - - UV will automatically create the virtual environment at .venv/ - during the sync operation. If a Waveshare e-paper HAT is detected, - the epaper extras will be installed automatically. - """ - # Build uv sync command - cmd = [ - "sudo", - "-u", - "birdnetpi", - "/opt/uv/bin/uv", - "sync", - "--locked", - "--no-dev", - "--quiet", - ] - - # Auto-detect and install e-paper dependencies if hardware is present - if has_waveshare_epaper_hat(): - log("ℹ", "Waveshare e-paper HAT detected (SPI devices found)") # noqa: RUF001 - cmd.extend(["--extra", "epaper"]) - else: - log("ℹ", "No e-paper HAT detected, skipping epaper extras") # noqa: RUF001 - - # Retry up to 3 times for network failures (GitHub access for waveshare-epd) - max_retries = 3 - for attempt in range(1, max_retries + 1): - result = subprocess.run( - cmd, - cwd="/opt/birdnetpi", - check=False, - stdin=subprocess.DEVNULL, - capture_output=True, - text=True, - ) - if result.returncode == 0: - break - - # Check if it's a network error - if "Could not resolve host" in result.stderr or "failed to fetch" in result.stderr: - if attempt < max_retries: - log("⚠", f"Network error, retrying ({attempt}/{max_retries})...") - import time - - time.sleep(5) # Wait 5 seconds before retry - continue - - # Non-network error or final retry failed - raise RuntimeError(f"Failed to install Python dependencies: {result.stderr}") - - def install_assets() -> None: """Download and install BirdNET assets.""" install_assets_path = "/opt/birdnetpi/.venv/bin/install-assets" @@ -676,15 +550,23 @@ def install_systemd_services() -> None: "exec_start": "/opt/birdnetpi/.venv/bin/update-daemon --mode both", "environment": "PYTHONPATH=/opt/birdnetpi/src SERVICE_NAME=update_daemon", }, - { - "name": "birdnetpi-epaper-display.service", - "description": "BirdNET E-Paper Display", - "after": "network-online.target birdnetpi-fastapi.service", - "exec_start": "/opt/birdnetpi/.venv/bin/epaper-display-daemon", - "environment": "PYTHONPATH=/opt/birdnetpi/src SERVICE_NAME=epaper_display", - }, ] + # Conditionally add epaper display service if hardware detected + if has_waveshare_epaper_hat(): + log("ℹ", "Installing epaper display service (hardware detected)") # noqa: RUF001 + services.append( + { + "name": "birdnetpi-epaper-display.service", + "description": "BirdNET E-Paper Display", + "after": "network-online.target birdnetpi-fastapi.service", + "exec_start": "/opt/birdnetpi/.venv/bin/epaper-display-daemon", + "environment": "PYTHONPATH=/opt/birdnetpi/src SERVICE_NAME=epaper_display", + } + ) + else: + log("ℹ", "Skipping epaper display service (no hardware detected)") # noqa: RUF001 + for service_config in services: service_name = service_config["name"] service_file_path = os.path.join(systemd_dir, service_name) @@ -902,19 +784,8 @@ def main() -> None: ) log("✓", "Completed: data directories, system packages") - # Wave 2: Install uv (sequential, needs network after apt operations complete) - print() - log("→", "Installing uv package manager") - install_uv() - log("✓", "Installing uv package manager") - - # Wave 3: Python dependencies (sequential, needs uv) - print() - log("→", "Installing Python dependencies") - install_python_dependencies() - log("✓", "Installing Python dependencies") - - # Wave 4: Configuration and services (parallel, long-running tasks at bottom) + # Wave 2: Configuration and services (parallel, long-running tasks at bottom) + # Note: uv and Python dependencies already installed by install.sh print() log("→", "Starting: web/cache configuration, systemd services, asset download") run_parallel( From ea9749b97b252c9ce434d1b39d44e2459dc5897d Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Fri, 24 Oct 2025 22:26:01 -0400 Subject: [PATCH 09/48] feat: Enable SPI early in install.sh with immediate reboot - Check and enable SPI interface at start of install.sh - Reboot immediately after enabling SPI (before uv installation) - Detect epaper HAT after reboot when SPI devices exist - Fix uv paths: /opt/uv/uv not /opt/uv/bin/uv - Remove enable_spi_interface() from setup_app.py (now in install.sh) - Prompt user to re-run installer after SPI reboot Flow: 1. install.sh checks SPI -> enables if needed -> reboots 2. User re-runs install.sh 3. SPI now enabled -> /dev/spidev* exists 4. epaper HAT detected -> extras installed 5. Continue with normal installation --- install/install.sh | 36 +++++++++++++++++++-- install/setup_app.py | 76 ++------------------------------------------ 2 files changed, 36 insertions(+), 76 deletions(-) diff --git a/install/install.sh b/install/install.sh index e836e5f9..de3c9d81 100644 --- a/install/install.sh +++ b/install/install.sh @@ -29,6 +29,38 @@ echo "" echo "Data will install to: /var/lib/birdnetpi" 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" + else + echo "dtparam=spi=on" | sudo tee -a "$BOOT_CONFIG" > /dev/null + fi + echo "" + echo "========================================" + echo "SPI interface enabled!" + echo "System must reboot for changes to take effect." + echo "" + echo "After reboot, re-run this installer:" + echo " curl -fsSL | bash" + echo "or" + echo " bash install.sh" + echo "========================================" + echo "" + read -r -p "Press Enter to reboot now, or Ctrl+C to cancel..." + sudo reboot + exit 0 + fi +fi + # Bootstrap the environment echo "Installing prerequisites..." sudo apt-get update @@ -84,7 +116,7 @@ fi # Install Python dependencies (makes Jinja2 available for setup_app.py) echo "Installing Python dependencies..." cd "$INSTALL_DIR" -UV_CMD="sudo -u birdnetpi /opt/uv/bin/uv sync --locked --no-dev --quiet" +UV_CMD="sudo -u birdnetpi /opt/uv/uv sync --locked --no-dev --quiet" if [ -n "$EPAPER_EXTRAS" ]; then UV_CMD="$UV_CMD $EPAPER_EXTRAS" fi @@ -93,4 +125,4 @@ eval "$UV_CMD" # Execute the main setup script with uv run (dependencies now available) echo "" echo "Starting installation..." -/opt/uv/bin/uv run python "$INSTALL_DIR/install/setup_app.py" +/opt/uv/uv run python "$INSTALL_DIR/install/setup_app.py" diff --git a/install/setup_app.py b/install/setup_app.py index b8ce404c..f07275e8 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -212,55 +212,6 @@ def create_directories() -> None: ) -def enable_spi_interface() -> bool: - """Enable SPI interface in Raspberry Pi boot configuration. - - Waveshare e-paper HATs require SPI to be enabled. This function checks if - SPI is enabled and enables it if needed. - - Returns: - bool: True if a reboot is required to apply changes - """ - boot_config = Path("/boot/firmware/config.txt") - if not boot_config.exists(): - # Not a Raspberry Pi or config not found - return False - - try: - content = boot_config.read_text() - - # Check if SPI is already enabled (uncommented dtparam=spi=on exists) - lines = content.split("\n") - spi_enabled = any(line.strip() == "dtparam=spi=on" for line in lines) - - if spi_enabled: - return False # Already enabled, no reboot needed - - # Enable SPI by uncommenting the line - updated_content = content.replace("#dtparam=spi=on", "dtparam=spi=on") - - # If the line doesn't exist at all, add it - if "dtparam=spi=on" not in updated_content: - updated_content += "\n# Enable SPI for e-paper HAT\ndtparam=spi=on\n" - - # Write the updated config - subprocess.run( - ["sudo", "tee", str(boot_config)], - input=updated_content, - text=True, - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - log("✓", "SPI interface enabled in boot config (reboot required)") - return True # Reboot required - - except Exception as e: - log("✗", f"Failed to enable SPI: {e}") - return False - - def has_waveshare_epaper_hat() -> bool: """Detect if a Waveshare e-paper HAT is connected. @@ -746,31 +697,8 @@ def main() -> None: print("=" * 60) print() - # Check and enable SPI if needed (must be before any hardware detection) - print() - log("→", "Checking SPI interface for e-paper HAT support") - needs_reboot = enable_spi_interface() - if needs_reboot: - print() - print("=" * 60) - print("REBOOT REQUIRED") - print("=" * 60) - print() - print("SPI interface has been enabled for e-paper HAT support.") - print("The system needs to reboot to apply this change.") - print() - print("After reboot, please run the installer again:") - print(" cd /opt/birdnetpi && sudo -u birdnetpi .venv/bin/python install/setup_app.py") - print() - print("=" * 60) - print() - response = input("Reboot now? (y/n): ").strip().lower() - if response == "y": - subprocess.run(["sudo", "reboot"], check=True) - else: - print("Please reboot manually and re-run the installer.") - sys.exit(0) - log("✓", "SPI interface check complete") + # Note: SPI enablement is now handled by install.sh before this script runs + # Hardware detection (epaper HAT) will work correctly if SPI was enabled try: # Wave 1: System setup (parallel - apt-update already done in install.sh) From 828b961470a58bf1bfa24a4de802b4f78b043b0b Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Fri, 24 Oct 2025 23:05:14 -0400 Subject: [PATCH 10/48] feat: Add profile support, Le Potato support, SPI option, and network retry - Add profile management to flasher (list, select, edit, duplicate) - Fix bug where edited profiles always saved as 'default' - Add LibreComputer Le Potato (AML-S905X-CC) device support - Clone portability script to boot partition - Create helper script (lepotato_setup.sh) with correct model number - Add two-step boot instructions in README - Add 'Enable SPI (for ePaper HAT)?' option to flasher - Uncomments dtparam=spi=on in config.txt - No reboot needed on first boot - Add retry mechanism with exponential backoff to install.sh - Handles transient network/DNS failures - 3 retries with 5s/10s/20s delays --- install/flash_sdcard.py | 451 +++++++++++++++++++++++++++++++++++----- install/install.sh | 27 ++- 2 files changed, 421 insertions(+), 57 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index ee3218c0..c384829a 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -117,29 +117,139 @@ def find_command(cmd: str, homebrew_paths: list[str] | None = None) -> str: "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", }, + "Le Potato": { + # LibreComputer AML-S905X-CC - uses same arm64 image as Pi, requires portability script + "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", + "requires_portability": True, + }, } CONFIG_DIR = Path.home() / ".config" / "birdnetpi" -CONFIG_FILE = CONFIG_DIR / "image_options.json" +PROFILES_DIR = CONFIG_DIR / "profiles" + +def list_profiles() -> list[dict[str, Any]]: + """List all saved profiles with metadata. -def load_saved_config() -> dict[str, Any] | None: - """Load saved configuration from ~/.config/birdnetpi/image_options.json.""" - if CONFIG_FILE.exists(): + Returns: + List of profile dicts with 'name', 'path', and 'config' keys + """ + if not PROFILES_DIR.exists(): + return [] + + profiles = [] + for profile_file in sorted(PROFILES_DIR.glob("*.json")): try: - with open(CONFIG_FILE) as f: + with open(profile_file) as f: + config = json.load(f) + profiles.append( + { + "name": profile_file.stem, + "path": profile_file, + "config": config, + } + ) + except Exception as e: + console.print( + f"[yellow]Warning: Could not load profile {profile_file.name}: {e}[/yellow]" + ) + + return profiles + + +def load_profile(profile_name: str) -> dict[str, Any] | None: + """Load a specific profile by name. + + Args: + profile_name: Name of the profile to load + + Returns: + Profile configuration dict or None if not found + """ + profile_path = PROFILES_DIR / f"{profile_name}.json" + if profile_path.exists(): + try: + with open(profile_path) as f: return json.load(f) except Exception as e: - console.print(f"[yellow]Warning: Could not load saved config: {e}[/yellow]") + console.print(f"[yellow]Warning: Could not load profile {profile_name}: {e}[/yellow]") return None -def save_config(config: dict[str, Any]) -> None: - """Save configuration to ~/.config/birdnetpi/image_options.json.""" - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - with open(CONFIG_FILE, "w") as f: +def save_profile(profile_name: str, config: dict[str, Any]) -> None: + """Save configuration as a named profile. + + Args: + profile_name: Name for the profile + config: Configuration dict to save + """ + PROFILES_DIR.mkdir(parents=True, exist_ok=True) + profile_path = PROFILES_DIR / f"{profile_name}.json" + with open(profile_path, "w") as f: json.dump(config, f, indent=2) - console.print(f"[green]Configuration saved to {CONFIG_FILE}[/green]") + console.print(f"[green]Profile '{profile_name}' saved to {profile_path}[/green]") + + +def select_profile() -> tuple[dict[str, Any] | None, str | None, bool]: + """Display available profiles and let user select one (supports 0-9). + + Returns: + Tuple of (selected profile config or None, profile name or None, should_edit flag) + """ + profiles = list_profiles() + + if not profiles: + return None, None, False + + # Limit to first 10 profiles (0-9) + profiles = profiles[:10] + + console.print() + console.print("[bold cyan]Saved Profiles:[/bold cyan]") + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Key", style="dim") + table.add_column("Profile Name", style="green") + table.add_column("Hostname", justify="left") + table.add_column("WiFi SSID", justify="left") + + for idx, profile in enumerate(profiles): + config = profile["config"] + hostname = config.get("hostname", "N/A") + wifi_ssid = config.get("wifi_ssid", "Not configured") + table.add_row(str(idx), profile["name"], hostname, wifi_ssid) + + console.print(table) + console.print() + + choices = [str(i) for i in range(len(profiles))] + ["n"] + choice = Prompt.ask( + "[bold]Select profile (0-9) or 'n' for new configuration[/bold]", + choices=choices, + default="n", + ) + + if choice == "n": + return None, None, False + + selected_profile = profiles[int(choice)] + profile_name = selected_profile["name"] + console.print(f"[green]Selected profile: {profile_name}[/green]") + + # Ask if user wants to use as-is or edit + action = Prompt.ask( + "[bold]Use profile as-is or edit/duplicate?[/bold]", + choices=["use", "edit"], + default="use", + ) + + should_edit = action == "edit" + if should_edit: + console.print( + "[cyan]You can now edit the configuration (press Enter to keep existing values)[/cyan]" + ) + + return selected_profile["config"], profile_name, should_edit def parse_size_to_gb(size_str: str) -> float | None: @@ -278,12 +388,13 @@ def select_device() -> str: def select_pi_version() -> str: - """Prompt user to select Raspberry Pi version.""" + """Prompt user to select device model.""" console.print() - console.print("[bold cyan]Select Raspberry Pi Version:[/bold cyan]") + 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 = { @@ -291,18 +402,26 @@ def select_pi_version() -> str: "4": "Pi 4", "3": "Pi 3", "0": "Pi Zero 2 W", + "L": "Le Potato", } - # Display in ascending order (0, 3, 4, 5) - for version in ["0", "3", "4", "5"]: - model = version_map[version] - table.add_row(version, model) + # Display in order (0, 3, 4, 5, L) + display_order = [ + ("0", "Pi Zero 2 W", ""), + ("3", "Pi 3", ""), + ("4", "Pi 4", ""), + ("5", "Pi 5", ""), + ("L", "Le Potato", "AML-S905X-CC"), + ] + + for version, model, notes in display_order: + table.add_row(version, model, notes) console.print(table) console.print() choice = Prompt.ask( - "[bold]Select Raspberry Pi model[/bold]", + "[bold]Select device model[/bold]", choices=list(version_map.keys()), ) @@ -345,8 +464,16 @@ def download_image(pi_version: str, download_dir: Path) -> Path: return filepath -def get_config_from_prompts(saved_config: dict[str, Any] | None) -> dict[str, Any]: # noqa: C901 - """Prompt user for configuration options.""" +def get_config_from_prompts( # noqa: C901 + saved_config: dict[str, Any] | None, + edit_mode: bool = False, +) -> dict[str, Any]: + """Prompt user for configuration options. + + Args: + saved_config: Previously saved configuration to use as defaults + edit_mode: If True, show prompts with defaults; if False, auto-use saved values + """ config: dict[str, Any] = {} console.print() @@ -354,64 +481,88 @@ def get_config_from_prompts(saved_config: dict[str, Any] | None) -> dict[str, An console.print() # WiFi settings - if saved_config and "enable_wifi" in saved_config: + if saved_config and "enable_wifi" in saved_config and not edit_mode: config["enable_wifi"] = saved_config["enable_wifi"] console.print(f"[dim]Using saved WiFi enabled: {config['enable_wifi']}[/dim]") else: - config["enable_wifi"] = Confirm.ask("Enable WiFi?", default=False) + default_wifi = saved_config.get("enable_wifi", False) if saved_config else False + config["enable_wifi"] = Confirm.ask("Enable WiFi?", default=default_wifi) if config["enable_wifi"]: - if saved_config and "wifi_ssid" in saved_config: + if saved_config and "wifi_ssid" in saved_config and not edit_mode: config["wifi_ssid"] = saved_config["wifi_ssid"] console.print(f"[dim]Using saved WiFi SSID: {config['wifi_ssid']}[/dim]") else: - config["wifi_ssid"] = Prompt.ask("WiFi SSID") + default_ssid = saved_config.get("wifi_ssid", "") if saved_config else "" + config["wifi_ssid"] = Prompt.ask("WiFi SSID", default=default_ssid or "") - if saved_config and "wifi_auth" in saved_config: + if saved_config and "wifi_auth" in saved_config and not edit_mode: config["wifi_auth"] = saved_config["wifi_auth"] console.print(f"[dim]Using saved WiFi Auth: {config['wifi_auth']}[/dim]") else: + default_auth = saved_config.get("wifi_auth", "WPA2") if saved_config else "WPA2" config["wifi_auth"] = Prompt.ask( - "WiFi Auth Type", choices=["WPA", "WPA2", "WPA3"], default="WPA2" + "WiFi Auth Type", choices=["WPA", "WPA2", "WPA3"], default=default_auth ) - if saved_config and "wifi_password" in saved_config: + if saved_config and "wifi_password" in saved_config and not edit_mode: config["wifi_password"] = saved_config["wifi_password"] console.print("[dim]Using saved WiFi password[/dim]") else: - config["wifi_password"] = Prompt.ask("WiFi Password", password=True) + default_pass = saved_config.get("wifi_password", "") if saved_config else "" + config["wifi_password"] = Prompt.ask( + "WiFi Password", password=True, default=default_pass + ) # User settings - if saved_config and "admin_user" in saved_config: + if saved_config and "admin_user" in saved_config and not edit_mode: config["admin_user"] = saved_config["admin_user"] console.print(f"[dim]Using saved admin user: {config['admin_user']}[/dim]") else: - config["admin_user"] = Prompt.ask("Device Admin", default="birdnetpi") + default_user = saved_config.get("admin_user", "birdnetpi") if saved_config else "birdnetpi" + config["admin_user"] = Prompt.ask("Device Admin", default=default_user) - if saved_config and "admin_password" in saved_config: + if saved_config and "admin_password" in saved_config and not edit_mode: config["admin_password"] = saved_config["admin_password"] console.print("[dim]Using saved admin password[/dim]") else: - config["admin_password"] = Prompt.ask("Device Password", password=True) + default_pass = saved_config.get("admin_password", "") if saved_config else "" + config["admin_password"] = Prompt.ask( + "Device Password", password=True, default=default_pass + ) - if saved_config and "hostname" in saved_config: + if saved_config and "hostname" in saved_config and not edit_mode: config["hostname"] = saved_config["hostname"] console.print(f"[dim]Using saved hostname: {config['hostname']}[/dim]") else: - config["hostname"] = Prompt.ask("Device Hostname", default="birdnetpi") + default_hostname = ( + saved_config.get("hostname", "birdnetpi") if saved_config else "birdnetpi" + ) + config["hostname"] = Prompt.ask("Device Hostname", default=default_hostname) # Advanced settings - if saved_config and "gpio_debug" in saved_config: + if saved_config and "gpio_debug" in saved_config and not edit_mode: config["gpio_debug"] = saved_config["gpio_debug"] console.print(f"[dim]Using saved GPIO debug: {config['gpio_debug']}[/dim]") else: - config["gpio_debug"] = Confirm.ask("Enable GPIO Debugging (Advanced)?", default=False) + default_gpio = saved_config.get("gpio_debug", False) if saved_config else False + config["gpio_debug"] = Confirm.ask( + "Enable GPIO Debugging (Advanced)?", default=default_gpio + ) - if saved_config and "copy_installer" in saved_config: + if saved_config and "copy_installer" in saved_config and not edit_mode: config["copy_installer"] = saved_config["copy_installer"] console.print(f"[dim]Using saved copy installer: {config['copy_installer']}[/dim]") else: - config["copy_installer"] = Confirm.ask("Copy install.sh?", default=True) + default_copy = saved_config.get("copy_installer", True) if saved_config else True + config["copy_installer"] = Confirm.ask("Copy install.sh?", default=default_copy) + + if saved_config and "enable_spi" in saved_config and not edit_mode: + config["enable_spi"] = saved_config["enable_spi"] + console.print(f"[dim]Using saved SPI enabled: {config['enable_spi']}[/dim]") + else: + default_spi = saved_config.get("enable_spi", False) if saved_config else False + config["enable_spi"] = Confirm.ask("Enable SPI (for ePaper HAT)?", default=default_spi) # BirdNET-Pi pre-configuration (optional) console.print() @@ -465,7 +616,7 @@ def get_config_from_prompts(saved_config: dict[str, Any] | None) -> dict[str, An # Check for saved value (must not be None or empty string) saved_value = saved_config.get(key, unset) if saved_config else unset - if saved_value is not unset and saved_value not in (None, ""): + if saved_value is not unset and saved_value not in (None, "") and not edit_mode: config[key] = saved_value console.print( f"[dim]Using saved {prompt_config['prompt'].lower()}: {saved_value}[/dim]" @@ -477,8 +628,17 @@ def get_config_from_prompts(saved_config: dict[str, Any] | None) -> dict[str, An for line in prompt_config["help"]: console.print(f"[dim]{line}[/dim]") + # Get default value for edit mode + default_value = "" + if edit_mode and saved_value is not unset and saved_value not in (None, ""): + default_value = str(saved_value) + # Prompt user - user_input = Prompt.ask(prompt_config["prompt"], default="", show_default=False) + user_input = Prompt.ask( + prompt_config["prompt"], + default=default_value, + show_default=bool(default_value), + ) config[key] = user_input if user_input else None return config @@ -561,7 +721,11 @@ def flash_image(image_path: Path, device: str) -> None: console.print(f"[green]✓ Image flashed successfully in {duration_str}[/green]") -def configure_boot_partition(device: str, config: dict[str, Any]) -> None: # noqa: C901 +def configure_boot_partition( # noqa: C901 + device: str, + config: dict[str, Any], + pi_version: str, +) -> None: """Configure the bootfs partition with user settings.""" console.print() console.print("[cyan]Configuring boot partition...[/cyan]") @@ -742,6 +906,35 @@ def configure_boot_partition(device: str, config: dict[str, Any]) -> None: # no ) console.print("[green]✓ GPIO debugging enabled[/green]") + # Enable SPI for ePaper HAT + if config.get("enable_spi"): + # Uncomment dtparam=spi=on in config.txt (or add if missing) + config_txt_path = boot_mount / "config.txt" + result = subprocess.run( + ["sudo", "cat", str(config_txt_path)], + capture_output=True, + text=True, + check=True, + ) + config_content = result.stdout + + # Check if line exists (commented or uncommented) + if "dtparam=spi=on" in config_content: + # Uncomment if commented + config_content = config_content.replace("#dtparam=spi=on", "dtparam=spi=on") + else: + # Add if missing + config_content += "\n# Enable SPI for ePaper HAT\ndtparam=spi=on\n" + + temp_config = Path("/tmp/birdnetpi_config_txt") + temp_config.write_text(config_content) + subprocess.run( + ["sudo", "cp", str(temp_config), str(config_txt_path)], + check=True, + ) + temp_config.unlink() + console.print("[green]✓ SPI enabled for ePaper HAT[/green]") + # Copy installer script if requested if config.get("copy_installer"): install_script = Path(__file__).parent / "install.sh" @@ -756,6 +949,134 @@ def configure_boot_partition(device: str, config: dict[str, Any]) -> None: # no "[yellow]Warning: install.sh not found, skipping installer copy[/yellow]" ) + # Copy LibreComputer portability script for Le Potato + if pi_version == "Le Potato": + console.print() + console.print("[cyan]Installing LibreComputer Raspbian Portability Script...[/cyan]") + + # Clone the portability repo to boot partition + lrp_dest = boot_mount / "lrp" + temp_clone = Path("/tmp/lrp_clone") + + # Remove any existing temp directory + if temp_clone.exists(): + subprocess.run(["rm", "-rf", str(temp_clone)], check=True) + + # Clone the repo + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "https://github.com/libre-computer-project/libretech-raspbian-portability.git", + str(temp_clone), + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + # Copy to boot partition + subprocess.run( + ["sudo", "cp", "-r", str(temp_clone), str(lrp_dest)], + check=True, + ) + + # Clean up temp directory + subprocess.run(["rm", "-rf", str(temp_clone)], check=True) + + # Create helper script that runs portability script with correct model + helper_script = """#!/bin/bash +# LibreComputer Le Potato Portability Helper Script +# This script automatically runs the portability script with the correct model number + +set -e + +echo "=========================================" +echo "LibreComputer Le Potato Portability Setup" +echo "=========================================" +echo "" +echo "This will convert this Raspbian SD card to boot on the Le Potato (AML-S905X-CC)." +echo "" +echo "WARNING: This will modify the bootloader and kernel on this SD card." +echo "After this process completes, the SD card will ONLY work on Le Potato," +echo "not on Raspberry Pi anymore." +echo "" +read -r -p "Press Enter to continue, or Ctrl+C to cancel..." +echo "" + +# Run the portability script with the Le Potato model number +sudo /boot/firmware/lrp/oneshot.sh aml-s905x-cc + +echo "" +echo "Conversion complete! System will shut down." +echo "After shutdown, move the SD card to your Le Potato and boot it." +""" + temp_helper = Path("/tmp/lepotato_setup.sh") + temp_helper.write_text(helper_script) + subprocess.run( + ["sudo", "cp", str(temp_helper), str(boot_mount / "lepotato_setup.sh")], + check=True, + ) + # Make executable + subprocess.run( + ["sudo", "chmod", "+x", str(boot_mount / "lepotato_setup.sh")], + check=True, + ) + temp_helper.unlink() + + # Create README for user + readme_content = """# LibreComputer Le Potato Setup Instructions + +This SD card contains the Raspbian Portability Script for Le Potato. + +## IMPORTANT: Two-Step Boot Process Required + +1. **First boot on a Raspberry Pi:** + - Insert this SD card into a Raspberry Pi (any model) + - Boot the Pi and log in with the credentials you configured + - Run the helper script: bash /boot/firmware/lepotato_setup.sh + - The Pi will shut down when complete + +2. **Move to Le Potato:** + - Remove the SD card from the Raspberry Pi + - Insert it into your Le Potato + - Power on the Le Potato - it will now boot successfully! + +3. **Install BirdNET-Pi:** + - SSH into the Le Potato + - Run: bash /boot/firmware/install.sh + +## Helper Script + +The lepotato_setup.sh script automatically runs the portability conversion +with the correct model number (aml-s905x-cc). You can also run the portability +script directly if needed: + + sudo /boot/firmware/lrp/oneshot.sh aml-s905x-cc + +## Why This Is Necessary + +The Le Potato (AML-S905X-CC) requires a modified bootloader and kernel to run +Raspbian. The portability script must run on a real Raspberry Pi to install +these components before the SD card will boot on the Le Potato. + +For more information, visit: +https://github.com/libre-computer-project/libretech-raspbian-portability +""" + temp_readme = Path("/tmp/birdnetpi_lepotato_readme.txt") + temp_readme.write_text(readme_content) + subprocess.run( + ["sudo", "cp", str(temp_readme), str(boot_mount / "LE_POTATO_README.txt")], + check=True, + ) + temp_readme.unlink() + + console.print("[green]✓ LibreComputer portability script installed[/green]") + console.print("[green]✓ Le Potato helper script: lepotato_setup.sh[/green]") + console.print("[green]✓ Setup instructions: LE_POTATO_README.txt[/green]") + # Create BirdNET-Pi pre-configuration file if any settings provided birdnet_config_lines = ["# BirdNET-Pi boot configuration"] has_birdnet_config = False @@ -815,10 +1136,11 @@ def main(save_config_flag: bool) -> None: ) ) - # Load saved configuration - saved_config = load_saved_config() - if saved_config: - console.print(f"[green]Found saved configuration at {CONFIG_FILE}[/green]") + # Try to select from saved profiles first + profile_config, profile_name, edit_mode = select_profile() + + # Store which profile was selected (None for new config) + saved_config = profile_config # Select device device = select_device() @@ -831,14 +1153,19 @@ def main(save_config_flag: bool) -> None: download_dir.mkdir(parents=True, exist_ok=True) image_path = download_image(pi_version, download_dir) - # Get configuration - config = get_config_from_prompts(saved_config) + # Get configuration (edit_mode shows prompts with defaults instead of auto-using saved values) + config = get_config_from_prompts(saved_config, edit_mode=edit_mode) - # Save configuration if requested - if save_config_flag or ( - not saved_config and Confirm.ask("Save this configuration for future use?") + # Save configuration as profile + # CRITICAL FIX: When editing, default to the original profile name, not "default" + if ( + save_config_flag + or edit_mode + or (not saved_config and Confirm.ask("Save this configuration as a profile?")) ): - save_config(config) + default_name = profile_name if profile_name else "default" + new_profile_name = Prompt.ask("Profile name", default=default_name) + save_profile(new_profile_name, config) # Flash image console.print() @@ -856,7 +1183,7 @@ def main(save_config_flag: bool) -> None: flash_image(image_path, device) # Configure boot partition - configure_boot_partition(device, config) + configure_boot_partition(device, config, pi_version) # Eject SD card console.print() @@ -871,7 +1198,7 @@ def main(save_config_flag: bool) -> None: # Build summary message summary_parts = [ "[bold green]✓ SD Card Ready![/bold green]\n", - f"Raspberry Pi Model: [yellow]{pi_version}[/yellow]", + f"Device Model: [yellow]{pi_version}[/yellow]", f"Hostname: [cyan]{config.get('hostname', 'birdnetpi')}[/cyan]", f"Admin User: [cyan]{config['admin_user']}[/cyan]", "SSH: [green]Enabled[/green]", @@ -883,8 +1210,22 @@ def main(save_config_flag: bool) -> None: else: summary_parts.append("WiFi: [yellow]Not configured (Ethernet required)[/yellow]") - # Add installer script status - if config.get("copy_installer"): + # Special instructions for Le Potato + if pi_version == "Le Potato": + summary_parts.append("Portability Script: [green]Installed[/green]\n") + summary_parts.append( + "[bold yellow]⚠ IMPORTANT: Two-Step Boot Process Required![/bold yellow]\n" + ) + summary_parts.append( + "[dim]1. Boot this SD card in a Raspberry Pi (any model)\n" + "2. Run: [cyan]bash /boot/firmware/lepotato_setup.sh[/cyan]\n" + "3. Wait for Pi to shut down\n" + "4. Move SD card to Le Potato and boot\n" + "5. SSH in and run: [cyan]bash /boot/firmware/install.sh[/cyan]\n\n" + "See [cyan]LE_POTATO_README.txt[/cyan] on boot partition for details.[/dim]" + ) + # Add installer script status for regular Pi + elif config.get("copy_installer"): summary_parts.append("Installer: [green]Copied to /boot/firmware/install.sh[/green]\n") summary_parts.append( "[dim]Insert the SD card into your Raspberry Pi and power it on.\n" diff --git a/install/install.sh b/install/install.sh index de3c9d81..020d693e 100644 --- a/install/install.sh +++ b/install/install.sh @@ -113,14 +113,37 @@ else echo "No e-paper HAT detected, skipping epaper extras" fi -# Install Python dependencies (makes Jinja2 available for setup_app.py) +# Install Python dependencies with retry mechanism (for network issues) echo "Installing Python dependencies..." cd "$INSTALL_DIR" UV_CMD="sudo -u birdnetpi /opt/uv/uv sync --locked --no-dev --quiet" if [ -n "$EPAPER_EXTRAS" ]; then UV_CMD="$UV_CMD $EPAPER_EXTRAS" fi -eval "$UV_CMD" + +MAX_RETRIES=3 +RETRY_COUNT=0 +RETRY_DELAY=5 + +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if eval "$UV_CMD"; then + echo "Python dependencies installed successfully" + break + else + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then + echo "Failed to install dependencies (attempt $RETRY_COUNT/$MAX_RETRIES)" + echo "Waiting $RETRY_DELAY seconds before retry..." + sleep $RETRY_DELAY + # Increase delay for next retry (exponential backoff) + RETRY_DELAY=$((RETRY_DELAY * 2)) + else + echo "ERROR: Failed to install Python dependencies after $MAX_RETRIES attempts" + echo "This usually indicates a network issue. Please check your internet connection and try again." + exit 1 + fi + fi +done # Execute the main setup script with uv run (dependencies now available) echo "" From 776026d1f00fa3790df3a76e07bc5e85a9b1cb94 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 26 Oct 2025 21:25:16 -0400 Subject: [PATCH 11/48] fix: Use venv python directly to avoid permission issues - Run setup_app.py with .venv/bin/python instead of uv run - Avoids permission errors when venv is owned by birdnetpi user - setup_app.py needs sudo for system operations, can't run as birdnetpi --- install/install.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/install/install.sh b/install/install.sh index 020d693e..bfc2a828 100644 --- a/install/install.sh +++ b/install/install.sh @@ -145,7 +145,9 @@ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do fi done -# Execute the main setup script with uv run (dependencies now available) +# Execute the main setup script using the venv directly +# We use the venv's python instead of uv run to avoid permission issues +# (uv sync ran as birdnetpi, but setup_app.py needs sudo for system operations) echo "" echo "Starting installation..." -/opt/uv/uv run python "$INSTALL_DIR/install/setup_app.py" +"$INSTALL_DIR/.venv/bin/python" "$INSTALL_DIR/install/setup_app.py" From 9fa164ccc0e5f4d4ae73962e894cda289e1472a5 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 26 Oct 2025 21:33:11 -0400 Subject: [PATCH 12/48] fix: Add network readiness check before dependency installation - Wait up to 60 seconds for network/DNS to be ready - Ping github.com to verify DNS resolution works - Prevents 'Could not resolve host' errors during early boot - Continues after timeout with warning if network still not ready --- install/install.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/install/install.sh b/install/install.sh index bfc2a828..fd132e52 100644 --- a/install/install.sh +++ b/install/install.sh @@ -113,6 +113,24 @@ else echo "No e-paper HAT detected, skipping epaper extras" fi +# Wait for network and DNS to be ready +echo "Checking network connectivity..." +MAX_NETWORK_WAIT=30 +NETWORK_WAIT=0 +while [ $NETWORK_WAIT -lt $MAX_NETWORK_WAIT ]; do + if ping -c 1 -W 2 github.com >/dev/null 2>&1; then + echo "Network is ready" + break + fi + NETWORK_WAIT=$((NETWORK_WAIT + 1)) + if [ $NETWORK_WAIT -lt $MAX_NETWORK_WAIT ]; then + echo "Waiting for network... ($NETWORK_WAIT/$MAX_NETWORK_WAIT)" + sleep 2 + else + echo "WARNING: Network check timed out, proceeding anyway..." + fi +done + # Install Python dependencies with retry mechanism (for network issues) echo "Installing Python dependencies..." cd "$INSTALL_DIR" From 508374229abc49158977c82a03a899fc2fa7f7a4 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 26 Oct 2025 21:37:24 -0400 Subject: [PATCH 13/48] fix: Test DNS with git ls-remote before dependency installation - Ping alone isn't enough - git uses different DNS mechanism - Test actual git ls-remote to waveshare repo before uv sync - Add 2 second stabilization delay after DNS ready - Should prevent git DNS failures that ping doesn't catch --- install/install.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/install/install.sh b/install/install.sh index fd132e52..bce65a9e 100644 --- a/install/install.sh +++ b/install/install.sh @@ -113,24 +113,29 @@ else echo "No e-paper HAT detected, skipping epaper extras" fi -# Wait for network and DNS to be ready +# Wait for network and DNS to be ready (git uses different DNS than ping) echo "Checking network connectivity..." MAX_NETWORK_WAIT=30 NETWORK_WAIT=0 while [ $NETWORK_WAIT -lt $MAX_NETWORK_WAIT ]; do - if ping -c 1 -W 2 github.com >/dev/null 2>&1; then - echo "Network is ready" + # Test with both ping and git ls-remote to ensure DNS works for both + if ping -c 1 -W 2 github.com >/dev/null 2>&1 && \ + git ls-remote --exit-code https://github.com/waveshareteam/e-Paper.git HEAD >/dev/null 2>&1; then + echo "Network and DNS ready (verified with git)" break fi NETWORK_WAIT=$((NETWORK_WAIT + 1)) if [ $NETWORK_WAIT -lt $MAX_NETWORK_WAIT ]; then - echo "Waiting for network... ($NETWORK_WAIT/$MAX_NETWORK_WAIT)" + echo "Waiting for network and DNS... ($NETWORK_WAIT/$MAX_NETWORK_WAIT)" sleep 2 else echo "WARNING: Network check timed out, proceeding anyway..." fi done +# Give DNS resolver a moment to stabilize +sleep 2 + # Install Python dependencies with retry mechanism (for network issues) echo "Installing Python dependencies..." cd "$INSTALL_DIR" From 48b5ba8280ace341165854f1b5121373bd34833a Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 26 Oct 2025 21:40:24 -0400 Subject: [PATCH 14/48] feat: Add retry message after wait delay - Show 'Retrying dependency installation...' after sleep - Prevents appearance of stalled installation - User sees clear feedback that retry is happening --- install/install.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/install/install.sh b/install/install.sh index bce65a9e..8e722e52 100644 --- a/install/install.sh +++ b/install/install.sh @@ -160,6 +160,7 @@ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do sleep $RETRY_DELAY # Increase delay for next retry (exponential backoff) RETRY_DELAY=$((RETRY_DELAY * 2)) + echo "Retrying dependency installation (attempt $((RETRY_COUNT + 1))/$MAX_RETRIES)..." else echo "ERROR: Failed to install Python dependencies after $MAX_RETRIES attempts" echo "This usually indicates a network issue. Please check your internet connection and try again." From cbd0e19f523663148a521d3d135a9922eeaa669a Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Mon, 27 Oct 2025 22:57:38 -0400 Subject: [PATCH 15/48] feat: Add ePaper HAT support and offline installation improvements Major enhancements for SD card flashing and first-boot installation: ## ePaper HAT Support - Add standalone test script (install/test_epaper.py) for hardware verification - Auto-detect ePaper display models (2.13", 2.9", 2.7", 4.2", 7.5") - Add SPI enablement option in flasher (uncommets dtparam=spi=on) - Download Waveshare library to boot partition during flashing - Patch pyproject.toml at boot to use local library (no network needed) ## LibreComputer Le Potato Support - Add Le Potato (AML-S905X-CC) to device selection - Clone portability scripts to boot partition - Patch oneshot.sh to support Raspbian 12 (Bookworm) - Create helper script for easy setup ## Installation Reliability - Add retry mechanism with exponential backoff for dependency installation - Implement offline Waveshare installation using boot partition cache - Use uv.sources pattern for dependency source management ## Cleanup - Remove unused install/ui_whiptail.py from old installer design Changes enable: - Offline ePaper HAT installation after SD flashing - Hardware testing without full installation (--test-epaper flag) - Support for non-Raspberry Pi ARM SBCs - More reliable first-boot installation with retry logic --- install/flash_sdcard.py | 49 +++++++ install/install.sh | 41 +++++- install/test_epaper.py | 204 ++++++++++++++++++++++++++ install/ui_whiptail.py | 314 ---------------------------------------- pyproject.toml | 5 +- 5 files changed, 296 insertions(+), 317 deletions(-) create mode 100755 install/test_epaper.py delete mode 100644 install/ui_whiptail.py diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index c384829a..d330c468 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -935,6 +935,38 @@ 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() + console.print("[cyan]Downloading Waveshare ePaper library...[/cyan]") + 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 with depth 1 for speed (only latest commit) + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "https://github.com/waveshareteam/e-Paper.git", + str(temp_waveshare), + ], + check=True, + capture_output=True, + ) + + # Copy to boot partition + subprocess.run( + ["sudo", "cp", "-r", str(temp_waveshare), str(waveshare_dest)], + check=True, + ) + shutil.rmtree(temp_waveshare) + console.print("[green]✓ Waveshare ePaper library downloaded to boot partition[/green]") + # Copy installer script if requested if config.get("copy_installer"): install_script = Path(__file__).parent / "install.sh" @@ -977,6 +1009,23 @@ def configure_boot_partition( # noqa: C901 stderr=subprocess.DEVNULL, ) + # Patch oneshot.sh to support Raspbian 12 (Bookworm) in addition to 11 (Bullseye) + oneshot_path = temp_clone / "oneshot.sh" + if oneshot_path.exists(): + oneshot_content = oneshot_path.read_text() + # Change the version check from "11" only to "11" or "12" + oneshot_content = oneshot_content.replace( + 'elif [ "${TARGET_OS_RELEASE[VERSION_ID]}" != \'"11"\' ]; then\n' + '\t\techo "os-release: for 64-bit systems, only Raspbian 11 is supported." >&2', + 'elif [ "${TARGET_OS_RELEASE[VERSION_ID]}" != \'"11"\' ] && ' + '[ "${TARGET_OS_RELEASE[VERSION_ID]}" != \'"12"\' ]; then\n' + '\t\techo "os-release: only Raspbian 11 and 12 supported for 64-bit." >&2', + ) + oneshot_path.write_text(oneshot_content) + console.print( + "[green]✓ Patched oneshot.sh to support Raspbian 12 (Bookworm)[/green]" + ) + # Copy to boot partition subprocess.run( ["sudo", "cp", "-r", str(temp_clone), str(lrp_dest)], diff --git a/install/install.sh b/install/install.sh index 8e722e52..f359295e 100644 --- a/install/install.sh +++ b/install/install.sh @@ -6,6 +6,9 @@ # # Or download first: # curl -fsSL -o install.sh && bash install.sh +# +# Test ePaper HAT only: +# bash install.sh --test-epaper set -e # Configuration @@ -13,6 +16,12 @@ REPO_URL="${BIRDNET_REPO_URL:-https://github.com/mverteuil/BirdNET-Pi.git}" BRANCH="${BIRDNET_BRANCH:-main}" INSTALL_DIR="/opt/birdnetpi" +# Parse command line arguments +TEST_EPAPER=false +if [ "$1" = "--test-epaper" ]; then + TEST_EPAPER=true +fi + # Check if running as root if [ "$(id -u)" -eq 0 ]; then echo "This script should not be run as root. Please run as a non-root user with sudo privileges." @@ -104,9 +113,12 @@ echo "Installing uv package manager..." sudo mkdir -p /opt/uv sudo curl -LsSf https://astral.sh/uv/install.sh | sudo INSTALLER_NO_MODIFY_PATH=1 UV_INSTALL_DIR=/opt/uv sh -# Detect Waveshare e-paper HAT via SPI devices +# Detect Waveshare e-paper HAT via SPI devices (or force for test mode) EPAPER_EXTRAS="" -if ls /dev/spidev* &>/dev/null; then +if [ "$TEST_EPAPER" = true ]; then + echo "Test mode: forcing ePaper HAT extras installation" + EPAPER_EXTRAS="--extra epaper" +elif ls /dev/spidev* &>/dev/null; then echo "Waveshare e-paper HAT detected (SPI devices found)" EPAPER_EXTRAS="--extra epaper" else @@ -136,6 +148,20 @@ done # Give DNS resolver a moment to stabilize sleep 2 +# If Waveshare library was downloaded to boot partition, patch pyproject.toml to use local path +WAVESHARE_BOOT_PATH="/boot/firmware/waveshare-epd" +if [ -d "$WAVESHARE_BOOT_PATH" ] && [ -n "$EPAPER_EXTRAS" ]; then + echo "Using pre-downloaded Waveshare library from boot partition..." + cd "$INSTALL_DIR" + + # Patch pyproject.toml to use local path instead of git URL + # Replace: waveshare-epd = {git = "...", subdirectory = "..."} + # With: waveshare-epd = {path = "/boot/firmware/waveshare-epd/RaspberryPi_JetsonNano/python"} + sudo -u birdnetpi sed -i 's|waveshare-epd = {git = "https://github.com/waveshareteam/e-Paper.git", subdirectory = "RaspberryPi_JetsonNano/python"}|waveshare-epd = {path = "/boot/firmware/waveshare-epd/RaspberryPi_JetsonNano/python"}|' pyproject.toml + + echo "✓ Configured to use local Waveshare library" +fi + # Install Python dependencies with retry mechanism (for network issues) echo "Installing Python dependencies..." cd "$INSTALL_DIR" @@ -169,6 +195,17 @@ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do fi done +# If test mode, run ePaper test and exit +if [ "$TEST_EPAPER" = true ]; then + echo "" + echo "========================================" + echo "ePaper HAT Test Mode" + echo "========================================" + echo "" + "$INSTALL_DIR/.venv/bin/python" "$INSTALL_DIR/install/test_epaper.py" + exit $? +fi + # Execute the main setup script using the venv directly # We use the venv's python instead of uv run to avoid permission issues # (uv sync ran as birdnetpi, but setup_app.py needs sudo for system operations) diff --git a/install/test_epaper.py b/install/test_epaper.py new file mode 100755 index 00000000..9907ad0a --- /dev/null +++ b/install/test_epaper.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Test script for Waveshare e-Paper HAT. + +This script verifies that the ePaper HAT is properly connected and working. +Can be run standalone without installing the full BirdNET-Pi system. +""" + +import sys +import time +from pathlib import Path + + +def check_spi_devices() -> bool: + """Check if SPI devices are available.""" + print("=" * 50) + print("Checking SPI devices...") + print("=" * 50) + + spi_devices = list(Path("/dev").glob("spidev*")) + if not spi_devices: + print("❌ No SPI devices found!") + print(" SPI may not be enabled in /boot/firmware/config.txt") + print(" Add or uncomment: dtparam=spi=on") + print(" Then reboot and try again.") + return False + + print(f"✓ Found {len(spi_devices)} SPI device(s):") + for device in spi_devices: + print(f" - {device}") + return True + + +def check_waveshare_module() -> bool: + """Check if waveshare_epd module is available.""" + print("\n" + "=" * 50) + print("Checking waveshare_epd Python module...") + print("=" * 50) + + try: + import waveshare_epd # noqa: F401 + + print("✓ waveshare_epd module is installed") + return True + except ImportError: + print("❌ waveshare_epd module not found!") + print(" Install with: uv sync --extra epaper") + print(" Or: pip install waveshare-epd") + return False + + +def detect_display_model() -> tuple[object | None, str | None]: + """Try to detect which ePaper display model is connected.""" + print("\n" + "=" * 50) + print("Detecting display model...") + print("=" * 50) + + # Common Waveshare ePaper display models + models = [ + "epd2in13_V4", # 2.13inch e-Paper HAT (V4) + "epd2in13_V3", # 2.13inch e-Paper HAT (V3) + "epd2in13", # 2.13inch e-Paper HAT + "epd2in9", # 2.9inch e-Paper HAT + "epd2in7", # 2.7inch e-Paper HAT + "epd4in2", # 4.2inch e-Paper HAT + "epd7in5", # 7.5inch e-Paper HAT + ] + + for model in models: + try: + print(f" Trying {model}...", end=" ") + module = __import__(f"waveshare_epd.{model}", fromlist=[model]) + epd_class = module.EPD + + # Try to initialize + epd = epd_class() + epd.init() + + # If we got here, this is the right model + print("✓ DETECTED!") + return epd, model + except Exception as e: + print(f"✗ ({type(e).__name__})") + continue + + print("\n❌ Could not detect display model!") + print(" Make sure the display is properly connected.") + return None, None + + +def test_display(epd: object, model: str) -> bool: + """Test the display by drawing a simple pattern.""" + print("\n" + "=" * 50) + print(f"Testing display: {model}") + print("=" * 50) + + try: + from PIL import Image, ImageDraw, ImageFont + + print(" Creating test image...") + + # Create blank image + width = epd.height # Note: height/width are swapped for rotation + height = epd.width + image = Image.new("1", (width, height), 255) # 1-bit, white background + draw = ImageDraw.Draw(image) + + # Draw test pattern + print(" Drawing test pattern...") + + # Border + draw.rectangle([(0, 0), (width - 1, height - 1)], outline=0) + + # Text + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16) + except OSError: + font = ImageFont.load_default() + + text_lines = [ + "BirdNET-Pi", + "ePaper HAT Test", + f"Model: {model}", + f"Size: {width}x{height}", + ] + + y_offset = 20 + for line in text_lines: + draw.text((10, y_offset), line, font=font, fill=0) + y_offset += 25 + + # Diagonal lines + draw.line([(0, 0), (width - 1, height - 1)], fill=0, width=2) + draw.line([(0, height - 1), (width - 1, 0)], fill=0, width=2) + + print(" Displaying image...") + epd.display(epd.getbuffer(image)) + + print(" Waiting 5 seconds...") + time.sleep(5) + + print(" Clearing display...") + epd.init() + epd.Clear() + + print(" Putting display to sleep...") + epd.sleep() + + print("\n✓ Display test successful!") + print(" If you saw the test pattern on the display, it's working correctly.") + return True + + except Exception as e: + print(f"\n❌ Display test failed: {e}") + import traceback + + traceback.print_exc() + return False + + +def main() -> int: + """Run all tests.""" + print("\n" + "=" * 50) + print("Waveshare e-Paper HAT Test") + print("=" * 50) + print() + + # Check SPI + if not check_spi_devices(): + print("\n" + "=" * 50) + print("RESULT: SPI not available") + print("=" * 50) + return 1 + + # Check Python module + if not check_waveshare_module(): + print("\n" + "=" * 50) + print("RESULT: waveshare_epd module not installed") + print("=" * 50) + return 1 + + # Detect and test display + epd, model = detect_display_model() + if not epd: + print("\n" + "=" * 50) + print("RESULT: Could not detect display") + print("=" * 50) + return 1 + + # Test the display + success = test_display(epd, model) + + print("\n" + "=" * 50) + if success: + print("RESULT: ✓ All tests passed!") + print("Your ePaper HAT is working correctly.") + else: + print("RESULT: ✗ Display test failed") + print("=" * 50) + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/install/ui_whiptail.py b/install/ui_whiptail.py deleted file mode 100644 index 30990493..00000000 --- a/install/ui_whiptail.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Pre-installation UI using whiptail for configuration collection.""" - -import platform -import subprocess -from dataclasses import dataclass - - -@dataclass -class InstallConfig: - """Configuration collected from user during pre-install.""" - - site_name: str = "BirdNET-Pi" - latitude: float = 0.0 - longitude: float = 0.0 - timezone: str = "UTC" - configure_wifi: bool = False - wifi_ssid: str = "" - wifi_password: str = "" - - -class WhiptailUI: - """Whiptail-based UI for pre-installation configuration.""" - - def __init__(self): - """Initialize whiptail UI.""" - self.width = 70 - self.height = 20 - - def show_welcome(self) -> bool: - """Show welcome screen with system information. - - Returns: - bool: True if user wants to continue, False to exit - """ - # Get system info - hostname = platform.node() - machine = platform.machine() - system = platform.system() - - message = f"""Welcome to BirdNET-Pi Installer! - -System Information: - Hostname: {hostname} - Architecture: {machine} - OS: {system} - -This installer will: - 1. Install system dependencies - 2. Configure BirdNET-Pi services - 3. Set up audio capture and analysis - 4. Enable web interface - -Requirements: - - Raspberry Pi 3B or newer - - 8GB+ SD card (16GB+ recommended) - - Internet connection - - Sudo privileges - -Press OK to continue or Cancel to exit.""" - - result = subprocess.run( - [ - "whiptail", - "--title", - "BirdNET-Pi Installer", - "--yesno", - message, - str(self.height + 5), - str(self.width), - ], - check=False, - ) - return result.returncode == 0 - - def collect_basic_config(self) -> InstallConfig: - """Collect basic configuration from user. - - Returns: - InstallConfig: User configuration - """ - config = InstallConfig() - - # Site name - result = subprocess.run( - [ - "whiptail", - "--title", - "Site Configuration", - "--inputbox", - "Enter a name for this BirdNET-Pi station:", - "10", - str(self.width), - config.site_name, - ], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - config.site_name = result.stderr.strip() or config.site_name - - # Location - Latitude - result = subprocess.run( - [ - "whiptail", - "--title", - "Location Configuration", - "--inputbox", - "Enter latitude (decimal degrees, e.g., 43.6532):", - "10", - str(self.width), - str(config.latitude), - ], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - try: - config.latitude = float(result.stderr.strip()) - except ValueError: - pass - - # Location - Longitude - result = subprocess.run( - [ - "whiptail", - "--title", - "Location Configuration", - "--inputbox", - "Enter longitude (decimal degrees, e.g., -79.3832):", - "10", - str(self.width), - str(config.longitude), - ], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - try: - config.longitude = float(result.stderr.strip()) - except ValueError: - pass - - # Timezone - result = subprocess.run( - [ - "whiptail", - "--title", - "Timezone Configuration", - "--inputbox", - "Enter timezone (e.g., America/Toronto, Europe/London):", - "10", - str(self.width), - config.timezone, - ], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - config.timezone = result.stderr.strip() or config.timezone - - return config - - def ask_wifi_config(self) -> bool: - """Ask if user wants to configure WiFi. - - Returns: - bool: True if user wants to configure WiFi - """ - result = subprocess.run( - [ - "whiptail", - "--title", - "WiFi Configuration", - "--yesno", - "Would you like to configure WiFi now?\n\n" - "Note: WiFi can also be configured later through\n" - "the web interface or raspi-config.", - "12", - str(self.width), - ], - check=False, - ) - return result.returncode == 0 - - def collect_wifi_config(self) -> tuple[str, str]: - """Collect WiFi credentials. - - Returns: - tuple[str, str]: (SSID, password) - """ - # SSID - result = subprocess.run( - [ - "whiptail", - "--title", - "WiFi Configuration", - "--inputbox", - "Enter WiFi network name (SSID):", - "10", - str(self.width), - ], - capture_output=True, - text=True, - check=False, - ) - ssid = result.stderr.strip() if result.returncode == 0 else "" - - # Password - result = subprocess.run( - [ - "whiptail", - "--title", - "WiFi Configuration", - "--passwordbox", - "Enter WiFi password:", - "10", - str(self.width), - ], - capture_output=True, - text=True, - check=False, - ) - password = result.stderr.strip() if result.returncode == 0 else "" - - return ssid, password - - def show_config_summary(self, config: InstallConfig) -> bool: - """Show configuration summary and ask for confirmation. - - Args: - config: Installation configuration - - Returns: - bool: True if user confirms, False to go back - """ - wifi_status = f"SSID: {config.wifi_ssid}" if config.configure_wifi else "Not configured" - - message = f"""Configuration Summary: - -Site Name: {config.site_name} -Location: {config.latitude}, {config.longitude} -Timezone: {config.timezone} -WiFi: {wifi_status} - -Installation will: - • Install system packages (~500MB) - • Download BirdNET models (~150MB) - • Set up systemd services - • Configure web interface on port 8888 - -This will take approximately 10-15 minutes. - -Proceed with installation?""" - - result = subprocess.run( - [ - "whiptail", - "--title", - "Confirm Installation", - "--yesno", - message, - str(self.height + 2), - str(self.width), - ], - check=False, - ) - return result.returncode == 0 - - def show_error(self, message: str) -> None: - """Show error message. - - Args: - message: Error message to display - """ - subprocess.run( - [ - "whiptail", - "--title", - "Error", - "--msgbox", - message, - "10", - str(self.width), - ], - check=False, - ) - - def run_pre_install(self) -> InstallConfig | None: - """Run complete pre-installation UI flow. - - Returns: - InstallConfig: User configuration, or None if cancelled - """ - # Welcome screen - if not self.show_welcome(): - return None - - # Collect basic configuration - config = self.collect_basic_config() - - # Ask about WiFi - if self.ask_wifi_config(): - config.configure_wifi = True - config.wifi_ssid, config.wifi_password = self.collect_wifi_config() - - # Show summary and confirm - if not self.show_config_summary(config): - return None - - return config diff --git a/pyproject.toml b/pyproject.toml index f763c0c2..d574a661 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,7 @@ dependencies = [ [project.optional-dependencies] epaper = [ - "waveshare-epd @ git+https://github.com/waveshareteam/e-Paper.git#subdirectory=RaspberryPi_JetsonNano/python", + "waveshare-epd", "RPi.GPIO>=0.7.1; platform_machine=='armv7l' or platform_machine=='aarch64'", "spidev>=3.6; platform_machine=='armv7l' or platform_machine=='aarch64'" ] @@ -194,3 +194,6 @@ package-dir = {"" = "src"} [tool.setuptools.packages.find] where = ["src"] + +[tool.uv.sources] +waveshare-epd = {git = "https://github.com/waveshareteam/e-Paper.git", subdirectory = "RaspberryPi_JetsonNano/python"} From eccef596587ff55d1c9fcd3191324294356d9d27 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Mon, 27 Oct 2025 23:07:34 -0400 Subject: [PATCH 16/48] fix: Improve Le Potato oneshot.sh patching with warning - Add warning if version check pattern not found - Split multiline replace into separate steps for reliability - Add diagnostic output to debug patching issues --- install/flash_sdcard.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index d330c468..f39e763e 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1014,17 +1014,24 @@ def configure_boot_partition( # noqa: C901 if oneshot_path.exists(): oneshot_content = oneshot_path.read_text() # Change the version check from "11" only to "11" or "12" - oneshot_content = oneshot_content.replace( - 'elif [ "${TARGET_OS_RELEASE[VERSION_ID]}" != \'"11"\' ]; then\n' - '\t\techo "os-release: for 64-bit systems, only Raspbian 11 is supported." >&2', + # The bash script uses '"11"' which in Python needs to be written as \'"11"\' + old_check = 'elif [ "${TARGET_OS_RELEASE[VERSION_ID]}" != \'"11"\' ]; then' + new_check = ( 'elif [ "${TARGET_OS_RELEASE[VERSION_ID]}" != \'"11"\' ] && ' - '[ "${TARGET_OS_RELEASE[VERSION_ID]}" != \'"12"\' ]; then\n' - '\t\techo "os-release: only Raspbian 11 and 12 supported for 64-bit." >&2', - ) - oneshot_path.write_text(oneshot_content) - console.print( - "[green]✓ Patched oneshot.sh to support Raspbian 12 (Bookworm)[/green]" + '[ "${TARGET_OS_RELEASE[VERSION_ID]}" != \'"12"\' ]; then' ) + if old_check in oneshot_content: + oneshot_content = oneshot_content.replace(old_check, new_check) + oneshot_content = oneshot_content.replace( + "only Raspbian 11 is supported", + "only Raspbian 11 and 12 are supported", + ) + oneshot_path.write_text(oneshot_content) + console.print( + "[green]✓ Patched oneshot.sh to support Raspbian 12 (Bookworm)[/green]" + ) + else: + console.print("[yellow]Warning: Could not find version check to patch[/yellow]") # Copy to boot partition subprocess.run( From cee6803ee210285965fbb31000f5621e1eb7909c Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Mon, 27 Oct 2025 23:10:59 -0400 Subject: [PATCH 17/48] fix: Add missing gpiozero and Pillow dependencies for ePaper HAT The waveshare_epd library requires gpiozero for GPIO operations, and Pillow is needed for image rendering in the test script. Without these, display modules fail with ModuleNotFoundError. --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d574a661..05047d04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,8 +90,10 @@ dependencies = [ [project.optional-dependencies] epaper = [ "waveshare-epd", + "gpiozero>=2.0; platform_machine=='armv7l' or platform_machine=='aarch64'", "RPi.GPIO>=0.7.1; platform_machine=='armv7l' or platform_machine=='aarch64'", - "spidev>=3.6; platform_machine=='armv7l' or platform_machine=='aarch64'" + "spidev>=3.6; platform_machine=='armv7l' or platform_machine=='aarch64'", + "Pillow>=10.0.0; platform_machine=='armv7l' or platform_machine=='aarch64'" ] [project.scripts] From 40f33e3ed209a5c601889ab3f311d1eca9610500 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Mon, 27 Oct 2025 23:40:09 -0400 Subject: [PATCH 18/48] fix: Add progress indicator for Waveshare library download Show spinner with estimated time (1-2 minutes) while cloning the e-Paper repository to prevent appearing frozen. Uses rich console.status() for visual feedback. --- install/flash_sdcard.py | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index f39e763e..5994bd9b 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -937,7 +937,6 @@ def configure_boot_partition( # noqa: C901 # Clone Waveshare ePaper library to boot partition for offline installation console.print() - console.print("[cyan]Downloading Waveshare ePaper library...[/cyan]") waveshare_dest = boot_mount / "waveshare-epd" temp_waveshare = Path("/tmp/waveshare_clone") @@ -946,25 +945,29 @@ def configure_boot_partition( # noqa: C901 shutil.rmtree(temp_waveshare) # Clone with depth 1 for speed (only latest commit) - subprocess.run( - [ - "git", - "clone", - "--depth", - "1", - "https://github.com/waveshareteam/e-Paper.git", - str(temp_waveshare), - ], - check=True, - capture_output=True, - ) + # Note: This can take 1-2 minutes depending on network speed + with console.status( + "[cyan]Downloading Waveshare ePaper library (this may take 1-2 minutes)...[/cyan]" + ): + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "--quiet", + "https://github.com/waveshareteam/e-Paper.git", + str(temp_waveshare), + ], + check=True, + ) - # Copy to boot partition - subprocess.run( - ["sudo", "cp", "-r", str(temp_waveshare), str(waveshare_dest)], - check=True, - ) - shutil.rmtree(temp_waveshare) + # Copy to boot partition + subprocess.run( + ["sudo", "cp", "-r", str(temp_waveshare), str(waveshare_dest)], + check=True, + ) + shutil.rmtree(temp_waveshare) console.print("[green]✓ Waveshare ePaper library downloaded to boot partition[/green]") # Copy installer script if requested From 8790b04d253e522836b4869692b50c74aa186bf9 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 09:50:12 -0400 Subject: [PATCH 19/48] fix: Use sparse-checkout to reduce Waveshare library size Only download the Python subdirectory (~45MB) instead of the full repo to fit on the boot partition. Uses git sparse-checkout to efficiently fetch only RaspberryPi_JetsonNano/python directory. Update install.sh path to match the new directory structure. --- install/flash_sdcard.py | 37 ++++++++++++++++++++++++++++++++----- install/install.sh | 5 ++--- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 5994bd9b..6e542953 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -944,17 +944,20 @@ def configure_boot_partition( # noqa: C901 if temp_waveshare.exists(): shutil.rmtree(temp_waveshare) - # Clone with depth 1 for speed (only latest commit) - # Note: This can take 1-2 minutes depending on network speed + # 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 (this may take 1-2 minutes)...[/cyan]" + "[cyan]Downloading Waveshare ePaper library (Python only, ~45MB)...[/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), @@ -962,9 +965,33 @@ def configure_boot_partition( # noqa: C901 check=True, ) - # Copy to boot partition + # Configure sparse checkout for Python subdirectory only subprocess.run( - ["sudo", "cp", "-r", str(temp_waveshare), str(waveshare_dest)], + ["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) diff --git a/install/install.sh b/install/install.sh index f359295e..6b4f1833 100644 --- a/install/install.sh +++ b/install/install.sh @@ -155,9 +155,8 @@ if [ -d "$WAVESHARE_BOOT_PATH" ] && [ -n "$EPAPER_EXTRAS" ]; then cd "$INSTALL_DIR" # Patch pyproject.toml to use local path instead of git URL - # Replace: waveshare-epd = {git = "...", subdirectory = "..."} - # With: waveshare-epd = {path = "/boot/firmware/waveshare-epd/RaspberryPi_JetsonNano/python"} - sudo -u birdnetpi sed -i 's|waveshare-epd = {git = "https://github.com/waveshareteam/e-Paper.git", subdirectory = "RaspberryPi_JetsonNano/python"}|waveshare-epd = {path = "/boot/firmware/waveshare-epd/RaspberryPi_JetsonNano/python"}|' pyproject.toml + # The Python subdirectory is copied directly to /boot/firmware/waveshare-epd + sudo -u birdnetpi sed -i 's|waveshare-epd = {git = "https://github.com/waveshareteam/e-Paper.git", subdirectory = "RaspberryPi_JetsonNano/python"}|waveshare-epd = {path = "/boot/firmware/waveshare-epd"}|' pyproject.toml echo "✓ Configured to use local Waveshare library" fi From 0a785f73a5237bec4f6c905b2562a36c27ec366b Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 11:15:49 -0400 Subject: [PATCH 20/48] fix: Work around expired LibreComputer GPG key The LibreComputer repository has an expired GPG key which causes apt-get update to fail. Patch oneshot.sh to make apt operations non-fatal with || echo, allowing bootloader conversion to proceed. This is a workaround for upstream issue - the main bootloader conversion functionality doesn't require their package repository. --- install/flash_sdcard.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 6e542953..20d3e3c0 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1056,6 +1056,12 @@ def configure_boot_partition( # noqa: C901 "only Raspbian 11 is supported", "only Raspbian 11 and 12 are supported", ) + # Also make apt-get update non-fatal (LibreComputer repo has expired GPG key) + # This allows bootloader conversion to proceed even if package repo fails + oneshot_content = oneshot_content.replace( + "apt-get update", + "apt-get update || echo 'Warning: apt update failed, continuing anyway...'", + ) oneshot_path.write_text(oneshot_content) console.print( "[green]✓ Patched oneshot.sh to support Raspbian 12 (Bookworm)[/green]" From 33e8d8e150be886ecf0d6902b17e3080e85358a8 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 11:54:34 -0400 Subject: [PATCH 21/48] fix: Copy Waveshare library to writable location before building The boot partition (FAT32) is read-only for non-root users, causing uv build to fail with 'Permission denied'. Copy the library from /boot/firmware/waveshare-epd to /opt/birdnetpi/waveshare-epd with proper ownership before installing. This allows uv to write build artifacts during package installation. --- install/install.sh | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/install/install.sh b/install/install.sh index 6b4f1833..d3521d3d 100644 --- a/install/install.sh +++ b/install/install.sh @@ -148,15 +148,21 @@ done # Give DNS resolver a moment to stabilize sleep 2 -# If Waveshare library was downloaded to boot partition, patch pyproject.toml to use local path +# 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" if [ -d "$WAVESHARE_BOOT_PATH" ] && [ -n "$EPAPER_EXTRAS" ]; then echo "Using pre-downloaded Waveshare library from boot partition..." + + # 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" + cd "$INSTALL_DIR" - # Patch pyproject.toml to use local path instead of git URL - # The Python subdirectory is copied directly to /boot/firmware/waveshare-epd - sudo -u birdnetpi sed -i 's|waveshare-epd = {git = "https://github.com/waveshareteam/e-Paper.git", subdirectory = "RaspberryPi_JetsonNano/python"}|waveshare-epd = {path = "/boot/firmware/waveshare-epd"}|' pyproject.toml + # 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 echo "✓ Configured to use local Waveshare library" fi From fc4eae92439ece55602dc56bbccf29afbbed993f Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 12:02:14 -0400 Subject: [PATCH 22/48] fix: Make all apt-get operations non-fatal in Le Potato script Use regex to append '|| true' to all apt-get commands in oneshot.sh. This ensures the bootloader conversion continues even when apt operations fail due to the expired LibreComputer repository GPG key. The bootloader conversion is the critical functionality - apt operations are secondary and can safely fail. --- install/flash_sdcard.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 20d3e3c0..9b11acf9 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1056,11 +1056,16 @@ def configure_boot_partition( # noqa: C901 "only Raspbian 11 is supported", "only Raspbian 11 and 12 are supported", ) - # Also make apt-get update non-fatal (LibreComputer repo has expired GPG key) - # This allows bootloader conversion to proceed even if package repo fails - oneshot_content = oneshot_content.replace( - "apt-get update", - "apt-get update || echo 'Warning: apt update failed, continuing anyway...'", + # Make all apt operations non-fatal (LibreComputer repo has expired GPG key) + # Using || true ensures commands always succeed even with set -e + import re + + # Find all lines with apt-get and add || true if not already present + oneshot_content = re.sub( + r"^(\s*apt-get\s+.+?)$", + r"\1 || true", + oneshot_content, + flags=re.MULTILINE, ) oneshot_path.write_text(oneshot_content) console.print( From b5effa9d9f6c7d37f05a0c0ff2808be36b91bb07 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 12:22:22 -0400 Subject: [PATCH 23/48] fix: Regenerate lockfile after patching Waveshare source When we patch pyproject.toml to use local path instead of git URL, the lockfile becomes outdated. Run 'uv lock' after patching to regenerate the lockfile, allowing 'uv sync --locked' to succeed. This fixes the dependency installation failure when using the pre-downloaded Waveshare library from boot partition. --- install/install.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/install/install.sh b/install/install.sh index d3521d3d..5d70c177 100644 --- a/install/install.sh +++ b/install/install.sh @@ -164,6 +164,10 @@ if [ -d "$WAVESHARE_BOOT_PATH" ] && [ -n "$EPAPER_EXTRAS" ]; then # 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 + # Regenerate lockfile since we changed the source + echo "Regenerating lockfile for local Waveshare library..." + sudo -u birdnetpi /opt/uv/uv lock --quiet + echo "✓ Configured to use local Waveshare library" fi From c9ada2db610ed82807f954c70092dc03698a0b9d Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 12:31:34 -0400 Subject: [PATCH 24/48] fix: Update Le Potato apt patch to match 'apt' not just 'apt-get' The LibreComputer oneshot.sh script uses 'apt' commands (not 'apt-get'), and also uses apt-mark and apt-key. Updated regex to match: - apt, apt-get, apt-mark, apt-key (using apt[-\w]*) - Piped apt commands like: wget ... | sudo apt-key add - This ensures all apt operations are non-fatal when the LibreComputer repository has an expired GPG key. --- install/flash_sdcard.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 9b11acf9..8a3d4c22 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1060,9 +1060,17 @@ def configure_boot_partition( # noqa: C901 # Using || true ensures commands always succeed even with set -e import re - # Find all lines with apt-get and add || true if not already present + # Find all lines with apt commands (apt, apt-get, apt-mark, apt-key, etc.) + # Matches: "apt update", "apt-mark hold", "apt -y install", etc. oneshot_content = re.sub( - r"^(\s*apt-get\s+.+?)$", + r"^(\s*apt[-\w]*\s+.+?)$", + r"\1 || true", + oneshot_content, + flags=re.MULTILINE, + ) + # Also handle piped apt commands like: wget ... | sudo apt-key add - + oneshot_content = re.sub( + r"^(.+\|\s*sudo\s+apt[-\w]+\s+.+?)$", r"\1 || true", oneshot_content, flags=re.MULTILINE, From 5fca4c736a194c93220a132e19093345ef3ee5ad Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 12:35:30 -0400 Subject: [PATCH 25/48] fix: Add lgpio for proper GPIO support on Raspberry Pi OS Bookworm The RPi.GPIO 0.7.1 package redirects to Jetson.GPIO which doesn't work on Raspberry Pi. Add lgpio (the modern GPIO library for Bookworm) so gpiozero can use proper GPIO access. This fixes the 'Could not determine Jetson model' error when initializing the ePaper display. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 05047d04..72b72f77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,7 @@ dependencies = [ [project.optional-dependencies] epaper = [ "waveshare-epd", + "lgpio>=0.2.2.0; platform_machine=='armv7l' or platform_machine=='aarch64'", "gpiozero>=2.0; platform_machine=='armv7l' or platform_machine=='aarch64'", "RPi.GPIO>=0.7.1; platform_machine=='armv7l' or platform_machine=='aarch64'", "spidev>=3.6; platform_machine=='armv7l' or platform_machine=='aarch64'", From 0c1310ede0cf73acae225cc2753440dd23c38dc4 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 12:43:27 -0400 Subject: [PATCH 26/48] feat: Add three-color ePaper display models to detection Add B (three-color) versions of display models to the auto-detection list: - epd2in13b_V4 (2.13" V4 with red) - epd2in13b_V3 (2.13" V3 with red) Try B versions first as they're common and can fall back to two-color drivers if needed. --- install/test_epaper.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/install/test_epaper.py b/install/test_epaper.py index 9907ad0a..c42866d0 100755 --- a/install/test_epaper.py +++ b/install/test_epaper.py @@ -55,9 +55,12 @@ def detect_display_model() -> tuple[object | None, str | None]: print("=" * 50) # Common Waveshare ePaper display models + # Try B (three-color) versions first as they're more common models = [ - "epd2in13_V4", # 2.13inch e-Paper HAT (V4) - "epd2in13_V3", # 2.13inch e-Paper HAT (V3) + "epd2in13b_V4", # 2.13inch e-Paper HAT (V4) - Three color (B/W/Red) + "epd2in13_V4", # 2.13inch e-Paper HAT (V4) - Two color (B/W) + "epd2in13b_V3", # 2.13inch e-Paper HAT (V3) - Three color + "epd2in13_V3", # 2.13inch e-Paper HAT (V3) - Two color "epd2in13", # 2.13inch e-Paper HAT "epd2in9", # 2.9inch e-Paper HAT "epd2in7", # 2.7inch e-Paper HAT From cb188074ed7212f7f1788abd54c3bc3a1e3033df Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 12:50:24 -0400 Subject: [PATCH 27/48] fix: Handle three-color ePaper displays requiring dual buffers Three-color (B/W/Red) ePaper HAT models require two image buffers: - Black/white image buffer - Red image buffer (can be blank if not using red) Detects B versions by checking for 'b' in model name and provides both buffers to display() function. Two-color displays continue to use single buffer. Fixes TypeError: EPD.display() missing 1 required positional argument: 'imagered' Also adds proper type annotations with type: ignore comments for waveshare_epd dynamic attributes. Allows ANN401 (Any usage) for install/* files since they deal with dynamically imported modules. --- install/test_epaper.py | 32 +++++++++++++++++++++----------- pyproject.toml | 1 + 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/install/test_epaper.py b/install/test_epaper.py index c42866d0..46db9398 100755 --- a/install/test_epaper.py +++ b/install/test_epaper.py @@ -8,6 +8,7 @@ import sys import time from pathlib import Path +from typing import Any def check_spi_devices() -> bool: @@ -37,7 +38,7 @@ def check_waveshare_module() -> bool: print("=" * 50) try: - import waveshare_epd # noqa: F401 + import waveshare_epd # noqa: F401 # type: ignore[import-not-found] print("✓ waveshare_epd module is installed") return True @@ -48,7 +49,7 @@ def check_waveshare_module() -> bool: return False -def detect_display_model() -> tuple[object | None, str | None]: +def detect_display_model() -> tuple[Any, str | None]: """Try to detect which ePaper display model is connected.""" print("\n" + "=" * 50) print("Detecting display model...") @@ -90,7 +91,7 @@ def detect_display_model() -> tuple[object | None, str | None]: return None, None -def test_display(epd: object, model: str) -> bool: +def test_display(epd: Any, model: str) -> bool: """Test the display by drawing a simple pattern.""" print("\n" + "=" * 50) print(f"Testing display: {model}") @@ -102,8 +103,10 @@ def test_display(epd: object, model: str) -> bool: print(" Creating test image...") # Create blank image - width = epd.height # Note: height/width are swapped for rotation - height = epd.width + width: int = ( + epd.height + ) # Note: height/width are swapped for rotation # type: ignore[attr-defined] + height: int = epd.width # type: ignore[attr-defined] image = Image.new("1", (width, height), 255) # 1-bit, white background draw = ImageDraw.Draw(image) @@ -111,7 +114,7 @@ def test_display(epd: object, model: str) -> bool: print(" Drawing test pattern...") # Border - draw.rectangle([(0, 0), (width - 1, height - 1)], outline=0) + draw.rectangle(((0, 0), (width - 1, height - 1)), outline=0) # Text try: @@ -136,17 +139,24 @@ def test_display(epd: object, model: str) -> bool: draw.line([(0, height - 1), (width - 1, 0)], fill=0, width=2) print(" Displaying image...") - epd.display(epd.getbuffer(image)) + # Three-color displays (B versions) need two buffers: black/white and red + if "b" in model.lower(): + # Create a blank red layer (all white = no red) + image_red = Image.new("1", (width, height), 255) + epd.display(epd.getbuffer(image), epd.getbuffer(image_red)) # type: ignore[attr-defined] + else: + # Two-color displays only need black/white buffer + epd.display(epd.getbuffer(image)) # type: ignore[attr-defined] print(" Waiting 5 seconds...") time.sleep(5) print(" Clearing display...") - epd.init() - epd.Clear() + epd.init() # type: ignore[attr-defined] + epd.Clear() # type: ignore[attr-defined] print(" Putting display to sleep...") - epd.sleep() + epd.sleep() # type: ignore[attr-defined] print("\n✓ Display test successful!") print(" If you saw the test pattern on the display, it's working correctly.") @@ -183,7 +193,7 @@ def main() -> int: # Detect and test display epd, model = detect_display_model() - if not epd: + if not epd or not model: print("\n" + "=" * 50) print("RESULT: Could not detect display") print("=" * 50) diff --git a/pyproject.toml b/pyproject.toml index 72b72f77..02912ebd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -191,6 +191,7 @@ unfixable = ["TID252"] # Disallow `from ... import *` [tool.ruff.lint.per-file-ignores] "tests/*" = ["ANN"] +"install/*" = ["ANN401"] # Allow Any for dynamically imported modules [tool.setuptools] package-dir = {"" = "src"} From f28d4b59f5c56710061e1d65d528255c2d18b530 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 13:14:49 -0400 Subject: [PATCH 28/48] fix: Add spi and gpio groups to birdnetpi user for ePaper HAT access The birdnetpi user needs to be in the spi and gpio groups to access /dev/spidev* and /dev/gpiochip* devices without sudo. This allows the epaper-display-daemon to run as the birdnetpi user instead of requiring root permissions. --- install/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/install.sh b/install/install.sh index 5d70c177..205d374b 100644 --- a/install/install.sh +++ b/install/install.sh @@ -101,7 +101,7 @@ 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 -sudo usermod -aG audio,video,dialout birdnetpi +sudo usermod -aG audio,video,dialout,spi,gpio birdnetpi sudo chown birdnetpi:birdnetpi "$INSTALL_DIR" # Clone repository directly to installation directory as birdnetpi user From 2b3ffbeffb7f2d02d38a24ef6b17a89aaa0eab46 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 13:26:49 -0400 Subject: [PATCH 29/48] fix: Install updated LibreComputer keyring for Le Potato GPG key issues Official solution from LibreComputer support for expired GPG key: https://hub.libre.computer/t/signatures-were-invalid-expkeysig-2e5fb7fc58c58ffb/4166 The oneshot.sh script now installs libretech-keyring_2024.05.19 at the beginning, which contains updated certificates and allows the LibreComputer repository to work properly. This fixes: - E: The repository 'https://deb.libre.computer/repo linux InRelease' is not signed - W: GPG error: EXPKEYSIG 2E5FB7FC58C58FFB - E: Unable to locate package linux-image-lc-lts-arm64 - E: Unable to locate package linux-headers-lc-lts-arm64 --- install/flash_sdcard.py | 43 +++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 8a3d4c22..337c9966 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1056,25 +1056,30 @@ def configure_boot_partition( # noqa: C901 "only Raspbian 11 is supported", "only Raspbian 11 and 12 are supported", ) - # Make all apt operations non-fatal (LibreComputer repo has expired GPG key) - # Using || true ensures commands always succeed even with set -e - import re - - # Find all lines with apt commands (apt, apt-get, apt-mark, apt-key, etc.) - # Matches: "apt update", "apt-mark hold", "apt -y install", etc. - oneshot_content = re.sub( - r"^(\s*apt[-\w]*\s+.+?)$", - r"\1 || true", - oneshot_content, - flags=re.MULTILINE, - ) - # Also handle piped apt commands like: wget ... | sudo apt-key add - - oneshot_content = re.sub( - r"^(.+\|\s*sudo\s+apt[-\w]+\s+.+?)$", - r"\1 || true", - oneshot_content, - flags=re.MULTILINE, - ) + + # Add LibreComputer keyring installation at the beginning + # This fixes expired GPG key issues - official solution from: + # https://hub.libre.computer/t/signatures-were-invalid-expkeysig-2e5fb7fc58c58ffb/4166 + keyring_fix = """ +# Install updated LibreComputer keyring to fix expired GPG keys +echo "Installing updated LibreComputer keyring..." +wget -q https://deb.libre.computer/repo/pool/main/libr/libretech-keyring/libretech-keyring_2024.05.19_all.deb -O /tmp/libretech-keyring.deb +dpkg -i /tmp/libretech-keyring.deb +rm /tmp/libretech-keyring.deb +echo "✓ LibreComputer keyring updated" + +""" + # Insert after the shebang line + lines = oneshot_content.split("\n") + # Find first non-comment, non-empty line after shebang + insert_index = 1 + for i, line in enumerate(lines[1:], 1): + if line.strip() and not line.strip().startswith("#"): + insert_index = i + break + lines.insert(insert_index, keyring_fix) + oneshot_content = "\n".join(lines) + oneshot_path.write_text(oneshot_content) console.print( "[green]✓ Patched oneshot.sh to support Raspbian 12 (Bookworm)[/green]" From ad33435e370941b4d5973b79c7f299a656e65057 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 13:37:08 -0400 Subject: [PATCH 30/48] fix: Add network wait and validation for LibreComputer keyring download During first boot, network/DNS may not be ready when oneshot.sh runs, causing wget to download incomplete/corrupted files. Added: - 30 second network wait with ping check - File validation to ensure .deb package is valid before installing - Proper error handling that warns but continues on failure This should fix the 'unexpected end of file in archive magic version number' error when dpkg tries to install a corrupted download. --- install/flash_sdcard.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 337c9966..b2132e43 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1062,11 +1062,31 @@ def configure_boot_partition( # noqa: C901 # https://hub.libre.computer/t/signatures-were-invalid-expkeysig-2e5fb7fc58c58ffb/4166 keyring_fix = """ # Install updated LibreComputer keyring to fix expired GPG keys +echo "Waiting for network to be ready..." +for i in $(seq 1 30); do + if ping -c 1 -W 2 deb.libre.computer >/dev/null 2>&1; then + echo "Network ready" + break + fi + sleep 1 +done + echo "Installing updated LibreComputer keyring..." -wget -q https://deb.libre.computer/repo/pool/main/libr/libretech-keyring/libretech-keyring_2024.05.19_all.deb -O /tmp/libretech-keyring.deb -dpkg -i /tmp/libretech-keyring.deb -rm /tmp/libretech-keyring.deb -echo "✓ LibreComputer keyring updated" +if wget --timeout=30 --tries=3 https://deb.libre.computer/repo/pool/main/libr/libretech-keyring/libretech-keyring_2024.05.19_all.deb -O /tmp/libretech-keyring.deb; then + # Verify downloaded file is a valid .deb package + if file /tmp/libretech-keyring.deb | grep -q "Debian binary package"; then + if dpkg -i /tmp/libretech-keyring.deb; then + echo "✓ LibreComputer keyring updated successfully" + else + echo "⚠ Warning: Failed to install keyring package, continuing anyway..." + fi + else + echo "⚠ Warning: Downloaded file is not a valid .deb package, skipping..." + fi + rm -f /tmp/libretech-keyring.deb +else + echo "⚠ Warning: Failed to download keyring package, continuing anyway..." +fi """ # Insert after the shebang line From e2f4887ad5b1ec835333cee66c1a4645c9ab2aa1 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 13:45:00 -0400 Subject: [PATCH 31/48] fix: Correct Waveshare library download size message The sparse-checkout transfers ~6MB over the network (compressed git objects), not 45MB. The 45MB is the final on-disk size after checkout. Updated message to show actual transfer size to avoid confusion. --- install/flash_sdcard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index b2132e43..80c80979 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -947,7 +947,7 @@ def configure_boot_partition( # noqa: C901 # 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 only, ~45MB)...[/cyan]" + "[cyan]Downloading Waveshare ePaper library (Python subdirectory, ~6MB transfer)...[/cyan]" ): # Initialize sparse checkout subprocess.run( From ee62e8113f4d6620c7159fc459900aba5d6eea35 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 13:49:14 -0400 Subject: [PATCH 32/48] fix: Prevent oneshot.sh from overwriting updated LibreComputer GPG keys The libretech-keyring_2024.05.19 package installs updated GPG keys, but then oneshot.sh downloads the old expired key from libre-computer-deb.gpg and overwrites them. Now comment out the wget command that downloads the old key, so the updated keys from the keyring package are preserved. This should fix: - W: GPG error: EXPKEYSIG 2E5FB7FC58C58FFB - E: The repository 'https://deb.libre.computer/repo linux InRelease' is not signed --- install/flash_sdcard.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 80c80979..a22ab931 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1100,6 +1100,16 @@ def configure_boot_partition( # noqa: C901 lines.insert(insert_index, keyring_fix) oneshot_content = "\n".join(lines) + # Comment out the wget that downloads the old expired GPG key + # The keyring package we installed above has the updated keys + import re + oneshot_content = re.sub( + r"^(\s*wget\s+.*libre-computer-deb\.gpg.*)$", + r"# \1 # Commented: using updated keyring package instead", + oneshot_content, + flags=re.MULTILINE, + ) + oneshot_path.write_text(oneshot_content) console.print( "[green]✓ Patched oneshot.sh to support Raspbian 12 (Bookworm)[/green]" From 157222ded836826dfedf99d822d980ce49b65016 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 14:02:22 -0400 Subject: [PATCH 33/48] fix: Properly comment out wget line for expired GPG key The regex was adding the comment to the end of the line instead of commenting out the beginning. The wget command was still running. Changed from: wget ... 'libre-computer-deb.gpg' # Commented: using updated keyring To: # wget ... 'libre-computer-deb.gpg' # Commented: using updated keyring Removed \s* from regex start so it properly matches lines beginning with 'wget' and adds '#' at the beginning. --- install/flash_sdcard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index a22ab931..df925503 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1104,7 +1104,7 @@ def configure_boot_partition( # noqa: C901 # The keyring package we installed above has the updated keys import re oneshot_content = re.sub( - r"^(\s*wget\s+.*libre-computer-deb\.gpg.*)$", + r"^(wget\s+.*libre-computer-deb\.gpg.*)$", r"# \1 # Commented: using updated keyring package instead", oneshot_content, flags=re.MULTILINE, From 8e186b1d71f6a083d711ed783edfb080562a3534 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 14:03:41 -0400 Subject: [PATCH 34/48] fix: Bypass SSL certificate check for keyring download on first boot On fresh Raspbian images, the system clock is set to the image creation date (2024-07-04), causing wget to fail with SSL certificate errors: ERROR: The certificate of 'deb.libre.computer' is not trusted. ERROR: The certificate of 'deb.libre.computer' is not yet activated. Added --no-check-certificate to wget since: 1. We're downloading from the official LibreComputer repository 2. We validate the downloaded file is a valid .deb package before installing 3. The clock will be corrected by NTP after boot completes 4. This is a trusted first-party package needed to fix the GPG key issue This is safe because we verify the file type before installing. --- install/flash_sdcard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index df925503..19d53cb7 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1072,7 +1072,7 @@ def configure_boot_partition( # noqa: C901 done echo "Installing updated LibreComputer keyring..." -if wget --timeout=30 --tries=3 https://deb.libre.computer/repo/pool/main/libr/libretech-keyring/libretech-keyring_2024.05.19_all.deb -O /tmp/libretech-keyring.deb; then +if wget --no-check-certificate --timeout=30 --tries=3 https://deb.libre.computer/repo/pool/main/libr/libretech-keyring/libretech-keyring_2024.05.19_all.deb -O /tmp/libretech-keyring.deb; then # Verify downloaded file is a valid .deb package if file /tmp/libretech-keyring.deb | grep -q "Debian binary package"; then if dpkg -i /tmp/libretech-keyring.deb; then From 108f8b4dda6bed5e21e157d72d1b444495fa6235 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Tue, 28 Oct 2025 14:16:38 -0400 Subject: [PATCH 35/48] fix: Make grub-install non-fatal for Le Potato (uses u-boot) Le Potato uses u-boot bootloader, not grub. The oneshot.sh script tries to run grub-install for x86 boards, which fails on ARM: grub-install: error: /boot doesn't look like an EFI partition. Since the script uses 'set -e', this failure causes it to exit before completing the u-boot installation. Added || true to grub_install_cmd so the script continues past the error and completes the conversion process. --- install/flash_sdcard.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 19d53cb7..4fba3e00 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -1110,6 +1110,15 @@ def configure_boot_partition( # noqa: C901 flags=re.MULTILINE, ) + # Make grub-install non-fatal (Le Potato uses u-boot, not grub) + # The script tries to run grub-install for x86 boards, but Le Potato doesn't need it + oneshot_content = re.sub( + r"^(\$grub_install_cmd)$", + r"\1 || true # Non-fatal: Le Potato uses u-boot, not grub", + oneshot_content, + flags=re.MULTILINE, + ) + oneshot_path.write_text(oneshot_content) console.print( "[green]✓ Patched oneshot.sh to support Raspbian 12 (Bookworm)[/green]" From f483d3c49608e10169b8fa49affd8038e4a5b121 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 29 Oct 2025 21:25:22 -0400 Subject: [PATCH 36/48] (fix) Fix variable name referring to birdnet instead of birdnetpi --- install/flash_sdcard.py | 237 +++++++++++++++++++++++++++++++++++----- install/install.sh | 4 +- uv.lock | 40 +++++++ 3 files changed, 254 insertions(+), 27 deletions(-) diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 4fba3e00..65b0e193 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -97,7 +97,7 @@ def find_command(cmd: str, homebrew_paths: list[str] | None = None) -> str: return cmd -# Raspberry Pi OS image URLs (Lite versions for headless server) +# Raspberry Pi OS and Armbian image URLs (Lite/Minimal versions for headless server) PI_IMAGES = { "Pi 5": { "url": "https://downloads.raspberrypi.org/raspios_lite_arm64/images/raspios_lite_arm64-2024-07-04/2024-07-04-raspios-bookworm-arm64-lite.img.xz", @@ -117,12 +117,19 @@ def find_command(cmd: str, homebrew_paths: list[str] | None = None) -> str: "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", }, - "Le Potato": { + "Le Potato (Raspbian)": { # LibreComputer AML-S905X-CC - uses same arm64 image as Pi, requires portability script "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", "requires_portability": True, }, + "Le Potato (Armbian)": { + # LibreComputer AML-S905X-CC - Native Armbian Bookworm minimal + # URL format: https://dl.armbian.com/lepotato/Bookworm_current_minimal + # This redirects to the latest stable build with .sha file available + "url": "https://dl.armbian.com/lepotato/Bookworm_current_minimal", + "is_armbian": True, # Will download and verify .sha file from same location + }, } CONFIG_DIR = Path.home() / ".config" / "birdnetpi" @@ -210,14 +217,16 @@ def select_profile() -> tuple[dict[str, Any] | None, str | None, bool]: table = Table(show_header=True, header_style="bold cyan") table.add_column("Key", style="dim") table.add_column("Profile Name", style="green") + table.add_column("Device Type", justify="left") table.add_column("Hostname", justify="left") table.add_column("WiFi SSID", justify="left") for idx, profile in enumerate(profiles): config = profile["config"] + device_type = config.get("device_type", "Not set") hostname = config.get("hostname", "N/A") wifi_ssid = config.get("wifi_ssid", "Not configured") - table.add_row(str(idx), profile["name"], hostname, wifi_ssid) + table.add_row(str(idx), profile["name"], device_type, hostname, wifi_ssid) console.print(table) console.print() @@ -344,14 +353,42 @@ def list_block_devices() -> list[dict[str, str]]: sys.exit(1) -def select_device() -> str: - """Prompt user to select a block device to flash.""" +def select_device(device_index: int | None = None) -> str: + """Prompt user to select a block device to flash. + + Args: + device_index: Optional 1-based index to select device without prompting + + Returns: + Selected device path (e.g., "/dev/disk2") + """ devices = list_block_devices() if not devices: console.print("[red]No removable devices found![/red]") sys.exit(1) + # If device_index provided, validate and use it + if device_index is not None: + if device_index < 1 or device_index > len(devices): + console.print(f"[red]Invalid device index: {device_index}[/red]") + console.print(f"[yellow]Available indices: 1-{len(devices)}[/yellow]") + sys.exit(1) + + selected = devices[device_index - 1] + console.print(f"[cyan]Using device {device_index}: {selected['device']}[/cyan]") + + console.print() + console.print( + Panel( + f"[bold yellow]WARNING: ALL DATA ON {selected['device']} " + "WILL BE ERASED![/bold yellow]", + border_style="red", + ) + ) + return selected["device"] + + # Otherwise, prompt user to select console.print() console.print("[bold cyan]Available Devices:[/bold cyan]") table = Table(show_header=True, header_style="bold cyan") @@ -387,8 +424,41 @@ def select_device() -> str: return selected["device"] -def select_pi_version() -> str: - """Prompt user to select device model.""" +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") @@ -402,16 +472,18 @@ def select_pi_version() -> str: "4": "Pi 4", "3": "Pi 3", "0": "Pi Zero 2 W", - "L": "Le Potato", + "R": "Le Potato (Raspbian)", + "A": "Le Potato (Armbian)", } - # Display in order (0, 3, 4, 5, L) + # 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", ""), - ("L", "Le Potato", "AML-S905X-CC"), + ("R", "Le Potato (Raspbian)", "Two-step boot required"), + ("A", "Le Potato (Armbian)", "Native Armbian, direct boot"), ] for version, model, notes in display_order: @@ -420,26 +492,63 @@ def select_pi_version() -> str: 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: - """Download Raspberry Pi OS image if not already cached.""" + """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"] - filename = url.split("/")[-1] + 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 Raspberry Pi OS image for {pi_version}...[/cyan]") + console.print(f"[cyan]Downloading image for {pi_version}...[/cyan]") with Progress( TextColumn("[bold blue]{task.description}"), @@ -449,6 +558,7 @@ def download_image(pi_version: str, download_dir: Path) -> Path: TimeElapsedColumn(), console=console, ) as progress: + # Download with SSL verification enabled response = requests.get(url, stream=True, timeout=30) response.raise_for_status() @@ -461,6 +571,38 @@ def download_image(pi_version: str, download_dir: Path) -> Path: 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() + # SHA file format: "hash filename" + expected_sha = sha_response.text.strip().split()[0] + + # 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 @@ -947,7 +1089,8 @@ def configure_boot_partition( # noqa: C901 # 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]" + "[cyan]Downloading Waveshare ePaper library " + "(Python subdirectory, ~6MB transfer)...[/cyan]" ): # Initialize sparse checkout subprocess.run( @@ -1011,8 +1154,8 @@ def configure_boot_partition( # noqa: C901 "[yellow]Warning: install.sh not found, skipping installer copy[/yellow]" ) - # Copy LibreComputer portability script for Le Potato - if pi_version == "Le Potato": + # Copy LibreComputer portability script for Le Potato (Raspbian only, not Armbian) + if pi_version == "Le Potato (Raspbian)": console.print() console.print("[cyan]Installing LibreComputer Raspbian Portability Script...[/cyan]") @@ -1072,7 +1215,10 @@ def configure_boot_partition( # noqa: C901 done echo "Installing updated LibreComputer keyring..." -if wget --no-check-certificate --timeout=30 --tries=3 https://deb.libre.computer/repo/pool/main/libr/libretech-keyring/libretech-keyring_2024.05.19_all.deb -O /tmp/libretech-keyring.deb; then +KEYRING_URL="https://deb.libre.computer/repo/pool/main/libr/libretech-keyring" +KEYRING_DEB="libretech-keyring_2024.05.19_all.deb" +if wget --no-check-certificate --timeout=30 --tries=3 \ + "$KEYRING_URL/$KEYRING_DEB" -O /tmp/libretech-keyring.deb; then # Verify downloaded file is a valid .deb package if file /tmp/libretech-keyring.deb | grep -q "Debian binary package"; then if dpkg -i /tmp/libretech-keyring.deb; then @@ -1103,6 +1249,7 @@ def configure_boot_partition( # noqa: C901 # Comment out the wget that downloads the old expired GPG key # The keyring package we installed above has the updated keys import re + oneshot_content = re.sub( r"^(wget\s+.*libre-computer-deb\.gpg.*)$", r"# \1 # Commented: using updated keyring package instead", @@ -1111,7 +1258,8 @@ def configure_boot_partition( # noqa: C901 ) # Make grub-install non-fatal (Le Potato uses u-boot, not grub) - # The script tries to run grub-install for x86 boards, but Le Potato doesn't need it + # The script tries to run grub-install for x86 boards, + # but Le Potato doesn't need it oneshot_content = re.sub( r"^(\$grub_install_cmd)$", r"\1 || true # Non-fatal: Le Potato uses u-boot, not grub", @@ -1274,7 +1422,17 @@ def configure_boot_partition( # noqa: C901 @click.option( "--save-config", "save_config_flag", is_flag=True, help="Save configuration for future use" ) -def main(save_config_flag: bool) -> None: +@click.option( + "--device-index", + type=int, + help="SD card device index (1-based) for unattended operation", +) +@click.option( + "--device-type", + type=str, + help="Device type override (e.g., 'Pi 4', 'Le Potato (Armbian)')", +) +def main(save_config_flag: bool, device_index: int | None, device_type: str | None) -> None: """Flash Raspberry Pi OS to SD card and configure for BirdNET-Pi.""" console.print() console.print( @@ -1292,10 +1450,14 @@ def main(save_config_flag: bool) -> None: saved_config = profile_config # Select device - device = select_device() + device = select_device(device_index=device_index) # Select Pi version - pi_version = select_pi_version() + pi_version = select_pi_version( + saved_device_type=saved_config.get("device_type") if saved_config else None, + device_type_override=device_type, + edit_mode=edit_mode, + ) # Download image download_dir = Path.home() / ".cache" / "birdnetpi" / "images" @@ -1305,6 +1467,9 @@ def main(save_config_flag: bool) -> None: # Get configuration (edit_mode shows prompts with defaults instead of auto-using saved values) config = get_config_from_prompts(saved_config, edit_mode=edit_mode) + # Add device_type to config before saving + config["device_type"] = pi_version + # Save configuration as profile # CRITICAL FIX: When editing, default to the original profile name, not "default" if ( @@ -1331,8 +1496,17 @@ def main(save_config_flag: bool) -> None: ) flash_image(image_path, device) - # Configure boot partition - configure_boot_partition(device, config, pi_version) + # Configure boot partition (skip for Armbian - uses different partition layout) + image_info = PI_IMAGES[pi_version] + is_armbian = image_info.get("is_armbian", False) + if not is_armbian: + configure_boot_partition(device, config, pi_version) + else: + console.print() + console.print("[yellow]Note: Armbian uses its own first-boot configuration wizard[/yellow]") + console.print( + "[dim]You will be prompted to create a user and set up SSH on first boot[/dim]" + ) # Eject SD card console.print() @@ -1359,8 +1533,8 @@ def main(save_config_flag: bool) -> None: else: summary_parts.append("WiFi: [yellow]Not configured (Ethernet required)[/yellow]") - # Special instructions for Le Potato - if pi_version == "Le Potato": + # Special instructions for Le Potato (Raspbian) + if pi_version == "Le Potato (Raspbian)": summary_parts.append("Portability Script: [green]Installed[/green]\n") summary_parts.append( "[bold yellow]⚠ IMPORTANT: Two-Step Boot Process Required![/bold yellow]\n" @@ -1373,6 +1547,19 @@ def main(save_config_flag: bool) -> None: "5. SSH in and run: [cyan]bash /boot/firmware/install.sh[/cyan]\n\n" "See [cyan]LE_POTATO_README.txt[/cyan] on boot partition for details.[/dim]" ) + # Direct boot instructions for Le Potato (Armbian) + elif pi_version == "Le Potato (Armbian)": + summary_parts.append("Native Armbian: [green]Direct boot ready[/green]\n") + summary_parts.append( + "[dim]Insert the SD card into your Le Potato and power it on.\n" + "Armbian will run its first-boot setup wizard:\n" + " 1. Create a root password\n" + " 2. Create a user account\n" + " 3. Configure locale/timezone\n\n" + "After setup, SSH in and run the BirdNET-Pi installer:\n" + " [cyan]curl -fsSL https://raw.githubusercontent.com/mverteuil/BirdNET-Pi/" + "main/install/install.sh | bash[/cyan][/dim]" + ) # Add installer script status for regular Pi elif config.get("copy_installer"): summary_parts.append("Installer: [green]Copied to /boot/firmware/install.sh[/green]\n") diff --git a/install/install.sh b/install/install.sh index 205d374b..092d877d 100644 --- a/install/install.sh +++ b/install/install.sh @@ -12,8 +12,8 @@ set -e # Configuration -REPO_URL="${BIRDNET_REPO_URL:-https://github.com/mverteuil/BirdNET-Pi.git}" -BRANCH="${BIRDNET_BRANCH:-main}" +REPO_URL="${BIRDNETPI_REPO_URL:-https://github.com/mverteuil/BirdNET-Pi.git}" +BRANCH="${BIRDNETPI_BRANCH:-main}" INSTALL_DIR="/opt/birdnetpi" # Parse command line arguments diff --git a/uv.lock b/uv.lock index 1e8bb8ac..e4a6f121 100644 --- a/uv.lock +++ b/uv.lock @@ -275,6 +275,9 @@ dependencies = [ [package.optional-dependencies] epaper = [ + { name = "gpiozero", marker = "platform_machine == 'aarch64' or platform_machine == 'armv7l'" }, + { name = "lgpio", marker = "platform_machine == 'aarch64' or platform_machine == 'armv7l'" }, + { name = "pillow", marker = "platform_machine == 'aarch64' or platform_machine == 'armv7l'" }, { name = "rpi-gpio", marker = "platform_machine == 'aarch64' or platform_machine == 'armv7l'" }, { name = "spidev", marker = "platform_machine == 'aarch64' or platform_machine == 'armv7l'" }, { name = "waveshare-epd" }, @@ -323,15 +326,18 @@ requires-dist = [ { name = "colorama", specifier = "==0.4.4" }, { name = "dependency-injector", specifier = "==4.48.1" }, { name = "fastapi" }, + { name = "gpiozero", marker = "(platform_machine == 'aarch64' and extra == 'epaper') or (platform_machine == 'armv7l' and extra == 'epaper')", specifier = ">=2.0" }, { name = "gpsdclient" }, { name = "greenlet", specifier = ">=3.2.3" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "lgpio", marker = "(platform_machine == 'aarch64' and extra == 'epaper') or (platform_machine == 'armv7l' and extra == 'epaper')", specifier = ">=0.2.2.0" }, { name = "librosa" }, { name = "numpy", specifier = "<2" }, { name = "openpyxl", specifier = ">=3.1.5" }, { name = "packaging", specifier = ">=25.0" }, { name = "paho-mqtt" }, { name = "pandas" }, + { name = "pillow", marker = "(platform_machine == 'aarch64' and extra == 'epaper') or (platform_machine == 'armv7l' and extra == 'epaper')", specifier = ">=10.0.0" }, { name = "pip" }, { name = "plotly" }, { name = "psutil", specifier = ">=7.0.0" }, @@ -471,6 +477,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/98/5b86278fbbf250d239ae0ecb724f8572af1c91f4a11edf4d36a206189440/colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2", size = 16028, upload-time = "2020-10-13T02:42:26.463Z" }, ] +[[package]] +name = "colorzero" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/688824a06e8c4d04c7d2fd2af2d8da27bed51af20ee5f094154e1d680334/colorzero-2.0.tar.gz", hash = "sha256:e7d5a5c26cd0dc37b164ebefc609f388de24f8593b659191e12d85f8f9d5eb58", size = 25382, upload-time = "2021-03-15T23:42:23.261Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/a6/ddd0f130e44a7593ac6c55aa93f6e256d2270fd88e9d1b64ab7f22ab8fde/colorzero-2.0-py2.py3-none-any.whl", hash = "sha256:0e60d743a6b8071498a56465f7719c96a5e92928f858bab1be2a0d606c9aa0f8", size = 26573, upload-time = "2021-03-15T23:42:21.757Z" }, +] + [[package]] name = "contourpy" version = "1.3.2" @@ -654,6 +672,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] +[[package]] +name = "gpiozero" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorzero" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/334b8db8a981eca9a0fb1e7e48e1997a5eaa8f40bb31c504299dcca0e6ff/gpiozero-2.0.1.tar.gz", hash = "sha256:d4ea1952689ec7e331f9d4ebc9adb15f1d01c2c9dcfabb72e752c9869ab7e97e", size = 136176, upload-time = "2024-02-15T11:07:02.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/eb/6518a1b00488d48995034226846653c382d676cf5f04be62b3c3fae2c6a1/gpiozero-2.0.1-py3-none-any.whl", hash = "sha256:8f621de357171d574c0b7ea0e358cb66e560818a47b0eeedf41ce1cdbd20c70b", size = 150818, upload-time = "2024-02-15T11:07:00.451Z" }, +] + [[package]] name = "gpsdclient" version = "1.3.2" @@ -847,6 +877,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" }, ] +[[package]] +name = "lgpio" +version = "0.2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/33/26ec2e8049eaa2f077bf23a12dc61ca559fbfa7bea0516bf263d657ae275/lgpio-0.2.2.0.tar.gz", hash = "sha256:11372e653b200f76a0b3ef8a23a0735c85ec678a9f8550b9893151ed0f863fff", size = 90087, upload-time = "2024-03-29T21:59:55.901Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/4e/5721ae44b29e4fe9175f68c881694e3713066590739a7c87f8cee2835c25/lgpio-0.2.2.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:5b3c403e1fba9c17d178f1bde102726c548fc5c4fc1ccf5ec3e18f3c08e07e04", size = 382992, upload-time = "2024-03-29T22:00:45.039Z" }, + { url = "https://files.pythonhosted.org/packages/88/53/e57a22fe815fc68d0991655c1105b8ed872a68491d32e4e0e7d10ffb5c4d/lgpio-0.2.2.0-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:a2f71fb95b149d8ac82c7c6bae70f054f6dc42a006ad35c90c7d8e54921fbcf4", size = 364848, upload-time = "2024-04-01T22:49:45.889Z" }, +] + [[package]] name = "librosa" version = "0.11.0" From a4670135d98a7d263fba897ac9d3b78d0ce5e3c7 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Wed, 29 Oct 2025 22:55:32 -0400 Subject: [PATCH 37/48] fix: Parse API port from config for ePaper health checks The ePaper display daemon was hardcoded to check health on port 8000, but FastAPI runs on configurable port 8888. This caused health checks to fail and the display to show nothing. Now parses the API base URL from detections_endpoint config instead of hardcoding localhost:8000. For example, if detections_endpoint is 'http://127.0.0.1:8888/api/detections/', extracts 'http://127.0.0.1:8888' as the API base URL. Tested on Pi 4B - health checks now succeed and display shows status. --- src/birdnetpi/display/epaper.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/birdnetpi/display/epaper.py b/src/birdnetpi/display/epaper.py index a6ed1ff5..a392b85b 100644 --- a/src/birdnetpi/display/epaper.py +++ b/src/birdnetpi/display/epaper.py @@ -181,8 +181,19 @@ async def _get_health_status(self) -> dict[str, Any]: Dictionary with health status information """ try: + # Extract API base URL from detections_endpoint config + # Default to port 8000 if config not available + api_url = "http://localhost:8000" + if hasattr(self.config, "detections_endpoint") and self.config.detections_endpoint: + # Parse endpoint like "http://127.0.0.1:8888/api/detections/" + # to get base URL "http://127.0.0.1:8888" + endpoint = self.config.detections_endpoint + if "/api/" in endpoint: + api_url = endpoint.split("/api/")[0] + async with aiohttp.ClientSession() as session: - async with session.get("http://localhost:8000/api/health/ready") as response: + health_url = f"{api_url}/api/health/ready" + async with session.get(health_url) as response: if response.status == 200: data = await response.json() return { From 3b8e87d1379e1952a357c923258f419933457010 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Thu, 30 Oct 2025 12:37:24 -0400 Subject: [PATCH 38/48] feat: Add partial refresh support for ePaper displays Implements partial refresh mode to eliminate flicker on 2-color displays. Falls back gracefully to full refresh for 3-color displays that don't support partial refresh (like 2in13b_V4). - Add partial refresh counter and interval tracking - Init display in partial refresh mode after initial clear - Use displayPartial() for updates when supported - Full refresh every 20 partial updates to prevent ghosting - Graceful fallback if partial refresh not available 3-color displays will continue using full refresh due to hardware limitations with the red pigment layer. --- src/birdnetpi/display/epaper.py | 79 ++++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/src/birdnetpi/display/epaper.py b/src/birdnetpi/display/epaper.py index a392b85b..5a0d7e1f 100644 --- a/src/birdnetpi/display/epaper.py +++ b/src/birdnetpi/display/epaper.py @@ -74,6 +74,10 @@ def __init__( self._last_detection_id: uuid.UUID | None = None self._animation_frames = 0 + # Partial refresh tracking + self._partial_refresh_count = 0 + self._full_refresh_interval = 20 # Full refresh every N partial updates + # Try to import Waveshare library based on config display_type = self.config.epaper_display_type module_name = self.DISPLAY_MODULES.get(display_type) @@ -117,9 +121,16 @@ def _init_display(self) -> None: try: self._epd = self._epd_module.EPD() - self._epd.init() - self._epd.Clear() - logger.info("E-paper display initialized") + self._epd.init() # Full refresh init + self._epd.Clear() # Clear the display + # Switch to partial refresh mode for subsequent updates + # This eliminates flicker for normal status updates + try: + self._epd.init_part() + logger.info("E-paper display initialized (partial refresh mode)") + except (AttributeError, OSError): + # Some displays don't support partial refresh + logger.info("E-paper display initialized (full refresh only)") except Exception: logger.exception("Failed to initialize e-paper display") self._has_hardware = False @@ -412,13 +423,17 @@ def _create_composite_image( return composite def _update_display( - self, black_image: Image.Image, red_image: Image.Image | None = None + self, + black_image: Image.Image, + red_image: Image.Image | None = None, + force_full_refresh: bool = False, ) -> None: """Update the physical e-paper display with the given image(s). Args: black_image: PIL Image for black layer red_image: Optional PIL Image for red layer (3-color displays only) + force_full_refresh: Force a full refresh instead of partial (eliminates ghosting) """ if not self._has_hardware or self._epd is None: # In simulation mode, only save files if running in Docker @@ -447,14 +462,51 @@ def _update_display( return try: - if self._is_color_display and red_image: - # 3-color display: send both black and red buffers - self._epd.display(self._epd.getbuffer(black_image), self._epd.getbuffer(red_image)) - logger.debug("3-color display updated (black + red)") + # Decide whether to use full or partial refresh + use_full_refresh = force_full_refresh or ( + self._partial_refresh_count >= self._full_refresh_interval + ) + + if use_full_refresh: + # Full refresh: clear screen completely (eliminates ghosting but flickers) + self._epd.init() # Reinit for full refresh mode + if self._is_color_display and red_image: + # 3-color display: send both black and red buffers + self._epd.display( + self._epd.getbuffer(black_image), self._epd.getbuffer(red_image) + ) + logger.debug("3-color display updated (full refresh)") + else: + # 2-color display: send only black buffer + self._epd.display(self._epd.getbuffer(black_image)) + logger.debug("2-color display updated (full refresh)") + + # Reset counter and prepare for partial refresh + self._partial_refresh_count = 0 + self._epd.init_part() # Switch to partial refresh mode else: - # 2-color display: send only black buffer - self._epd.display(self._epd.getbuffer(black_image)) - logger.debug("2-color display updated") + # Partial refresh: update only changed pixels (no flicker but can ghost) + # Try partial refresh even for 3-color displays (V4 may support it) + # Note: red layer won't update in partial mode, only black layer + try: + self._epd.displayPartial(self._epd.getbuffer(black_image)) + self._partial_refresh_count += 1 + logger.debug( + "Display updated (partial refresh %d/%d)", + self._partial_refresh_count, + self._full_refresh_interval, + ) + except (AttributeError, TypeError): + # Partial refresh not supported, fall back to full + logger.warning("Partial refresh failed, falling back to full refresh") + self._epd.init() + if self._is_color_display and red_image: + self._epd.display( + self._epd.getbuffer(black_image), self._epd.getbuffer(red_image) + ) + else: + self._epd.display(self._epd.getbuffer(black_image)) + self._partial_refresh_count = 0 # Reset to force full refresh mode except Exception: logger.exception("Failed to update e-paper display") @@ -507,6 +559,11 @@ async def _display_loop(self) -> None: stats, health, detection, show_animation, animation_frame ) self._update_display(black_image, red_image) + logger.info( + "Display updated - Health: %s, Detection: %s", + health.get("status", "unknown"), + detection.common_name if detection else "None", + ) # Use faster refresh during animation (2 seconds) for better visibility # Normal refresh otherwise (default 30 seconds) to preserve e-paper lifespan From c59e6b02a94584063b290722803491cee4cf429c Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Thu, 30 Oct 2025 12:48:00 -0400 Subject: [PATCH 39/48] refactor: Reduce _update_display complexity by extracting helper methods Split the complex _update_display() method into focused helper methods: - _update_display_partial(): Attempt partial refresh with method name variants - _save_simulation_images(): Handle simulation mode file writes - _update_3color_display(): Handle 3-color displays (always full refresh) - _update_2color_display(): Handle 2-color displays (partial refresh capable) Also optimized red layer creation to only occur during animations, creating an empty red buffer otherwise to maintain 3-color display compatibility. Fixes Ruff C901 complexity warning. --- src/birdnetpi/display/epaper.py | 162 ++++++++++++++++++-------------- 1 file changed, 93 insertions(+), 69 deletions(-) diff --git a/src/birdnetpi/display/epaper.py b/src/birdnetpi/display/epaper.py index 5a0d7e1f..33fe3ee1 100644 --- a/src/birdnetpi/display/epaper.py +++ b/src/birdnetpi/display/epaper.py @@ -259,10 +259,11 @@ def _draw_status_screen( black_image = self._create_image() black_draw = ImageDraw.Draw(black_image) - # Create red layer (only for 3-color displays) + # Create red layer (only for 3-color displays AND only when showing animation) + # This allows partial refresh when not animating red_image = None red_draw = None - if self._is_color_display: + if self._is_color_display and show_animation: red_image = self._create_image() red_draw = ImageDraw.Draw(red_image) @@ -422,6 +423,34 @@ def _create_composite_image( return composite + def _update_display_partial(self, black_image: Image.Image) -> bool: + """Attempt partial refresh update for 2-color displays. + + Returns: + True if partial refresh succeeded, False if full refresh needed + """ + assert self._epd is not None # Type narrowing for pyright + try: + # Try different method names (varies by display model) + if hasattr(self._epd, "displayPartial"): + self._epd.displayPartial(self._epd.getbuffer(black_image)) + elif hasattr(self._epd, "DisplayPartial"): + self._epd.DisplayPartial(self._epd.getbuffer(black_image)) + elif hasattr(self._epd, "display_Partial"): + self._epd.display_Partial(self._epd.getbuffer(black_image)) + else: + return False + + self._partial_refresh_count += 1 + logger.debug( + "Display updated (partial refresh %d/%d)", + self._partial_refresh_count, + self._full_refresh_interval, + ) + return True + except (AttributeError, TypeError): + return False + def _update_display( self, black_image: Image.Image, @@ -436,80 +465,75 @@ def _update_display( force_full_refresh: Force a full refresh instead of partial (eliminates ghosting) """ if not self._has_hardware or self._epd is None: - # In simulation mode, only save files if running in Docker - # On SBC without hardware, skip file writes to avoid excessive disk wear - if SystemUtils.is_docker_environment(): - simulator_dir = self.path_resolver.get_display_simulator_dir() - simulator_dir.mkdir(parents=True, exist_ok=True) - black_path = simulator_dir / "display_output_black.png" - black_image.save(black_path) - logger.debug("Display black layer saved to %s", black_path) - - if red_image: - red_path = simulator_dir / "display_output_red.png" - red_image.save(red_path) - logger.debug("Display red layer saved to %s", red_path) - - # Generate composite image showing final display output - composite = self._create_composite_image(black_image, red_image) - comp_path = simulator_dir / "display_output_comp.png" - composite.save(comp_path) - logger.debug("Display composite saved to %s", comp_path) - else: - logger.debug( - "Skipping simulation file writes on SBC (no hardware detected, not in Docker)" - ) + self._save_simulation_images(black_image, red_image) return try: - # Decide whether to use full or partial refresh - use_full_refresh = force_full_refresh or ( - self._partial_refresh_count >= self._full_refresh_interval - ) - - if use_full_refresh: - # Full refresh: clear screen completely (eliminates ghosting but flickers) - self._epd.init() # Reinit for full refresh mode - if self._is_color_display and red_image: - # 3-color display: send both black and red buffers - self._epd.display( - self._epd.getbuffer(black_image), self._epd.getbuffer(red_image) - ) - logger.debug("3-color display updated (full refresh)") - else: - # 2-color display: send only black buffer - self._epd.display(self._epd.getbuffer(black_image)) - logger.debug("2-color display updated (full refresh)") - - # Reset counter and prepare for partial refresh - self._partial_refresh_count = 0 - self._epd.init_part() # Switch to partial refresh mode + if self._is_color_display: + self._update_3color_display(black_image, red_image) else: - # Partial refresh: update only changed pixels (no flicker but can ghost) - # Try partial refresh even for 3-color displays (V4 may support it) - # Note: red layer won't update in partial mode, only black layer - try: - self._epd.displayPartial(self._epd.getbuffer(black_image)) - self._partial_refresh_count += 1 - logger.debug( - "Display updated (partial refresh %d/%d)", - self._partial_refresh_count, - self._full_refresh_interval, - ) - except (AttributeError, TypeError): - # Partial refresh not supported, fall back to full - logger.warning("Partial refresh failed, falling back to full refresh") - self._epd.init() - if self._is_color_display and red_image: - self._epd.display( - self._epd.getbuffer(black_image), self._epd.getbuffer(red_image) - ) - else: - self._epd.display(self._epd.getbuffer(black_image)) - self._partial_refresh_count = 0 # Reset to force full refresh mode + self._update_2color_display(black_image, force_full_refresh) except Exception: logger.exception("Failed to update e-paper display") + def _save_simulation_images( + self, black_image: Image.Image, red_image: Image.Image | None + ) -> None: + """Save display images in simulation mode.""" + if not SystemUtils.is_docker_environment(): + logger.debug( + "Skipping simulation file writes on SBC (no hardware detected, not in Docker)" + ) + return + + simulator_dir = self.path_resolver.get_display_simulator_dir() + simulator_dir.mkdir(parents=True, exist_ok=True) + + black_path = simulator_dir / "display_output_black.png" + black_image.save(black_path) + logger.debug("Display black layer saved to %s", black_path) + + if red_image: + red_path = simulator_dir / "display_output_red.png" + red_image.save(red_path) + logger.debug("Display red layer saved to %s", red_path) + + composite = self._create_composite_image(black_image, red_image) + comp_path = simulator_dir / "display_output_comp.png" + composite.save(comp_path) + logger.debug("Display composite saved to %s", comp_path) + + def _update_3color_display( + self, black_image: Image.Image, red_image: Image.Image | None + ) -> None: + """Update 3-color display (always uses full refresh).""" + assert self._epd is not None # Type narrowing for pyright + self._epd.init() + if red_image is None: + red_image = self._create_image() + self._epd.display(self._epd.getbuffer(black_image), self._epd.getbuffer(red_image)) + logger.debug("3-color display updated (full refresh)") + + def _update_2color_display(self, black_image: Image.Image, force_full_refresh: bool) -> None: + """Update 2-color display with partial refresh support.""" + assert self._epd is not None # Type narrowing for pyright + use_full_refresh = ( + force_full_refresh or self._partial_refresh_count >= self._full_refresh_interval + ) + + if use_full_refresh or not self._update_display_partial(black_image): + # Full refresh + self._epd.init() + self._epd.display(self._epd.getbuffer(black_image)) + logger.debug("Display updated (full refresh)") + + # Reset counter and switch to partial mode + self._partial_refresh_count = 0 + try: + self._epd.init_part() + except (AttributeError, OSError): + pass + async def _check_for_new_detection(self) -> bool: """Check if there's a new detection since last check. From 8e3342ef8843de2533b759baa2ba1c92f5853059 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Thu, 30 Oct 2025 14:47:20 -0400 Subject: [PATCH 40/48] feat: Add timezone support and update timing info to ePaper display - Display local time based on configured timezone (not UTC) - Show update interval and next update time on display - Swap health status and system stats rows for better layout - Convert all timestamps (current time, detections) to local timezone - Add timezone helper methods to reduce complexity - Track update timing for display on screen This ensures users see their local time on the display instead of UTC, and provides visibility into when the next refresh will occur. --- src/birdnetpi/display/epaper.py | 97 ++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 20 deletions(-) diff --git a/src/birdnetpi/display/epaper.py b/src/birdnetpi/display/epaper.py index 33fe3ee1..222e44f7 100644 --- a/src/birdnetpi/display/epaper.py +++ b/src/birdnetpi/display/epaper.py @@ -10,8 +10,9 @@ import asyncio import logging import uuid -from datetime import datetime +from datetime import UTC, datetime, timedelta from typing import Any, ClassVar +from zoneinfo import ZoneInfo import aiohttp import psutil @@ -78,6 +79,10 @@ def __init__( self._partial_refresh_count = 0 self._full_refresh_interval = 20 # Full refresh every N partial updates + # Update timing tracking + self._last_update_time: datetime | None = None + self._next_update_time: datetime | None = None + # Try to import Waveshare library based on config display_type = self.config.epaper_display_type module_name = self.DISPLAY_MODULES.get(display_type) @@ -271,20 +276,44 @@ def _draw_status_screen( draw = black_draw font_small = self._get_font(10) + font_tiny = self._get_font(8) font_medium = self._get_font(12) font_large = self._get_font(16) y_offset = 0 - # Header with site name and timestamp + # Header with site name and local time header_text = self.config.site_name[:20] # Limit length draw.text((2, y_offset), header_text, font=font_large, fill=self.COLOR_BLACK) - timestamp = datetime.now().strftime("%H:%M") + # Get timezone for converting UTC to local time + tz = self._get_local_timezone() + + # Convert current time to configured timezone + timestamp = self._format_time_in_timezone(datetime.now(UTC), tz, "%H:%M") draw.text((200, y_offset), timestamp, font=font_medium, fill=self.COLOR_BLACK) y_offset += 18 - # System stats + # Update timing info (small text) + update_info = f"Updates: {self.config.epaper_refresh_interval}s" + if self._next_update_time: + next_time_str = self._format_time_in_timezone(self._next_update_time, tz) + update_info += f" | Next: {next_time_str}" + draw.text((2, y_offset), update_info, font=font_tiny, fill=self.COLOR_BLACK) + y_offset += 10 + + # Health status (swapped with system stats) + health_symbol = "✓" if health.get("status") == "ready" else "✗" + db_symbol = "✓" if health.get("database") else "✗" + draw.text( + (2, y_offset), + f"Health: {health_symbol} DB: {db_symbol}", + font=font_small, + fill=self.COLOR_BLACK, + ) + y_offset += 12 + + # System stats (swapped with health status) draw.text( (2, y_offset), f"CPU: {stats['cpu_percent']:.1f}%", @@ -303,17 +332,6 @@ def _draw_status_screen( font=font_small, fill=self.COLOR_BLACK, ) - y_offset += 12 - - # Health status - health_symbol = "✓" if health.get("status") == "ready" else "✗" - db_symbol = "✓" if health.get("database") else "✗" - draw.text( - (2, y_offset), - f"Health: {health_symbol} DB: {db_symbol}", - font=font_small, - fill=self.COLOR_BLACK, - ) y_offset += 14 # Separator line @@ -377,7 +395,8 @@ def _draw_status_screen( # Confidence and time - always black confidence_text = f"{detection.confidence * 100:.1f}%" - time_text = detection.timestamp.strftime("%H:%M:%S") + # Convert detection time to local timezone (reuse tz from above) + time_text = self._format_time_in_timezone(detection.timestamp, tz) draw.text( (2, y_offset), f"{confidence_text} at {time_text}", @@ -394,6 +413,37 @@ def _draw_status_screen( return black_image, red_image + def _get_local_timezone(self) -> ZoneInfo | None: + """Get the configured timezone for display conversions. + + Returns: + ZoneInfo object for the configured timezone, or None if invalid + """ + try: + return ZoneInfo(self.config.timezone) + except Exception: + return None + + def _format_time_in_timezone( + self, dt: datetime, tz: ZoneInfo | None, fmt: str = "%H:%M:%S" + ) -> str: + """Format a datetime in the configured timezone. + + Args: + dt: Datetime to format (should be timezone-aware) + tz: Timezone to convert to (None for system time) + fmt: strftime format string + + Returns: + Formatted time string + """ + if tz: + try: + return dt.astimezone(tz).strftime(fmt) + except Exception: + pass + return dt.strftime(fmt) + def _create_composite_image( self, black_image: Image.Image, red_image: Image.Image | None ) -> Image.Image: @@ -578,6 +628,16 @@ async def _display_loop(self) -> None: animation_frame = 12 - self._animation_frames self._animation_frames -= 1 + # Track update timing + self._last_update_time = datetime.now(UTC) + + # Calculate next update time + if self._animation_frames > 0: + sleep_duration = 2 # Fast refresh during animation + else: + sleep_duration = self.config.epaper_refresh_interval + self._next_update_time = self._last_update_time + timedelta(seconds=sleep_duration) + # Draw and update display black_image, red_image = self._draw_status_screen( stats, health, detection, show_animation, animation_frame @@ -591,10 +651,7 @@ async def _display_loop(self) -> None: # Use faster refresh during animation (2 seconds) for better visibility # Normal refresh otherwise (default 30 seconds) to preserve e-paper lifespan - if self._animation_frames > 0: - await asyncio.sleep(2) # Fast refresh during animation - else: - await asyncio.sleep(self.config.epaper_refresh_interval) + await asyncio.sleep(sleep_duration) except Exception: logger.exception("Error in display loop") From 195d485752ae49dc5e3e7edabe8f8026543508c6 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Thu, 30 Oct 2025 14:49:35 -0400 Subject: [PATCH 41/48] feat: Set system timezone from config during setup - Add set_system_timezone() function to configure system time - Uses timedatectl to set timezone based on config.timezone - Validates timezone against pytz before applying - Called automatically during initial system setup - Ensures system logs and timestamps match user's timezone This fixes the issue where the system shows UTC even when the user configured a different timezone like America/Toronto. --- src/birdnetpi/cli/setup_system.py | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/birdnetpi/cli/setup_system.py b/src/birdnetpi/cli/setup_system.py index f3f8d516..c34cebf8 100644 --- a/src/birdnetpi/cli/setup_system.py +++ b/src/birdnetpi/cli/setup_system.py @@ -10,6 +10,7 @@ """ import sqlite3 +import subprocess import sys from pathlib import Path from typing import Any @@ -426,6 +427,42 @@ def initialize_config( return config_path, config, boot_config +def set_system_timezone(config: BirdNETConfig) -> None: + """Set the system timezone based on the configuration. + + Uses timedatectl to set the system timezone to match the configured timezone. + This ensures that system logs and timestamps match the user's expected timezone. + + Args: + config: Configuration containing the timezone setting + """ + timezone = config.timezone + if not timezone or timezone == "UTC": + click.echo(" Timezone is UTC (default), skipping system timezone update") + return + + try: + # Validate timezone exists in pytz + if timezone not in pytz.all_timezones: + click.echo(f" ! Invalid timezone '{timezone}', skipping system timezone update") + return + + # Set system timezone using timedatectl + result = subprocess.run( + ["timedatectl", "set-timezone", timezone], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode == 0: + click.echo(f" ✓ System timezone set to {timezone}") + else: + click.echo(f" ! Failed to set system timezone: {result.stderr.strip()}") + except Exception as e: + click.echo(f" ! Error setting system timezone: {e}") + + @click.command() @click.option( "--non-interactive", @@ -480,6 +517,11 @@ def main(non_interactive: bool) -> None: config_manager.save(config) click.echo(f" ✓ Configuration saved to {config_path}") + # Set system timezone to match config + click.echo() + click.echo("Setting system timezone...") + set_system_timezone(config) + click.echo() click.echo("=" * 60) click.echo("System setup complete!") From f96995d469bb110472c4641215654bf5f3515899 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sat, 1 Nov 2025 21:31:26 -0400 Subject: [PATCH 42/48] feat: Enable installer to run as root user - Add sudo-to-su conversion in install.sh for root execution - Add subprocess wrapper in setup_app.py to strip sudo when root - Add build-essential and python3.11-dev for compiling C extensions - Add piwheels.org as extra index for pre-built ARM wheels - Increase UV_HTTP_TIMEOUT to 300s to prevent timeout on slow connections - Handle environment variables in sudo wrapper function This allows the installer to work on systems where only root access is available (e.g., DietPi fresh installs) while maintaining compatibility with non-root users who have sudo privileges. --- install/install.sh | 59 +++++++++++++++++++++++++++++++++++++++----- install/setup_app.py | 25 +++++++++++++++---- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/install/install.sh b/install/install.sh index 092d877d..39dc2aa8 100644 --- a/install/install.sh +++ b/install/install.sh @@ -22,10 +22,57 @@ if [ "$1" = "--test-epaper" ]; then TEST_EPAPER=true fi -# Check if running as root +# Check if running as root - convert sudo to su if [ "$(id -u)" -eq 0 ]; then - echo "This script should not be run as root. Please run as a non-root user with sudo privileges." - exit 1 + echo "Running as root - converting sudo commands to su" + sudo() { + local user="" + local env_vars=() + local cmd=() + + while [[ $# -gt 0 ]]; do + case "$1" in + -u|--user) + user="$2" + shift 2 + ;; + -g|--group) + # Skip group flag (su doesn't support it the same way) + shift 2 + ;; + -*) + # Skip other sudo flags + shift + ;; + *=*) + # Environment variable assignment + env_vars+=("$1") + shift + ;; + *) + # Rest are command arguments + cmd=("$@") + break + ;; + esac + done + + if [ -n "$user" ]; then + if [ ${#env_vars[@]} -gt 0 ]; then + su - "$user" -c "env ${env_vars[*]} ${cmd[*]}" + else + su - "$user" -c "${cmd[*]}" + fi + else + # No user specified, run as root with env vars if present + if [ ${#env_vars[@]} -gt 0 ]; then + env "${env_vars[@]}" "${cmd[@]}" + else + "${cmd[@]}" + fi + fi + } + export -f sudo fi echo "========================================" @@ -73,7 +120,7 @@ fi # Bootstrap the environment echo "Installing prerequisites..." sudo apt-get update -sudo apt-get install -y git python3.11 python3.11-venv python3-pip +sudo apt-get install -y git python3.11 python3.11-venv python3-pip build-essential python3.11-dev # Wait for DNS to settle after apt operations sleep 2 @@ -166,7 +213,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 /opt/uv/uv lock --quiet + sudo -u birdnetpi UV_HTTP_TIMEOUT=300 /opt/uv/uv lock --quiet echo "✓ Configured to use local Waveshare library" fi @@ -174,7 +221,7 @@ fi # Install Python dependencies with retry mechanism (for network issues) echo "Installing Python dependencies..." cd "$INSTALL_DIR" -UV_CMD="sudo -u birdnetpi /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 --quiet" if [ -n "$EPAPER_EXTRAS" ]; then UV_CMD="$UV_CMD $EPAPER_EXTRAS" fi diff --git a/install/setup_app.py b/install/setup_app.py index f07275e8..8e86ff4c 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -9,7 +9,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from pathlib import Path -from typing import TypedDict +from typing import Any, TypedDict from jinja2 import Template @@ -685,11 +685,26 @@ def show_final_summary(ip_address: str) -> None: def main() -> None: """Run the main installer with parallel execution.""" - # Check not running as root + # When running as root, strip "sudo" from subprocess commands + global subprocess if os.geteuid() == 0: - print("ERROR: This script should not be run as root.") - print("Please run as a non-root user with sudo privileges.") - sys.exit(1) + print("Running as root - sudo commands will execute directly") + original_subprocess = subprocess + + class SubprocessWrapper: + """Wrapper to strip 'sudo' from commands when running as root.""" + + @staticmethod + def run(cmd: list[str] | str, **kwargs: Any) -> subprocess.CompletedProcess: # type: ignore[misc] + # Strip 'sudo' from command if present + if isinstance(cmd, list) and cmd and cmd[0] == "sudo": + cmd = cmd[1:] + return original_subprocess.run(cmd, **kwargs) + + def __getattr__(self, name: str) -> Any: + return getattr(original_subprocess, name) + + subprocess = SubprocessWrapper() print() print("=" * 60) From 4f20a5fafc63cbe9be60650db651a7777a1e24f4 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sat, 1 Nov 2025 21:51:24 -0400 Subject: [PATCH 43/48] fix: Properly strip sudo flags in subprocess wrapper The previous implementation only stripped 'sudo' but left flags like '-u' which caused '[Errno 2] No such file or directory: -u' errors. Now properly strips: - sudo command itself - User/group flags (-u, --user, -g, --group) and their arguments - Other sudo flags Also moved wrapper class outside main() to reduce complexity. --- install/setup_app.py | 48 +++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/install/setup_app.py b/install/setup_app.py index 8e86ff4c..40c5e9cf 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -683,28 +683,44 @@ def show_final_summary(ip_address: str) -> None: print("=" * 60) +class _SubprocessWrapper: + """Wrapper to strip 'sudo' from commands when running as root.""" + + def __init__(self, original_subprocess: Any) -> None: + self._original = original_subprocess + + def run(self, cmd: list[str] | str, **kwargs: Any) -> subprocess.CompletedProcess: # type: ignore[misc] + """Run command, stripping sudo flags when running as root.""" + # Strip 'sudo' and its flags from command if present + if isinstance(cmd, list) and cmd and cmd[0] == "sudo": + # Remove "sudo" and any user-related flags + new_cmd = [] + i = 1 # Skip "sudo" + while i < len(cmd): + if cmd[i] in ("-u", "--user", "-g", "--group"): + # Skip flag and its argument + i += 2 + elif cmd[i].startswith("-"): + # Skip other flags + i += 1 + else: + # Found the actual command + new_cmd = cmd[i:] + break + cmd = new_cmd if new_cmd else cmd[1:] + return self._original.run(cmd, **kwargs) + + def __getattr__(self, name: str) -> Any: + return getattr(self._original, name) + + def main() -> None: """Run the main installer with parallel execution.""" # When running as root, strip "sudo" from subprocess commands global subprocess if os.geteuid() == 0: print("Running as root - sudo commands will execute directly") - original_subprocess = subprocess - - class SubprocessWrapper: - """Wrapper to strip 'sudo' from commands when running as root.""" - - @staticmethod - def run(cmd: list[str] | str, **kwargs: Any) -> subprocess.CompletedProcess: # type: ignore[misc] - # Strip 'sudo' from command if present - if isinstance(cmd, list) and cmd and cmd[0] == "sudo": - cmd = cmd[1:] - return original_subprocess.run(cmd, **kwargs) - - def __getattr__(self, name: str) -> Any: - return getattr(original_subprocess, name) - - subprocess = SubprocessWrapper() + subprocess = _SubprocessWrapper(subprocess) print() print("=" * 60) From e47ef410446088dd88be6eb079fff75016bc0931 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sat, 1 Nov 2025 22:08:23 -0400 Subject: [PATCH 44/48] fix: Convert sudo -u to su when running setup_app.py as root The install_assets() function uses 'sudo -u birdnetpi' to ensure files are created with correct ownership. When setup_app.py runs as root, we need to convert these commands to 'su - birdnetpi -c "..."' to maintain proper file ownership. Changes: - Add subprocess wrapper that converts sudo -u commands to su - Properly parse sudo flags (-u, --user, -g, --group) - Use shlex.quote to safely construct su command strings - Require root privileges (needed for systemctl, apt-get, etc.) --- install/setup_app.py | 48 +++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/install/setup_app.py b/install/setup_app.py index 40c5e9cf..130f17d5 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -1,6 +1,7 @@ """BirdNET-Pi SBC installer with parallel execution.""" import os +import shlex import socket import subprocess import sys @@ -684,30 +685,40 @@ def show_final_summary(ip_address: str) -> None: class _SubprocessWrapper: - """Wrapper to strip 'sudo' from commands when running as root.""" + """Wrapper to convert sudo to su when running as root.""" def __init__(self, original_subprocess: Any) -> None: self._original = original_subprocess def run(self, cmd: list[str] | str, **kwargs: Any) -> subprocess.CompletedProcess: # type: ignore[misc] - """Run command, stripping sudo flags when running as root.""" - # Strip 'sudo' and its flags from command if present + """Run command, converting sudo -u to su when running as root.""" if isinstance(cmd, list) and cmd and cmd[0] == "sudo": - # Remove "sudo" and any user-related flags - new_cmd = [] - i = 1 # Skip "sudo" + user = None + actual_cmd = [] + i = 1 + + # Parse sudo arguments while i < len(cmd): - if cmd[i] in ("-u", "--user", "-g", "--group"): - # Skip flag and its argument + if cmd[i] in ("-u", "--user"): + user = cmd[i + 1] i += 2 + elif cmd[i] in ("-g", "--group"): + i += 2 # Skip group flag elif cmd[i].startswith("-"): - # Skip other flags - i += 1 + i += 1 # Skip other flags else: # Found the actual command - new_cmd = cmd[i:] + actual_cmd = cmd[i:] break - cmd = new_cmd if new_cmd else cmd[1:] + + if user: + # Convert to: su - user -c "command args..." + cmd_str = " ".join(shlex.quote(arg) for arg in actual_cmd) + cmd = ["su", "-", user, "-c", cmd_str] + else: + # No user, just run directly (strip sudo) + cmd = actual_cmd if actual_cmd else cmd[1:] + return self._original.run(cmd, **kwargs) def __getattr__(self, name: str) -> Any: @@ -716,11 +727,16 @@ def __getattr__(self, name: str) -> Any: def main() -> None: """Run the main installer with parallel execution.""" - # When running as root, strip "sudo" from subprocess commands global subprocess - if os.geteuid() == 0: - print("Running as root - sudo commands will execute directly") - subprocess = _SubprocessWrapper(subprocess) + + # Verify running as root (needed for systemctl, apt-get, etc.) + if os.geteuid() != 0: + print("ERROR: This script must be run as root.") + print("The install.sh script should handle running this with appropriate privileges.") + sys.exit(1) + + # When running as root, convert sudo commands to su + subprocess = _SubprocessWrapper(subprocess) print() print("=" * 60) From 98c4f6349bcccb80871e948062e3ac1c9d1e875b Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sat, 1 Nov 2025 22:13:59 -0400 Subject: [PATCH 45/48] fix: Add DBus fallback for timezone setting on DietPi DietPi doesn't have DBus available during installation, so timedatectl fails with 'Failed to connect to bus'. Add a fallback that directly manipulates /etc/timezone and /etc/localtime when DBus is unavailable. Fallback method: - Write timezone to /etc/timezone - Symlink /etc/localtime to /usr/share/zoneinfo/{timezone} This ensures timezone configuration works on minimal systems like DietPi while still preferring timedatectl on full systems. --- src/birdnetpi/cli/setup_system.py | 162 +++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 1 deletion(-) diff --git a/src/birdnetpi/cli/setup_system.py b/src/birdnetpi/cli/setup_system.py index c34cebf8..bccb5ab0 100644 --- a/src/birdnetpi/cli/setup_system.py +++ b/src/birdnetpi/cli/setup_system.py @@ -149,6 +149,90 @@ def is_attended_install() -> bool: return sys.stdin.isatty() +def get_supported_os_options() -> dict[str, str]: + """Get supported operating systems. + + Returns: + Dict mapping OS keys to display names + """ + return { + "raspbian": "Raspberry Pi OS", + "armbian": "Armbian", + "dietpi": "DietPi", + } + + +def get_supported_devices() -> dict[str, str]: + """Get supported device types. + + Returns: + Dict mapping device keys to display names + """ + return { + "pi_zero_2w": "Raspberry Pi Zero 2W", + "pi_3b": "Raspberry Pi 3B/3B+", + "pi_4b": "Raspberry Pi 4B", + "pi_5": "Raspberry Pi 5", + "orange_pi_5": "Orange Pi 5", + "orange_pi_5_plus": "Orange Pi 5 Plus", + "orange_pi_5_pro": "Orange Pi 5 Pro", + "rock_5b": "Radxa ROCK 5B", + "other": "Other (generic configuration)", + } + + +def prompt_os_selection(default: str = "raspbian") -> str: + """Prompt user to select an operating system. + + Args: + default: Default OS key + + Returns: + Selected OS key + """ + os_options = get_supported_os_options() + + click.echo("\nSupported Operating Systems:") + click.echo("-" * 60) + for key, name in os_options.items(): + marker = "(default)" if key == default else "" + click.echo(f" {key:12} - {name} {marker}") + + while True: + os_key = click.prompt("\nOperating System", default=default, show_default=True) + if os_key in os_options: + return os_key + else: + click.echo(f" ✗ Invalid OS: {os_key}") + click.echo(f" Please enter one of: {', '.join(os_options.keys())}") + + +def prompt_device_selection(default: str = "pi_4b") -> str: + """Prompt user to select a device type. + + Args: + default: Default device key + + Returns: + Selected device key + """ + devices = get_supported_devices() + + click.echo("\nSupported Devices:") + click.echo("-" * 60) + for key, name in devices.items(): + marker = "(default)" if key == default else "" + click.echo(f" {key:18} - {name} {marker}") + + while True: + device_key = click.prompt("\nDevice Type", default=default, show_default=True) + if device_key in devices: + return device_key + else: + click.echo(f" ✗ Invalid device: {device_key}") + click.echo(f" Please enter one of: {', '.join(devices.keys())}") + + def get_common_timezones() -> list[str]: """Get list of common timezones for user selection. @@ -370,6 +454,49 @@ def configure_location( click.echo(loc_msg) +def configure_os( + boot_config: dict[str, str], +) -> str: + """Configure operating system via prompt or boot config. + + Args: + boot_config: Boot volume pre-configuration + + Returns: + Selected OS key + """ + if "os" not in boot_config: + os_key = prompt_os_selection(default="raspbian") + click.echo(f" Selected OS: {get_supported_os_options()[os_key]}") + return os_key + else: + os_key = boot_config["os"] + click.echo(f"OS: {get_supported_os_options().get(os_key, os_key)} (from boot config)") + return os_key + + +def configure_device( + boot_config: dict[str, str], +) -> str: + """Configure device type via prompt or boot config. + + Args: + boot_config: Boot volume pre-configuration + + Returns: + Selected device key + """ + if "device" not in boot_config: + device_key = prompt_device_selection(default="pi_4b") + click.echo(f" Selected device: {get_supported_devices()[device_key]}") + return device_key + else: + device_key = boot_config["device"] + device_name = get_supported_devices().get(device_key, device_key) + click.echo(f"Device: {device_name} (from boot config)") + return device_key + + def configure_language( config: BirdNETConfig, boot_config: dict[str, str], @@ -447,7 +574,7 @@ def set_system_timezone(config: BirdNETConfig) -> None: click.echo(f" ! Invalid timezone '{timezone}', skipping system timezone update") return - # Set system timezone using timedatectl + # Try timedatectl first (requires systemd/DBus) result = subprocess.run( ["timedatectl", "set-timezone", timezone], capture_output=True, @@ -457,6 +584,28 @@ def set_system_timezone(config: BirdNETConfig) -> None: if result.returncode == 0: click.echo(f" ✓ System timezone set to {timezone}") + elif "Failed to connect to bus" in result.stderr: + # Fallback for systems without DBus (e.g., DietPi during installation) + # Directly set timezone files + try: + # Write timezone to /etc/timezone + with Path("/etc/timezone").open("w") as f: + f.write(f"{timezone}\n") + + # Link /etc/localtime to the zoneinfo file + zoneinfo_path = Path(f"/usr/share/zoneinfo/{timezone}") + localtime_path = Path("/etc/localtime") + + if zoneinfo_path.exists(): + # Remove old symlink/file + localtime_path.unlink(missing_ok=True) + # Create new symlink + localtime_path.symlink_to(zoneinfo_path) + click.echo(f" ✓ System timezone set to {timezone} (fallback method)") + else: + click.echo(f" ! Timezone file not found: {zoneinfo_path}") + except Exception as fallback_error: + click.echo(f" ! Failed to set timezone (fallback): {fallback_error}") else: click.echo(f" ! Failed to set system timezone: {result.stderr.strip()}") except Exception as e: @@ -507,6 +656,17 @@ def main(non_interactive: bool) -> None: click.echo("Configuration Prompts") click.echo("-" * 60) + # OS and device selection first + os_key = configure_os(boot_config) + device_key = configure_device(boot_config) + + # Store OS and device info in config for reference + # Note: These aren't part of BirdNETConfig model, but we store them + # for future use (e.g., OS-specific optimizations, device-specific settings) + os_name = get_supported_os_options()[os_key] + device_name = get_supported_devices()[device_key] + click.echo(f"\nConfiguring for {os_name} on {device_name}") + configure_device_name(config, boot_config) configure_location(config, boot_config, lat_detected) configure_language(config, boot_config, path_resolver) From 55aa72af986b5f097a6c1f6c37846a7bee0986d0 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sat, 1 Nov 2025 22:44:56 -0400 Subject: [PATCH 46/48] refactor: Add ServiceRegistry to centralize service management Problem: Service lists were hardcoded in 3 different places, causing the epaper display service to be installed but not shown in status output. Solution: Create ServiceRegistry class with: - SYSTEM_SERVICES: Redis, Caddy (not managed by BirdNET) - CORE_SERVICES: Always-installed BirdNET services - _optional_services: Runtime-detected services (e.g., epaper) Benefits: - Single source of truth for all services - Epaper service now shows in final status output - Easy to add new optional services in the future - No more duplicate service lists to maintain Changes: - Add ServiceRegistry.add_optional_service() for runtime registration - Register epaper service when hardware is detected - Replace hardcoded lists in start_services(), check_services_health(), and show_final_summary() with ServiceRegistry methods --- install/setup_app.py | 89 ++++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/install/setup_app.py b/install/setup_app.py index 130f17d5..22090b4d 100644 --- a/install/setup_app.py +++ b/install/setup_app.py @@ -10,7 +10,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from pathlib import Path -from typing import Any, TypedDict +from typing import Any, ClassVar, TypedDict from jinja2 import Template @@ -24,6 +24,41 @@ class DeviceSpecs(TypedDict): memory_comment: str +class ServiceRegistry: + """Central registry for system and BirdNET services.""" + + # System services (not managed by BirdNET) + SYSTEM_SERVICES: ClassVar[list[str]] = ["redis-server.service", "caddy.service"] + + # Core BirdNET services (always installed) + CORE_SERVICES: ClassVar[list[str]] = [ + "birdnetpi-fastapi.service", + "birdnetpi-audio-capture.service", + "birdnetpi-audio-analysis.service", + "birdnetpi-audio-websocket.service", + "birdnetpi-update.service", + ] + + # Optional services (added at runtime based on hardware detection) + _optional_services: ClassVar[list[str]] = [] + + @classmethod + def add_optional_service(cls, service_name: str) -> None: + """Add an optional service to the registry.""" + if service_name not in cls._optional_services: + cls._optional_services.append(service_name) + + @classmethod + def get_birdnet_services(cls) -> list[str]: + """Get all BirdNET services (core + optional).""" + return cls.CORE_SERVICES + cls._optional_services + + @classmethod + def get_all_services(cls) -> list[str]: + """Get all services (system + BirdNET).""" + return cls.SYSTEM_SERVICES + cls.get_birdnet_services() + + # Thread-safe logging _log_lock = threading.Lock() @@ -507,15 +542,16 @@ def install_systemd_services() -> None: # Conditionally add epaper display service if hardware detected if has_waveshare_epaper_hat(): log("ℹ", "Installing epaper display service (hardware detected)") # noqa: RUF001 - services.append( - { - "name": "birdnetpi-epaper-display.service", - "description": "BirdNET E-Paper Display", - "after": "network-online.target birdnetpi-fastapi.service", - "exec_start": "/opt/birdnetpi/.venv/bin/epaper-display-daemon", - "environment": "PYTHONPATH=/opt/birdnetpi/src SERVICE_NAME=epaper_display", - } - ) + service_config = { + "name": "birdnetpi-epaper-display.service", + "description": "BirdNET E-Paper Display", + "after": "network-online.target birdnetpi-fastapi.service", + "exec_start": "/opt/birdnetpi/.venv/bin/epaper-display-daemon", + "environment": "PYTHONPATH=/opt/birdnetpi/src SERVICE_NAME=epaper_display", + } + services.append(service_config) + # Register service in the global registry + ServiceRegistry.add_optional_service(service_config["name"]) else: log("ℹ", "Skipping epaper display service (no hardware detected)") # noqa: RUF001 @@ -582,14 +618,7 @@ def start_systemd_services() -> None: ) # Start BirdNET services - birdnet_services = [ - "birdnetpi-fastapi.service", - "birdnetpi-audio-capture.service", - "birdnetpi-audio-analysis.service", - "birdnetpi-audio-websocket.service", - "birdnetpi-update.service", - ] - for service in birdnet_services: + for service in ServiceRegistry.get_birdnet_services(): subprocess.run( ["sudo", "systemctl", "start", service], check=True, @@ -625,18 +654,8 @@ def check_service_status(service_name: str) -> str: def check_services_health() -> None: """Check that all services are running and healthy.""" - services = [ - "redis-server.service", - "caddy.service", - "birdnetpi-fastapi.service", - "birdnetpi-audio-capture.service", - "birdnetpi-audio-analysis.service", - "birdnetpi-audio-websocket.service", - "birdnetpi-update.service", - ] - all_healthy = True - for service in services: + for service in ServiceRegistry.get_all_services(): status = check_service_status(service) if status != "✓": all_healthy = False @@ -659,18 +678,8 @@ def show_final_summary(ip_address: str) -> None: print() # Show service status - services = [ - "redis-server.service", - "caddy.service", - "birdnetpi-fastapi.service", - "birdnetpi-audio-capture.service", - "birdnetpi-audio-analysis.service", - "birdnetpi-audio-websocket.service", - "birdnetpi-update.service", - ] - print("Service Status:") - for service in services: + for service in ServiceRegistry.get_all_services(): status = check_service_status(service) print(f" {status} {service}") From 1afe7a8d25252a06d599ce1135698664813bf115 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 01:06:21 -0500 Subject: [PATCH 47/48] refactor: Complete SD card flasher TUI with profile management Implement comprehensive TUI using Textual framework for SD card flashing: Profile Management: - Load existing profiles with pre-filled values when editing - Save new profiles with custom names - Track profile state (loaded vs new) throughout wizard flow - Pre-populate profile name when editing existing profile Wizard Flow Improvements: - Fixed edit workflow to restart from OS selection (full config edit) - Only show ProfileSaveScreen for new configurations, not loaded profiles - Added scrolling support for content that exceeds terminal height - Pre-select all inputs (OS, device, network, etc.) when editing Select Widget Integration: - Fixed Textual Select behavior (uses display text, not keys) - Implemented reverse lookup pattern for all Select widgets - Show friendly names ("Raspberry Pi OS") in dropdowns, not keys ("raspbian") - Consistent display name handling across all screens Device Selection Integration: - Replaced Rich prompts with Textual TUI screens - DeviceSelectionForFlashScreen for SD card selection - ConfirmFlashScreen for flash confirmation - Standalone DeviceSelectionApp for post-configuration device selection DietPi Branch Override Support: - Added repository URL and branch fields to BirdNETConfigScreen - Modified preserve_installer.sh to source environment variables - Support BIRDNETPI_REPO_URL and BIRDNETPI_BRANCH for testing - Automatic DietPi install.sh preservation regardless of checkbox Bug Fixes: - Fixed KeyError when displaying final summary (lookup from OS_IMAGES) - Fixed unused variable warnings (renamed to _key, _idx) - Fixed profile name not pre-populating when editing - Fixed SPI option not showing for Pi 4 + Raspberry Pi OS - Removed unnecessary original_name variable causing save workflow bug Code Quality: - Added noqa comments for unavoidable complexity in UI composition - Fixed line length issues with URL/SHA256 formatting - Added missing docstring parameters - Improved comment clarity and formatting --- install/flash_sdcard.py | 1719 ++++++++++++++++++++++++++++++--------- install/flasher.tcss | 142 ++++ install/flasher_tui.py | 1408 ++++++++++++++++++++++++++++++++ 3 files changed, 2864 insertions(+), 405 deletions(-) create mode 100644 install/flasher.tcss create mode 100644 install/flasher_tui.py diff --git a/install/flash_sdcard.py b/install/flash_sdcard.py index 65b0e193..50da7c69 100755 --- a/install/flash_sdcard.py +++ b/install/flash_sdcard.py @@ -5,6 +5,7 @@ # "click>=8.1.0", # "rich>=13.0.0", # "requests>=2.31.0", +# "textual>=0.47.0", # ] # /// """Flash Raspberry Pi OS to SD card and configure for BirdNET-Pi installation. @@ -24,11 +25,15 @@ import subprocess import sys import time +from collections import OrderedDict from pathlib import Path from typing import Any import click # type: ignore[import-untyped] import requests # type: ignore[import-untyped] + +# Import TUI module +from flasher_tui import FlasherWizardApp from rich.console import Console # type: ignore[import-untyped] from rich.panel import Panel # type: ignore[import-untyped] from rich.progress import ( # type: ignore[import-untyped] @@ -38,7 +43,7 @@ TextColumn, TimeElapsedColumn, ) -from rich.prompt import Confirm, Prompt # type: ignore[import-untyped] +from rich.prompt import Prompt # type: ignore[import-untyped] from rich.table import Table # type: ignore[import-untyped] console = Console() @@ -97,41 +102,452 @@ def find_command(cmd: str, homebrew_paths: list[str] | None = None) -> str: return cmd -# Raspberry Pi OS and Armbian image URLs (Lite/Minimal versions for headless server) -PI_IMAGES = { - "Pi 5": { - "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", +# OS Properties +# Defines intrinsic capabilities of each operating system +OS_PROPERTIES = { + "raspbian": { + "wifi_config_method": "networkmanager", # firstrun.sh uses NetworkManager + "user_config_method": "userconf", # userconf.txt + "spi_config_method": "config_txt", # config.txt dtparam=spi=on + "install_sh_path": "/boot/firmware/install.sh", + "install_sh_needs_preservation": False, # Boot partition persists + }, + "armbian": { + "wifi_config_method": "netplan", # systemd-networkd on minimal images + "user_config_method": "not_logged_in_yet", # .not_logged_in_yet file + "spi_config_method": None, # TODO: research Armbian SPI + "install_sh_path": "/boot/install.sh", + "install_sh_needs_preservation": False, # ext4 partition persists + }, + "dietpi": { + "wifi_config_method": "dietpi_wifi", # dietpi-wifi.txt + "user_config_method": "root_only", # Only root password via AUTO_SETUP_GLOBAL_PASSWORD + "spi_config_method": "config_txt_device_dependent", # config.txt for RPi, overlays for SBCs + "install_sh_path": "/root/install.sh", + "install_sh_needs_preservation": True, # DIETPISETUP partition deleted after first boot + }, +} + +# Device Properties +# Defines intrinsic hardware capabilities of each device +DEVICE_PROPERTIES = { + "pi_zero_2w": { + "has_wifi": True, + "has_spi": True, + }, + "pi_3": { + "has_wifi": True, + "has_spi": True, + }, + "pi_4": { + "has_wifi": True, + "has_spi": True, + }, + "pi_5": { + "has_wifi": True, + "has_spi": True, + }, + "le_potato": { + "has_wifi": False, # No WiFi hardware + "has_spi": True, # GPIO header supports SPI + }, + "orangepi5": { + "has_wifi": True, # Built-in WiFi + "has_spi": True, + }, + "orangepi5plus": { + "has_wifi": True, # Built-in WiFi + "has_spi": True, }, - "Pi 4": { - "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", + "orangepi5pro": { + "has_wifi": True, # Built-in WiFi + "has_spi": True, }, - "Pi 3": { - # Pi 3B+ and newer support 64-bit - using arm64 for ai-edge-litert compatibility - "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", + "rock5b": { + "has_wifi": True, # M.2 WiFi module support + "has_spi": True, }, - "Pi Zero 2 W": { - # Zero 2 W has same BCM2710A1 as Pi 3 - supports 64-bit - "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", +} + + +def get_combined_capabilities(os_key: str, device_key: str) -> 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") + + Returns: + Dictionary of combined capabilities + """ + os_props = OS_PROPERTIES.get(os_key, {}) + device_props = DEVICE_PROPERTIES.get(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) + ), + # 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) + ), + # 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), + "wifi_config_method": os_props.get("wifi_config_method"), + "user_config_method": os_props.get("user_config_method"), + "spi_config_method": os_props.get("spi_config_method"), + } + + +def copy_installer_script( + boot_mount: Path, + config: dict[str, Any], + os_key: str, + device_key: str, +) -> None: + """Copy install.sh to boot partition with OS-specific handling. + + Args: + boot_mount: Path to mounted boot partition + config: Configuration dictionary with copy_installer flag + os_key: OS type for capability lookup + device_key: Device key for capability lookup + """ + caps = get_combined_capabilities(os_key, device_key) + + # For OSes that need preservation (DietPi), always copy the installer + # 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 + + install_script = Path(__file__).parent / "install.sh" + if not install_script.exists(): + console.print("[yellow]Warning: install.sh not found, skipping copy[/yellow]") + return + + install_dest = boot_mount / "install.sh" + + # Copy install.sh to boot partition + subprocess.run(["sudo", "cp", str(install_script), str(install_dest)], check=True) + subprocess.run(["sudo", "chmod", "+x", str(install_dest)], check=True) + + # For OSes that need preservation (DietPi), create wrapper script + if caps.get("install_sh_needs_preservation"): + final_path = caps.get("install_sh_path", "/root/install.sh") + preserve_script_content = f"""#!/bin/bash +# Preserve and execute install.sh before/after DIETPISETUP partition is deleted +# This script runs during DietPi first boot automation + +# Try /boot/firmware first (Raspberry Pi), then /boot (other boards) +if [ -f /boot/firmware/install.sh ]; then + 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 + cp /boot/install.sh {final_path} + chmod +x {final_path} + echo "Preserved install.sh from /boot/ to {final_path}" +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/" +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 +fi +""" + preserve_script_path = boot_mount / "preserve_installer.sh" + temp_preserve = Path("/tmp/preserve_installer.sh") + temp_preserve.write_text(preserve_script_content) + 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]") + else: + final_path = caps.get("install_sh_path", "/boot/install.sh") + console.print(f"[green]✓ Copied install.sh to {final_path}[/green]") + + +def copy_birdnetpi_config( + boot_mount: Path, + config: dict[str, Any], +) -> 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 + """ + # Build config lines from available settings + config_lines = ["# BirdNET-Pi boot configuration"] + has_config = False + + # 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 + + # Application settings + if config.get("birdnet_device_name"): + config_lines.append(f"device_name={config['birdnet_device_name']}") + has_config = True + + if config.get("birdnet_latitude"): + config_lines.append(f"latitude={config['birdnet_latitude']}") + has_config = True + + if config.get("birdnet_longitude"): + config_lines.append(f"longitude={config['birdnet_longitude']}") + has_config = True + + if config.get("birdnet_timezone"): + config_lines.append(f"timezone={config['birdnet_timezone']}") + has_config = True + + if config.get("birdnet_language"): + config_lines.append(f"language={config['birdnet_language']}") + has_config = True + + # 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") + subprocess.run( + ["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]") + + +# OS and device image URLs (Lite/Minimal versions for headless server) +# Organized by OS type, then by device +# Device ordering: 0-5 reserved for official Raspberry Pi models, then alphabetical +OS_IMAGES = { + "raspbian": { + "name": "Raspberry Pi OS", + "devices": OrderedDict( + [ + # Index 0: Pi Zero 2 W + ( + "pi_zero_2w", + { + "name": "Raspberry Pi Zero 2 W", + "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", # noqa: E501 + }, + ), + # Index 1: Reserved for Pi 1 (not supported - 32-bit only) + # Index 2: Reserved for Pi 2 (not supported - 32-bit only) + # Index 3: Pi 3 + ( + "pi_3", + { + "name": "Raspberry Pi 3", + "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", # noqa: E501 + }, + ), + # Index 4: Pi 4 + ( + "pi_4", + { + "name": "Raspberry Pi 4", + "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", # noqa: E501 + }, + ), + # Index 5: Pi 5 + ( + "pi_5", + { + "name": "Raspberry Pi 5", + "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", # noqa: E501 + }, + ), + # Non-Pi devices in alphabetical order + ( + "le_potato", + { + "name": "Le Potato", + "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", # noqa: E501 + "requires_portability": True, + }, + ), + ] + ), }, - "Le Potato (Raspbian)": { - # LibreComputer AML-S905X-CC - uses same arm64 image as Pi, requires portability script - "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", - "requires_portability": True, + "armbian": { + "name": "Armbian", + "devices": OrderedDict( + [ + # Non-Pi devices in alphabetical order (no official Pi support) + ( + "le_potato", + { + "name": "Le Potato", + "url": "https://dl.armbian.com/lepotato/Bookworm_current_minimal", + "is_armbian": True, + }, + ), + ( + "orange_pi_5", + { + "name": "Orange Pi 5", + "url": "https://dl.armbian.com/orangepi5/Bookworm_current_minimal", + "is_armbian": True, + }, + ), + ( + "orange_pi_5_plus", + { + "name": "Orange Pi 5 Plus", + "url": "https://dl.armbian.com/orangepi5-plus/Bookworm_current_minimal", + "is_armbian": True, + }, + ), + ( + "orange_pi_5_pro", + { + "name": "Orange Pi 5 Pro", + "url": "https://dl.armbian.com/orangepi5pro/Trixie_vendor_minimal", + "is_armbian": True, + }, + ), + ( + "rock_5b", + { + "name": "Radxa ROCK 5B", + "url": "https://dl.armbian.com/rock-5b/Bookworm_current_minimal", + "is_armbian": True, + }, + ), + ] + ), }, - "Le Potato (Armbian)": { - # LibreComputer AML-S905X-CC - Native Armbian Bookworm minimal - # URL format: https://dl.armbian.com/lepotato/Bookworm_current_minimal - # This redirects to the latest stable build with .sha file available - "url": "https://dl.armbian.com/lepotato/Bookworm_current_minimal", - "is_armbian": True, # Will download and verify .sha file from same location + "dietpi": { + "name": "DietPi", + "devices": OrderedDict( + [ + # Index 0: Pi Zero 2 W + ( + "pi_zero_2w", + { + "name": "Raspberry Pi Zero 2 W", + "url": "https://dietpi.com/downloads/images/DietPi_RPi234-ARMv8-Bookworm.img.xz", + "is_dietpi": True, + }, + ), + # Index 1: Reserved for Pi 1 (not supported - 32-bit only) + # Index 2: Reserved for Pi 2 (not supported - 32-bit only) + # Index 3: Pi 3 + ( + "pi_3", + { + "name": "Raspberry Pi 3", + "url": "https://dietpi.com/downloads/images/DietPi_RPi234-ARMv8-Bookworm.img.xz", + "is_dietpi": True, + }, + ), + # Index 4: Pi 4 + ( + "pi_4", + { + "name": "Raspberry Pi 4", + "url": "https://dietpi.com/downloads/images/DietPi_RPi234-ARMv8-Bookworm.img.xz", + "is_dietpi": True, + }, + ), + # Index 5: Pi 5 + ( + "pi_5", + { + "name": "Raspberry Pi 5", + "url": "https://dietpi.com/downloads/images/DietPi_RPi5-ARMv8-Bookworm.img.xz", + "is_dietpi": True, + }, + ), + # Non-Pi devices in alphabetical order + ( + "orange_pi_5", + { + "name": "Orange Pi 5", + "url": "https://dietpi.com/downloads/images/DietPi_OrangePi5-ARMv8-Bookworm.img.xz", + "is_dietpi": True, + }, + ), + ( + "orange_pi_5_plus", + { + "name": "Orange Pi 5 Plus", + "url": "https://dietpi.com/downloads/images/DietPi_OrangePi5Plus-ARMv8-Bookworm.img.xz", + "is_dietpi": True, + }, + ), + ( + "orange_pi_5_pro", + { + "name": "Orange Pi 5 Pro", + "url": "https://dietpi.com/downloads/images/DietPi_OrangePi5Pro-ARMv8-Bookworm.img.xz", + "is_dietpi": True, + }, + ), + ( + "rock_5b", + { + "name": "Radxa ROCK 5B", + "url": "https://dietpi.com/downloads/images/DietPi_ROCK5B-ARMv8-Bookworm.img.xz", + "is_dietpi": True, + }, + ), + ] + ), }, } +# 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" @@ -184,83 +600,6 @@ def load_profile(profile_name: str) -> dict[str, Any] | None: return None -def save_profile(profile_name: str, config: dict[str, Any]) -> None: - """Save configuration as a named profile. - - Args: - profile_name: Name for the profile - config: Configuration dict to save - """ - PROFILES_DIR.mkdir(parents=True, exist_ok=True) - profile_path = PROFILES_DIR / f"{profile_name}.json" - with open(profile_path, "w") as f: - json.dump(config, f, indent=2) - console.print(f"[green]Profile '{profile_name}' saved to {profile_path}[/green]") - - -def select_profile() -> tuple[dict[str, Any] | None, str | None, bool]: - """Display available profiles and let user select one (supports 0-9). - - Returns: - Tuple of (selected profile config or None, profile name or None, should_edit flag) - """ - profiles = list_profiles() - - if not profiles: - return None, None, False - - # Limit to first 10 profiles (0-9) - profiles = profiles[:10] - - console.print() - console.print("[bold cyan]Saved Profiles:[/bold cyan]") - table = Table(show_header=True, header_style="bold cyan") - table.add_column("Key", style="dim") - table.add_column("Profile Name", style="green") - table.add_column("Device Type", justify="left") - table.add_column("Hostname", justify="left") - table.add_column("WiFi SSID", justify="left") - - for idx, profile in enumerate(profiles): - config = profile["config"] - device_type = config.get("device_type", "Not set") - hostname = config.get("hostname", "N/A") - wifi_ssid = config.get("wifi_ssid", "Not configured") - table.add_row(str(idx), profile["name"], device_type, hostname, wifi_ssid) - - console.print(table) - console.print() - - choices = [str(i) for i in range(len(profiles))] + ["n"] - choice = Prompt.ask( - "[bold]Select profile (0-9) or 'n' for new configuration[/bold]", - choices=choices, - default="n", - ) - - if choice == "n": - return None, None, False - - selected_profile = profiles[int(choice)] - profile_name = selected_profile["name"] - console.print(f"[green]Selected profile: {profile_name}[/green]") - - # Ask if user wants to use as-is or edit - action = Prompt.ask( - "[bold]Use profile as-is or edit/duplicate?[/bold]", - choices=["use", "edit"], - default="use", - ) - - should_edit = action == "edit" - if should_edit: - console.print( - "[cyan]You can now edit the configuration (press Enter to keep existing values)[/cyan]" - ) - - return selected_profile["config"], profile_name, should_edit - - def parse_size_to_gb(size_str: str) -> float | None: """Parse size string like '15.9 GB' or '2.0 TB' to gigabytes.""" try: @@ -354,10 +693,10 @@ def list_block_devices() -> list[dict[str, str]]: def select_device(device_index: int | None = None) -> str: - """Prompt user to select a block device to flash. + """Select a block device to flash using TUI or command-line option. Args: - device_index: Optional 1-based index to select device without prompting + device_index: Optional 1-based index to select device without TUI Returns: Selected device path (e.g., "/dev/disk2") @@ -368,7 +707,7 @@ def select_device(device_index: int | None = None) -> str: console.print("[red]No removable devices found![/red]") sys.exit(1) - # If device_index provided, validate and use it + # If device_index provided, validate and use it (no TUI) if device_index is not None: if device_index < 1 or device_index > len(devices): console.print(f"[red]Invalid device index: {device_index}[/red]") @@ -386,42 +725,31 @@ def select_device(device_index: int | None = None) -> str: border_style="red", ) ) - return selected["device"] - # Otherwise, prompt user to select - console.print() - console.print("[bold cyan]Available Devices:[/bold cyan]") - table = Table(show_header=True, header_style="bold cyan") - table.add_column("Index", style="dim") - table.add_column("Device", style="green") - table.add_column("Size", justify="right") - table.add_column("Type") - - for idx, device in enumerate(devices, 1): - table.add_row(str(idx), device["device"], device["size"], device["type"]) + # Still need confirmation even with device_index + confirm = Prompt.ask( + f"\n[bold red]Are you sure you want to flash {selected['device']}? " + "This will ERASE ALL DATA![/bold red]", + choices=["yes", "no"], + default="no", + ) + if confirm != "yes": + console.print("[yellow]Cancelled[/yellow]") + sys.exit(0) - console.print(table) - console.print() + return selected["device"] - choice = Prompt.ask( - "[bold]Select device to flash[/bold]", - choices=[str(i) for i in range(1, len(devices) + 1)], - ) + # Otherwise, use TUI for device selection + from flasher_tui import DeviceSelectionApp - selected = devices[int(choice) - 1] - console.print() - console.print( - Panel( - f"[bold yellow]WARNING: ALL DATA ON {selected['device']} WILL BE ERASED![/bold yellow]", - border_style="red", - ) - ) + app = DeviceSelectionApp(devices) + selected_device = app.run() - if not Confirm.ask(f"Are you sure you want to flash {selected['device']}?"): - console.print("[yellow]Cancelled[/yellow]") + if selected_device is None: + console.print("[yellow]Device selection cancelled[/yellow]") sys.exit(0) - return selected["device"] + return selected_device["device"] def select_pi_version( @@ -511,7 +839,7 @@ def select_pi_version( return version_map[choice] -def download_image(pi_version: str, download_dir: Path) -> Path: +def download_image(pi_version: str, download_dir: Path) -> Path: # noqa: C901 """Download Raspberry Pi OS or Armbian image if not already cached. Args: @@ -579,8 +907,27 @@ def download_image(pi_version: str, download_dir: Path) -> Path: try: sha_response = requests.get(sha_url, timeout=30) sha_response.raise_for_status() - # SHA file format: "hash filename" - expected_sha = sha_response.text.strip().split()[0] + + # 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 @@ -606,184 +953,155 @@ def download_image(pi_version: str, download_dir: Path) -> Path: return filepath -def get_config_from_prompts( # noqa: C901 - saved_config: dict[str, Any] | None, - edit_mode: bool = False, -) -> dict[str, Any]: - """Prompt user for configuration options. +def download_image_new(os_key: str, device_key: str, download_dir: Path) -> Path: # noqa: C901 + """Download OS image for the selected OS and device. Args: - saved_config: Previously saved configuration to use as defaults - edit_mode: If True, show prompts with defaults; if False, auto-use saved values - """ - config: dict[str, Any] = {} + os_key: Selected OS key (e.g., "raspbian", "armbian", "dietpi") + device_key: Selected device key (e.g., "pi_4", "orange_pi_5") + download_dir: Directory to store downloaded images - console.print() - console.print("[bold cyan]SD Card Configuration:[/bold cyan]") - console.print() + Returns: + Path to the downloaded image file + """ + image_info = OS_IMAGES[os_key]["devices"][device_key] + os_name = OS_IMAGES[os_key]["name"] + device_name = image_info["name"] + url = image_info["url"] + is_armbian = image_info.get("is_armbian", False) + is_dietpi = image_info.get("is_dietpi", False) - # WiFi settings - if saved_config and "enable_wifi" in saved_config and not edit_mode: - config["enable_wifi"] = saved_config["enable_wifi"] - console.print(f"[dim]Using saved WiFi enabled: {config['enable_wifi']}[/dim]") - else: - default_wifi = saved_config.get("enable_wifi", False) if saved_config else False - config["enable_wifi"] = Confirm.ask("Enable WiFi?", default=default_wifi) + # For Armbian/DietPi, follow redirects to get actual download URL + if is_armbian or is_dietpi: + os_label = "Armbian" if is_armbian else "DietPi" + console.print(f"[cyan]Resolving {os_label} image URL for {device_name}...[/cyan]") + # HEAD request to follow redirects and get actual filename + head_response = requests.head(url, allow_redirects=True, timeout=30) + head_response.raise_for_status() - if config["enable_wifi"]: - if saved_config and "wifi_ssid" in saved_config and not edit_mode: - config["wifi_ssid"] = saved_config["wifi_ssid"] - console.print(f"[dim]Using saved WiFi SSID: {config['wifi_ssid']}[/dim]") - else: - default_ssid = saved_config.get("wifi_ssid", "") if saved_config else "" - config["wifi_ssid"] = Prompt.ask("WiFi SSID", default=default_ssid or "") + # Extract final URL and filename after redirect + final_url = head_response.url + url = final_url # Use the actual file URL for download - if saved_config and "wifi_auth" in saved_config and not edit_mode: - config["wifi_auth"] = saved_config["wifi_auth"] - console.print(f"[dim]Using saved WiFi Auth: {config['wifi_auth']}[/dim]") - else: - default_auth = saved_config.get("wifi_auth", "WPA2") if saved_config else "WPA2" - config["wifi_auth"] = Prompt.ask( - "WiFi Auth Type", choices=["WPA", "WPA2", "WPA3"], default=default_auth - ) + # Try to get filename from Content-Disposition header + filename = None + content_disp = head_response.headers.get("Content-Disposition", "") + if "filename=" in content_disp: + # Extract filename from Content-Disposition header + import re + + match = re.search(r'filename[*]?=(?:"([^"]+)"|([^\s;]+))', content_disp) + if match: + filename = match.group(1) or match.group(2) + # Clean up any URL encoding + from urllib.parse import unquote + + filename = unquote(filename) + + # Fallback: extract from URL query parameter or path + if not filename: + if "filename=" in final_url: + # Try to extract from response-content-disposition query param + import re + from urllib.parse import unquote + + match = re.search(r"filename%3D([^&]+)", final_url) + if match: + filename = unquote(match.group(1)) + else: + # Last resort: use last path component (may be too long) + filename = final_url.split("/")[-1].split("?")[0] - if saved_config and "wifi_password" in saved_config and not edit_mode: - config["wifi_password"] = saved_config["wifi_password"] - console.print("[dim]Using saved WiFi password[/dim]") - else: - default_pass = saved_config.get("wifi_password", "") if saved_config else "" - config["wifi_password"] = Prompt.ask( - "WiFi Password", password=True, default=default_pass - ) + # If filename is still too long or invalid, create a safe one + if not filename or len(filename) > 200: + # Use device-specific name + filename = f"{os_label.lower()}_{device_key}.img.xz" - # User settings - if saved_config and "admin_user" in saved_config and not edit_mode: - config["admin_user"] = saved_config["admin_user"] - console.print(f"[dim]Using saved admin user: {config['admin_user']}[/dim]") + console.print(f"[dim]Resolved to: {filename}[/dim]") else: - default_user = saved_config.get("admin_user", "birdnetpi") if saved_config else "birdnetpi" - config["admin_user"] = Prompt.ask("Device Admin", default=default_user) + filename = url.split("/")[-1] - if saved_config and "admin_password" in saved_config and not edit_mode: - config["admin_password"] = saved_config["admin_password"] - console.print("[dim]Using saved admin password[/dim]") - else: - default_pass = saved_config.get("admin_password", "") if saved_config else "" - config["admin_password"] = Prompt.ask( - "Device Password", password=True, default=default_pass - ) + filepath = download_dir / filename - if saved_config and "hostname" in saved_config and not edit_mode: - config["hostname"] = saved_config["hostname"] - console.print(f"[dim]Using saved hostname: {config['hostname']}[/dim]") - else: - default_hostname = ( - saved_config.get("hostname", "birdnetpi") if saved_config else "birdnetpi" - ) - config["hostname"] = Prompt.ask("Device Hostname", default=default_hostname) + if filepath.exists(): + console.print(f"[green]Using cached image: {filepath}[/green]") + return filepath - # Advanced settings - if saved_config and "gpio_debug" in saved_config and not edit_mode: - config["gpio_debug"] = saved_config["gpio_debug"] - console.print(f"[dim]Using saved GPIO debug: {config['gpio_debug']}[/dim]") - else: - default_gpio = saved_config.get("gpio_debug", False) if saved_config else False - config["gpio_debug"] = Confirm.ask( - "Enable GPIO Debugging (Advanced)?", default=default_gpio - ) + console.print(f"[cyan]Downloading {os_name} image for {device_name}...[/cyan]") - if saved_config and "copy_installer" in saved_config and not edit_mode: - config["copy_installer"] = saved_config["copy_installer"] - console.print(f"[dim]Using saved copy installer: {config['copy_installer']}[/dim]") - else: - default_copy = saved_config.get("copy_installer", True) if saved_config else True - config["copy_installer"] = Confirm.ask("Copy install.sh?", default=default_copy) + 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() - if saved_config and "enable_spi" in saved_config and not edit_mode: - config["enable_spi"] = saved_config["enable_spi"] - console.print(f"[dim]Using saved SPI enabled: {config['enable_spi']}[/dim]") - else: - default_spi = saved_config.get("enable_spi", False) if saved_config else False - config["enable_spi"] = Confirm.ask("Enable SPI (for ePaper HAT)?", default=default_spi) + total = int(response.headers.get("content-length", 0)) + task = progress.add_task(f"Downloading {filename}", total=total) - # BirdNET-Pi pre-configuration (optional) - console.print() - console.print("[bold cyan]BirdNET-Pi Configuration (Optional):[/bold cyan]") - console.print("[dim]Pre-configure BirdNET-Pi for headless installation[/dim]") - console.print() + with open(filepath, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + progress.update(task, advance=len(chunk)) - # Sentinel for missing/empty values - unset = object() - - # Configuration prompts - only shown if previous field was provided - birdnet_prompts = { - "birdnet_device_name": { - "prompt": "Device Name", - "help": None, - "condition": None, - }, - "birdnet_latitude": { - "prompt": "Latitude", - "help": None, - "condition": None, - }, - "birdnet_longitude": { - "prompt": "Longitude", - "help": None, - "condition": "birdnet_latitude", # Only ask if latitude provided - }, - "birdnet_timezone": { - "prompt": "Timezone", - "help": [ - "Common timezones:", - " Americas: America/New_York, America/Chicago, America/Los_Angeles", - " Europe: Europe/London, Europe/Paris, Europe/Berlin", - " Asia: Asia/Tokyo, Asia/Shanghai, Asia/Kolkata", - " Pacific: Pacific/Auckland, Australia/Sydney", - ], - "condition": "birdnet_longitude", # Only ask if longitude provided - }, - "birdnet_language": { - "prompt": "Language Code", - "help": ["Common languages: en, es, fr, de, it, pt, nl, ru, zh, ja"], - "condition": None, - }, - } + console.print(f"[green]Downloaded: {filepath}[/green]") - for key, prompt_config in birdnet_prompts.items(): - # Check if condition is met (if any) - condition = prompt_config["condition"] - if condition and not config.get(condition): - continue + # 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() - # Check for saved value (must not be None or empty string) - saved_value = saved_config.get(key, unset) if saved_config else unset - if saved_value is not unset and saved_value not in (None, "") and not edit_mode: - config[key] = saved_value - console.print( - f"[dim]Using saved {prompt_config['prompt'].lower()}: {saved_value}[/dim]" - ) - else: - # Show help text if provided - if prompt_config["help"]: - console.print() - for line in prompt_config["help"]: - console.print(f"[dim]{line}[/dim]") - - # Get default value for edit mode - default_value = "" - if edit_mode and saved_value is not unset and saved_value not in (None, ""): - default_value = str(saved_value) - - # Prompt user - user_input = Prompt.ask( - prompt_config["prompt"], - default=default_value, - show_default=bool(default_value), - ) - config[key] = user_input if user_input else None + # 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 config + return filepath def flash_image(image_path: Path, device: str) -> None: @@ -863,12 +1181,574 @@ def flash_image(image_path: Path, device: str) -> None: console.print(f"[green]✓ Image flashed successfully in {duration_str}[/green]") +def configure_armbian_with_anylinuxfs( # noqa: C901 + device: str, + config: dict[str, Any], + os_key: str, + device_key: str, +) -> None: + """Configure Armbian ext4 partition using anylinuxfs. + + Args: + device: Device path (e.g., "/dev/disk4") + config: Configuration dict with WiFi, user, password settings + os_key: Operating system key (e.g., "armbian") + device_key: Device key (e.g., "opi5pro") + """ + console.print() + console.print("[cyan]Configuring Armbian partition...[/cyan]") + + # Check if anylinuxfs is installed + anylinuxfs_path = shutil.which("anylinuxfs") + if not anylinuxfs_path: + console.print("[yellow]anylinuxfs not found - skipping automated configuration[/yellow]") + console.print( + "[dim]Install anylinuxfs for automated setup: " + "brew tap nohajc/anylinuxfs && brew install anylinuxfs[/dim]" + ) + return + + # Mount the ext4 partition using anylinuxfs + partition = f"{device}s1" # First partition is root + + # Check if anylinuxfs already has something mounted + console.print("[cyan]Checking for existing anylinuxfs mounts...[/cyan]") + try: + # Unmount any existing anylinuxfs mount (it can only mount one at a time) + subprocess.run( + ["sudo", "anylinuxfs", "unmount"], + capture_output=True, + check=False, + timeout=10, + ) + time.sleep(2) # Wait for unmount to complete + except Exception: + pass # Ignore errors, continue anyway + + # Prevent macOS from trying to mount the ext4 partition + # macOS will show "disk not readable" dialog otherwise + console.print("[cyan]Preventing macOS auto-mount...[/cyan]") + try: + # Unmount any auto-mounted partitions from this disk + subprocess.run( + ["diskutil", "unmountDisk", device], + capture_output=True, + check=False, # Don't fail if nothing was mounted + ) + except Exception: + pass # Ignore errors, continue anyway + + try: + console.print(f"[cyan]Mounting {partition} using anylinuxfs...[/cyan]") + console.print("[dim]This may take 10-15 seconds to start the microVM...[/dim]") + console.print("[yellow]You may be prompted for your password by anylinuxfs[/yellow]") + console.print( + "[yellow]If macOS shows 'disk not readable', " + "click 'Ignore' - anylinuxfs will handle it[/yellow]" + ) + + # Run anylinuxfs - it will fork to background and exit with 0 + # We need to wait for the mount to appear after the command completes + result = subprocess.run( + ["sudo", "anylinuxfs", partition, "-w", "false"], + capture_output=False, # Allow password prompt to show + check=False, + ) + + if result.returncode != 0: + console.print(f"[red]anylinuxfs failed with exit code: {result.returncode}[/red]") + return + + console.print("[dim]Waiting for mount to appear...[/dim]") + + # Wait for mount to appear by checking common mount points + # anylinuxfs typically mounts to /Volumes/armbi_root or similar + mount_point = None + possible_mount_names = ["armbi_root", "armbian_root", "ARMBIAN"] + + for attempt in range(60): + time.sleep(1) + + # Check for mount point in /Volumes + try: + volumes_path = Path("/Volumes") + if volumes_path.exists(): + for volume in volumes_path.iterdir(): + volume_name = volume.name.lower() + # Check if this looks like an Armbian mount + if any(name.lower() in volume_name for name in possible_mount_names): + if volume.is_dir(): + # Verify it's actually mounted by checking for Linux directories + if (volume / "etc").exists() or (volume / "boot").exists(): + mount_point = volume + break + except Exception as e: + console.print(f"[dim]Error checking volumes: {e}[/dim]") + pass # Ignore errors, keep polling + + if mount_point: + break + + if attempt % 5 == 0 and attempt > 0: + console.print(f"[dim]Still waiting for mount... ({attempt}s)[/dim]") + + if not mount_point or not mount_point.exists(): + console.print("[red]Could not find anylinuxfs mount point after 60 seconds[/red]") + console.print("[yellow]Check /Volumes for armbi_root or similar mount[/yellow]") + return + + console.print(f"[green]✓ Mounted at {mount_point}[/green]") + + # Configure WiFi via armbian_first_run.txt + if config.get("enable_wifi"): + console.print("[cyan]Configuring WiFi...[/cyan]") + boot_dir = mount_point / "boot" + armbian_first_run = boot_dir / "armbian_first_run.txt" + + wifi_config = f"""#----------------------------------------------------------------- +# Armbian first run configuration +# Generated by BirdNET-Pi flash tool +#----------------------------------------------------------------- + +FR_general_delete_this_file_after_completion=1 + +FR_net_change_defaults=1 +FR_net_wifi_enabled=1 +FR_net_wifi_ssid='{config["wifi_ssid"]}' +FR_net_wifi_key='{config["wifi_password"]}' +FR_net_wifi_countrycode='US' +FR_net_ethernet_enabled=0 +""" + # Write via temp file then copy with sudo + # Use -X to skip extended attributes (NFS mounts don't support them) + temp_wifi = Path("/tmp/armbian_first_run.txt") + temp_wifi.write_text(wifi_config) + subprocess.run(["sudo", "cp", "-X", str(temp_wifi), str(armbian_first_run)], check=True) + temp_wifi.unlink() + console.print( + f"[green]✓ WiFi configured via armbian_first_run.txt " + f"(SSID: {config['wifi_ssid']})[/green]" + ) + + # ALSO configure WiFi via netplan for systemd-networkd (minimal images) + # This works on minimal images that don't have NetworkManager + console.print("[cyan]Configuring WiFi via netplan...[/cyan]") + netplan_dir = mount_point / "etc" / "netplan" + netplan_wifi = netplan_dir / "30-wifis-dhcp.yaml" + + # Escape SSID and password for YAML + wifi_ssid = config["wifi_ssid"].replace('"', '\\"') + wifi_password = config["wifi_password"].replace('"', '\\"') + + netplan_config = f"""# Created by BirdNET-Pi flash tool +# WiFi configuration for systemd-networkd +network: + wifis: + wlan0: + dhcp4: yes + dhcp6: yes + access-points: + "{wifi_ssid}": + password: "{wifi_password}" +""" + # Write via temp file then copy with sudo + temp_netplan = Path("/tmp/30-wifis-dhcp.yaml") + temp_netplan.write_text(netplan_config) + subprocess.run(["sudo", "cp", "-X", str(temp_netplan), str(netplan_wifi)], check=True) + # Set proper permissions (netplan requires 600) + subprocess.run(["sudo", "chmod", "600", str(netplan_wifi)], check=True) + temp_netplan.unlink() + console.print( + f"[green]✓ WiFi configured via netplan (SSID: {config['wifi_ssid']})[/green]" + ) + + # Configure user and password via .not_logged_in_yet + console.print("[cyan]Configuring user account...[/cyan]") + root_dir = mount_point / "root" + not_logged_in = root_dir / ".not_logged_in_yet" + + admin_user = config.get("admin_user", "birdnetpi") + admin_password = config.get("admin_password", "birdnetpi") + + user_config = f"""# Armbian first boot user configuration +# Generated by BirdNET-Pi flash tool + +PRESET_ROOT_PASSWORD="{admin_password}" +PRESET_USER_NAME="{admin_user}" +PRESET_USER_PASSWORD="{admin_password}" +PRESET_USER_SHELL="bash" +""" + # Write via temp file then copy with sudo + # Use -X to skip extended attributes (NFS mounts don't support them) + temp_user = Path("/tmp/not_logged_in_yet") + temp_user.write_text(user_config) + subprocess.run(["sudo", "cp", "-X", str(temp_user), str(not_logged_in)], check=True) + temp_user.unlink() + console.print(f"[green]✓ User configured (username: {admin_user})[/green]") + + # Copy installer script if requested + copy_installer_script(mount_point / "boot", config, os_key, device_key) + + # Copy BirdNET-Pi pre-configuration file if any settings provided + copy_birdnetpi_config(mount_point / "boot", config) + + except subprocess.CalledProcessError as e: + console.print(f"[red]Error configuring Armbian: {e}[/red]") + console.print("[yellow]Continuing without automated configuration[/yellow]") + finally: + # Unmount + console.print("[cyan]Unmounting anylinuxfs...[/cyan]") + try: + subprocess.run(["sudo", "anylinuxfs", "unmount"], check=True, timeout=10) + console.print("[green]✓ Armbian partition configured and unmounted[/green]") + except subprocess.TimeoutExpired: + console.print("[yellow]Warning: Unmount timed out - trying stop command[/yellow]") + try: + subprocess.run(["sudo", "anylinuxfs", "stop"], check=True, timeout=5) + except Exception: + console.print("[yellow]Warning: Could not stop anylinuxfs cleanly[/yellow]") + except subprocess.CalledProcessError: + console.print("[yellow]Warning: Could not unmount anylinuxfs[/yellow]") + + +def configure_dietpi_boot( # noqa: C901 + device: str, config: dict[str, Any], os_key: str, device_key: str +) -> None: + """Configure DietPi boot partition with dietpi.txt and dietpi-wifi.txt.""" + console.print() + console.print("[cyan]Configuring DietPi boot partition...[/cyan]") + + # Mount boot partition + if platform.system() == "Darwin": + # DietPi uses different partition numbers on different devices + # Find the FAT partition that contains dietpi.txt + boot_partition = None + boot_mount = None + + # Check partitions 1-3 for a FAT filesystem with dietpi.txt + for partition_num in range(1, 4): + test_partition = f"{device}s{partition_num}" + + # Check if partition exists + result = subprocess.run( + ["diskutil", "info", test_partition], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + continue # Partition doesn't exist + + # Check if it's a FAT filesystem (mountable by macOS) + if "FAT" not in result.stdout: + continue + + # Try to mount it + subprocess.run(["diskutil", "mount", test_partition], check=False, capture_output=True) + time.sleep(1) + + # Find where it mounted + mount_info = subprocess.run( + ["diskutil", "info", test_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)": + test_mount = Path(mount_path) + # Check if dietpi.txt exists + if (test_mount / "dietpi.txt").exists(): + boot_partition = test_partition + boot_mount = test_mount + break + + if boot_mount: + break + + if not boot_mount: + console.print("[red]Error: Could not find DietPi configuration partition[/red]") + console.print("[yellow]Looking for FAT partition with dietpi.txt file[/yellow]") + return + else: + boot_partition = f"{device}1" + boot_mount = Path("/mnt/dietpi_boot") + boot_mount.mkdir(parents=True, exist_ok=True) + subprocess.run(["sudo", "mount", boot_partition, str(boot_mount)], check=True) + + try: + console.print(f"[dim]Boot partition mounted at: {boot_mount}[/dim]") + + # Read existing dietpi.txt + dietpi_txt_path = boot_mount / "dietpi.txt" + if not dietpi_txt_path.exists(): + console.print("[yellow]Warning: dietpi.txt not found on boot partition[/yellow]") + return + + # Read the file + with open(dietpi_txt_path) as f: + dietpi_txt_lines = f.readlines() + + # Update configuration values + updates = { + "AUTO_SETUP_AUTOMATED": "1", # Enable automated first-run setup + "AUTO_SETUP_NET_HOSTNAME": config.get("hostname", "birdnetpi"), + "AUTO_SETUP_GLOBAL_PASSWORD": config["admin_password"], + "AUTO_SETUP_TIMEZONE": config.get("timezone", "UTC"), + "AUTO_SETUP_LOCALE": "en_US.UTF-8", + "AUTO_SETUP_KEYBOARD_LAYOUT": "us", # Set keyboard layout + "AUTO_SETUP_SSH_SERVER_INDEX": "-2", # Enable OpenSSH (more reliable) + "CONFIG_BOOT_WAIT_FOR_NETWORK": "2", # Wait for network (required) + } + + # Enable WiFi if configured + if config.get("enable_wifi"): + updates["AUTO_SETUP_NET_WIFI_ENABLED"] = "1" + updates["AUTO_SETUP_NET_WIFI_COUNTRY_CODE"] = config.get("wifi_country", "US") + + # If install.sh will be copied, configure DietPi to preserve it + # The DIETPISETUP partition (/boot or /boot/firmware) is deleted after first boot, + # so we create a script that copies install.sh to /root during first boot + preserve_installer = config.get("copy_installer") + if preserve_installer: + # Check if this is a Raspberry Pi (has config.txt in boot partition) + # On RPi, DietPi uses /boot/firmware/, on other boards it's /boot/ + config_txt_path = boot_mount / "config.txt" + if config_txt_path.exists(): + # Raspberry Pi - use /boot/firmware/ + updates["AUTO_SETUP_CUSTOM_SCRIPT_EXEC"] = "/boot/firmware/preserve_installer.sh" + else: + # Other boards - use /boot/ + updates["AUTO_SETUP_CUSTOM_SCRIPT_EXEC"] = "/boot/preserve_installer.sh" + + # Apply updates to dietpi.txt + # Handle both uncommented lines and commented lines (starting with #) + new_lines = [] + updated_keys = set() + + for line in dietpi_txt_lines: + updated = False + stripped_line = line.strip() + + for key, value in updates.items(): + # Match several patterns: + # - KEY=value + # - #KEY=value + # - KEY =value (with space) + # - # KEY=value (with space after #) + if ( + stripped_line.startswith(f"{key}=") + or stripped_line.startswith(f"#{key}=") + or stripped_line.startswith(f"{key} =") + or stripped_line.startswith(f"# {key}=") + ): + new_lines.append(f"{key}={value}\n") + updated = True + updated_keys.add(key) + console.print(f"[dim] Setting {key}={value}[/dim]") + break + if not updated: + new_lines.append(line) + + # Verify all keys were found and updated + missing_keys = set(updates.keys()) - updated_keys + if missing_keys: + console.print( + f"[yellow]Warning: Could not find these settings in dietpi.txt: " + f"{missing_keys}[/yellow]" + ) + console.print("[yellow]Adding them to the end of the file...[/yellow]") + for key in missing_keys: + new_lines.append(f"{key}={updates[key]}\n") + console.print(f"[dim] Adding {key}={updates[key]}[/dim]") + + # Write updated dietpi.txt + temp_dietpi_txt = Path("/tmp/dietpi.txt") + temp_dietpi_txt.write_text("".join(new_lines)) + subprocess.run(["sudo", "cp", str(temp_dietpi_txt), str(dietpi_txt_path)], check=True) + temp_dietpi_txt.unlink() + + console.print("[green]✓ Updated dietpi.txt[/green]") + + # Verify the changes were written + console.print("[dim]Verifying changes...[/dim]") + with open(dietpi_txt_path) as f: + verify_lines = f.readlines() + for key, expected_value in updates.items(): + found = False + for line in verify_lines: + if line.strip().startswith(f"{key}="): + actual_value = line.strip().split("=", 1)[1] + if actual_value == expected_value: + console.print(f"[dim] ✓ Verified {key}={expected_value}[/dim]") + found = True + else: + console.print( + f"[yellow] ⚠ {key} has value '{actual_value}' " + f"instead of '{expected_value}'[/yellow]" + ) + found = True + break + if not found: + console.print(f"[yellow] ⚠ Could not verify {key} in written file[/yellow]") + + # Configure WiFi if enabled + if config.get("enable_wifi"): + dietpi_wifi_path = boot_mount / "dietpi-wifi.txt" + if dietpi_wifi_path.exists(): + wifi_content = f"""# WiFi settings +aWIFI_SSID[0]='{config["wifi_ssid"]}' +aWIFI_KEY[0]='{config["wifi_password"]}' +""" + temp_wifi = Path("/tmp/dietpi-wifi.txt") + temp_wifi.write_text(wifi_content) + subprocess.run(["sudo", "cp", str(temp_wifi), str(dietpi_wifi_path)], check=True) + temp_wifi.unlink() + console.print("[green]✓ Configured WiFi[/green]") + + # Enable SPI for ePaper HAT + if config.get("enable_spi"): + # Check if this is a Raspberry Pi (has config.txt) + config_txt_path = boot_mount / "config.txt" + dietpi_env_path = boot_mount / "dietpiEnv.txt" + + if config_txt_path.exists(): + # Raspberry Pi - use config.txt dtparam + result = subprocess.run( + ["sudo", "cat", str(config_txt_path)], + capture_output=True, + text=True, + check=True, + ) + config_content = result.stdout + + # Check if line exists (commented or uncommented) + if "dtparam=spi=on" in config_content: + # Uncomment if commented + config_content = config_content.replace("#dtparam=spi=on", "dtparam=spi=on") + else: + # Add if missing + config_content += "\n# Enable SPI for ePaper HAT\ndtparam=spi=on\n" + + temp_config = Path("/tmp/dietpi_config_txt") + temp_config.write_text(config_content) + subprocess.run( + ["sudo", "cp", str(temp_config), str(config_txt_path)], + check=True, + ) + temp_config.unlink() + 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 + result = subprocess.run( + ["sudo", "cat", str(dietpi_env_path)], + capture_output=True, + text=True, + check=True, + ) + 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]" + ) + + else: + # Other SBC types not yet implemented + console.print( + "[yellow]Note: SPI configuration for this device not yet implemented[/yellow]" + ) + + # Copy installer script if requested (handles preservation for DietPi automatically) + 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) + + finally: + # Unmount + console.print("[cyan]Unmounting boot partition...[/cyan]") + if platform.system() == "Darwin": + subprocess.run(["diskutil", "unmount", "force", str(boot_mount)], check=True) + else: + subprocess.run(["sudo", "umount", str(boot_mount)], check=True) + + console.print("[green]✓ DietPi boot partition configured[/green]") + + +def configure_boot_partition_new( + device: str, + config: dict[str, Any], + os_key: str, + device_key: str, +) -> None: + """Configure the bootfs partition with user settings.""" + image_info = OS_IMAGES[os_key]["devices"][device_key] + is_dietpi = image_info.get("is_dietpi", False) + + # DietPi uses different configuration method + if is_dietpi: + configure_dietpi_boot(device, config, os_key, device_key) + return + + # Raspbian/other OS + requires_portability = image_info.get("requires_portability", False) + + # Create a legacy pi_version string for compatibility with existing code + if requires_portability: + pi_version = "Le Potato (Raspbian)" + else: + pi_version = image_info["name"] + + # Call the existing function with the legacy interface + configure_boot_partition(device, config, pi_version, os_key, device_key) + + def configure_boot_partition( # noqa: C901 device: str, config: dict[str, Any], pi_version: str, + os_key: str, + device_key: str, ) -> None: - """Configure the bootfs partition with user settings.""" + """Configure the bootfs partition with user settings (legacy interface).""" console.print() console.print("[cyan]Configuring boot partition...[/cyan]") @@ -1141,18 +2021,7 @@ def configure_boot_partition( # noqa: C901 console.print("[green]✓ Waveshare ePaper library downloaded to boot partition[/green]") # Copy installer script if requested - if config.get("copy_installer"): - install_script = Path(__file__).parent / "install.sh" - if install_script.exists(): - subprocess.run( - ["sudo", "cp", str(install_script), str(boot_mount / "install.sh")], - check=True, - ) - console.print("[green]✓ install.sh copied to boot partition[/green]") - else: - console.print( - "[yellow]Warning: install.sh not found, skipping installer copy[/yellow]" - ) + copy_installer_script(boot_mount, config, os_key, device_key) # Copy LibreComputer portability script for Le Potato (Raspbian only, not Armbian) if pi_version == "Le Potato (Raspbian)": @@ -1374,50 +2243,70 @@ def configure_boot_partition( # noqa: C901 console.print("[green]✓ Le Potato helper script: lepotato_setup.sh[/green]") console.print("[green]✓ Setup instructions: LE_POTATO_README.txt[/green]") - # Create BirdNET-Pi pre-configuration file if any settings provided - birdnet_config_lines = ["# BirdNET-Pi boot configuration"] - has_birdnet_config = False - - if config.get("birdnet_device_name"): - birdnet_config_lines.append(f"device_name={config['birdnet_device_name']}") - has_birdnet_config = True - - if config.get("birdnet_latitude"): - birdnet_config_lines.append(f"latitude={config['birdnet_latitude']}") - has_birdnet_config = True - - if config.get("birdnet_longitude"): - birdnet_config_lines.append(f"longitude={config['birdnet_longitude']}") - has_birdnet_config = True - - if config.get("birdnet_timezone"): - birdnet_config_lines.append(f"timezone={config['birdnet_timezone']}") - has_birdnet_config = True - - if config.get("birdnet_language"): - birdnet_config_lines.append(f"language={config['birdnet_language']}") - has_birdnet_config = True - - if has_birdnet_config: - temp_birdnet_config = Path("/tmp/birdnetpi_config.txt") - temp_birdnet_config.write_text("\n".join(birdnet_config_lines) + "\n") - subprocess.run( - ["sudo", "cp", str(temp_birdnet_config), str(boot_mount / "birdnetpi_config.txt")], - check=True, - ) - temp_birdnet_config.unlink() - console.print("[green]✓ BirdNET-Pi configuration written to boot partition[/green]") + # Copy BirdNET-Pi pre-configuration file if any settings provided + copy_birdnetpi_config(boot_mount, config) finally: # Unmount if platform.system() == "Darwin": - subprocess.run(["diskutil", "unmount", str(boot_mount)], check=True) + subprocess.run(["diskutil", "unmount", "force", str(boot_mount)], check=True) else: subprocess.run(["sudo", "umount", str(boot_mount)], check=True) console.print("[green]✓ Boot partition configured[/green]") +def run_configuration_wizard() -> dict[str, Any] | None: + """Run the Textual TUI wizard to gather configuration.""" + app = FlasherWizardApp(OS_IMAGES, DEVICE_PROPERTIES, OS_PROPERTIES) + return app.run() + + +def print_config_summary(config: dict[str, Any]) -> None: + """Print configuration summary to terminal for reference.""" + console.print() + console.print(Panel.fit("[bold green]Configuration Summary[/bold green]", border_style="green")) + console.print() + + # Create summary table + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("Setting", style="cyan bold") + table.add_column("Value", style="white") + + # OS and Device + table.add_row("Operating System", config.get("os_key", "N/A")) + table.add_row("Target Device", config.get("device_key", "N/A")) + + # Network + if config.get("enable_wifi"): + table.add_row("WiFi SSID", config.get("wifi_ssid", "")) + table.add_row("WiFi Auth", config.get("wifi_auth", "WPA-PSK")) + else: + table.add_row("WiFi", "Disabled (Ethernet only)") + + # System + table.add_row("Hostname", config.get("hostname", "")) + table.add_row("Username", config.get("username", "")) + + # Advanced + table.add_row("Preserve Installer", "Yes" if config.get("copy_installer") else "No") + table.add_row("Enable SPI", "Yes" if config.get("enable_spi") else "No") + table.add_row("GPIO Debug", "Yes" if config.get("gpio_debug") else "No") + + # BirdNET (optional) + if config.get("device_name"): + table.add_row("Device Name", config["device_name"]) + if config.get("latitude") is not None: + table.add_row("Location", f"{config['latitude']}, {config['longitude']}") + if config.get("timezone"): + table.add_row("Timezone", config["timezone"]) + if config.get("language"): + table.add_row("Language", config["language"]) + + console.print(table) + console.print() + + @click.command() @click.option( "--save-config", "save_config_flag", is_flag=True, help="Save configuration for future use" @@ -1432,7 +2321,7 @@ def configure_boot_partition( # noqa: C901 type=str, help="Device type override (e.g., 'Pi 4', 'Le Potato (Armbian)')", ) -def main(save_config_flag: bool, device_index: int | None, device_type: str | None) -> None: +def main(save_config_flag: bool, device_index: int | None, device_type: str | None) -> None: # noqa: C901 """Flash Raspberry Pi OS to SD card and configure for BirdNET-Pi.""" console.print() console.print( @@ -1443,43 +2332,28 @@ def main(save_config_flag: bool, device_index: int | None, device_type: str | No ) ) - # Try to select from saved profiles first - profile_config, profile_name, edit_mode = select_profile() + # Run TUI wizard to gather configuration + config = run_configuration_wizard() - # Store which profile was selected (None for new config) - saved_config = profile_config + # Handle cancellation + if config is None: + console.print("[yellow]Configuration cancelled[/yellow]") + return - # Select device - device = select_device(device_index=device_index) + # Print configuration summary to terminal scrollback + print_config_summary(config) - # Select Pi version - pi_version = select_pi_version( - saved_device_type=saved_config.get("device_type") if saved_config else None, - device_type_override=device_type, - edit_mode=edit_mode, - ) + # Extract os_key and device_key from config + os_key = config["os_key"] + device_key = config["device_key"] + + # Select SD card device + device = select_device(device_index=device_index) # Download image download_dir = Path.home() / ".cache" / "birdnetpi" / "images" download_dir.mkdir(parents=True, exist_ok=True) - image_path = download_image(pi_version, download_dir) - - # Get configuration (edit_mode shows prompts with defaults instead of auto-using saved values) - config = get_config_from_prompts(saved_config, edit_mode=edit_mode) - - # Add device_type to config before saving - config["device_type"] = pi_version - - # Save configuration as profile - # CRITICAL FIX: When editing, default to the original profile name, not "default" - if ( - save_config_flag - or edit_mode - or (not saved_config and Confirm.ask("Save this configuration as a profile?")) - ): - default_name = profile_name if profile_name else "default" - new_profile_name = Prompt.ask("Profile name", default=default_name) - save_profile(new_profile_name, config) + image_path = download_image_new(os_key, device_key, download_dir) # Flash image console.print() @@ -1496,32 +2370,56 @@ def main(save_config_flag: bool, device_index: int | None, device_type: str | No ) flash_image(image_path, device) - # Configure boot partition (skip for Armbian - uses different partition layout) - image_info = PI_IMAGES[pi_version] + # Configure boot partition + image_info = OS_IMAGES[os_key]["devices"][device_key] is_armbian = image_info.get("is_armbian", False) - if not is_armbian: - configure_boot_partition(device, config, pi_version) + is_dietpi = image_info.get("is_dietpi", False) + + if is_armbian: + # Use anylinuxfs to configure Armbian ext4 partition + configure_armbian_with_anylinuxfs(device, config, os_key, device_key) + elif is_dietpi: + # DietPi uses FAT32 boot partition like Raspbian + configure_boot_partition_new(device, config, os_key, device_key) else: - console.print() - console.print("[yellow]Note: Armbian uses its own first-boot configuration wizard[/yellow]") - console.print( - "[dim]You will be prompted to create a user and set up SSH on first boot[/dim]" - ) + # Use standard FAT32 boot partition configuration + configure_boot_partition_new(device, config, os_key, device_key) # Eject SD card console.print() console.print("[cyan]Ejecting SD card...[/cyan]") + + # Wait a bit for anylinuxfs unmount to fully complete + time.sleep(2) + if platform.system() == "Darwin": - subprocess.run(["diskutil", "eject", device], check=True) + # Try to eject, but don't fail if it's still mounted + result = subprocess.run( + ["diskutil", "eject", device], check=False, capture_output=True, text=True + ) + if result.returncode != 0: + console.print("[yellow]Could not eject - disk may still be in use[/yellow]") + console.print("[yellow]Please manually eject the SD card when ready[/yellow]") + console.print(f"[dim]Error: {result.stderr.strip()}[/dim]") else: - subprocess.run(["sudo", "eject", device], check=True) + result = subprocess.run( + ["sudo", "eject", device], check=False, capture_output=True, text=True + ) + if result.returncode != 0: + console.print("[yellow]Could not eject - disk may still be in use[/yellow]") + console.print("[yellow]Please manually eject the SD card when ready[/yellow]") console.print() - # Build summary message + # Build summary message - look up display names from OS_IMAGES + os_name = OS_IMAGES[os_key]["name"] + device_name = OS_IMAGES[os_key]["devices"][device_key]["name"] + requires_portability = image_info.get("requires_portability", False) + summary_parts = [ "[bold green]✓ SD Card Ready![/bold green]\n", - f"Device Model: [yellow]{pi_version}[/yellow]", + f"OS: [yellow]{os_name}[/yellow]", + f"Device: [yellow]{device_name}[/yellow]", f"Hostname: [cyan]{config.get('hostname', 'birdnetpi')}[/cyan]", f"Admin User: [cyan]{config['admin_user']}[/cyan]", "SSH: [green]Enabled[/green]", @@ -1533,8 +2431,8 @@ def main(save_config_flag: bool, device_index: int | None, device_type: str | No else: summary_parts.append("WiFi: [yellow]Not configured (Ethernet required)[/yellow]") - # Special instructions for Le Potato (Raspbian) - if pi_version == "Le Potato (Raspbian)": + # Special instructions for devices requiring portability script + if requires_portability: summary_parts.append("Portability Script: [green]Installed[/green]\n") summary_parts.append( "[bold yellow]⚠ IMPORTANT: Two-Step Boot Process Required![/bold yellow]\n" @@ -1547,19 +2445,30 @@ def main(save_config_flag: bool, device_index: int | None, device_type: str | No "5. SSH in and run: [cyan]bash /boot/firmware/install.sh[/cyan]\n\n" "See [cyan]LE_POTATO_README.txt[/cyan] on boot partition for details.[/dim]" ) - # Direct boot instructions for Le Potato (Armbian) - elif pi_version == "Le Potato (Armbian)": - summary_parts.append("Native Armbian: [green]Direct boot ready[/green]\n") - summary_parts.append( - "[dim]Insert the SD card into your Le Potato and power it on.\n" - "Armbian will run its first-boot setup wizard:\n" - " 1. Create a root password\n" - " 2. Create a user account\n" - " 3. Configure locale/timezone\n\n" - "After setup, SSH in and run the BirdNET-Pi installer:\n" - " [cyan]curl -fsSL https://raw.githubusercontent.com/mverteuil/BirdNET-Pi/" - "main/install/install.sh | bash[/cyan][/dim]" - ) + # Direct boot instructions for Armbian/DietPi + elif is_armbian or is_dietpi: + 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"): + summary_parts.append( + "[dim]Insert the SD card into your Le Potato 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" + "[yellow]Note:[/yellow] If WiFi was configured, it may take 1-2 minutes " + "to connect on first boot.[/dim]" + ) + else: + summary_parts.append( + "[dim]Insert the SD card into your Le Potato 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" + " 3. Configure locale/timezone\n\n" + "After setup, run: [cyan]bash /boot/install.sh[/cyan][/dim]" + ) # Add installer script status for regular Pi elif config.get("copy_installer"): summary_parts.append("Installer: [green]Copied to /boot/firmware/install.sh[/green]\n") diff --git a/install/flasher.tcss b/install/flasher.tcss new file mode 100644 index 00000000..f78f76c6 --- /dev/null +++ b/install/flasher.tcss @@ -0,0 +1,142 @@ +/* BirdNET-Pi SD Card Flasher TUI Styles */ + +/* Global screen alignment */ +Screen { + align: center middle; + background: $surface; +} + +/* Main dialog container */ +#dialog { + width: 80; + height: auto; + max-height: 90%; + border: solid $accent; + padding: 1 2; + background: $panel; + overflow-y: auto; +} + +/* Screen titles */ +.screen-title { + width: 100%; + content-align: center middle; + text-style: bold; + color: $accent; + margin-bottom: 1; +} + +/* Input widgets */ +Input { + width: 100%; + margin: 1 0; +} + +Input.-invalid { + border: solid red; +} + +Input.-valid:focus { + border: solid green; +} + +/* Select widgets */ +Select { + width: 100%; + margin: 1 0; +} + +/* Checkboxes and switches */ +Checkbox, Switch { + margin: 1 0; +} + +/* Button groups */ +.button-group { + width: 100%; + height: auto; + align: right middle; + margin-top: 1; +} + +Button { + margin: 0 1; + min-width: 12; +} + +/* Static text */ +Static { + width: 100%; +} + +/* Containers */ +Container { + height: auto; +} + +Vertical { + height: auto; +} + +Horizontal { + height: auto; +} + +/* List views */ +ListView { + height: auto; + max-height: 20; + border: solid $primary; + margin: 1 0; +} + +ListItem { + padding: 1; +} + +ListItem:hover { + background: $primary-lighten-1; +} + +ListItem > Label { + width: 100%; +} + +/* Info sections */ +.info-section { + border: solid $primary-lighten-1; + padding: 1; + margin: 1 0; + background: $surface-lighten-1; +} + +.info-label { + text-style: bold; + color: $text; +} + +.info-value { + color: $text-muted; +} + +/* Confirmation table */ +.config-table { + border: solid $primary; + padding: 1; + margin: 1 0; +} + +.config-row { + height: auto; +} + +.config-key { + width: 30%; + text-style: bold; + color: $accent; +} + +.config-value { + width: 70%; + color: $text; +} diff --git a/install/flasher_tui.py b/install/flasher_tui.py new file mode 100644 index 00000000..e95b28bd --- /dev/null +++ b/install/flasher_tui.py @@ -0,0 +1,1408 @@ +"""Textual TUI for BirdNET-Pi SD Card Flasher Configuration. + +This module provides a guided wizard interface for configuring SD card +flashing options using the Textual framework. +""" + +import json +from pathlib import Path +from typing import Any + +from textual import on +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal, Vertical +from textual.screen import ModalScreen +from textual.validation import Function, ValidationResult, Validator +from textual.widgets import Button, Checkbox, Input, Label, ListItem, ListView, Select, Static + +# ============================================================================ +# Capability Calculation +# ============================================================================ + + +def get_combined_capabilities( + os_key: str, device_key: str, os_properties: dict[str, Any], device_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") + 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, {}) + + 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) + ), + # 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) + ), + # 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), + "wifi_config_method": os_props.get("wifi_config_method"), + "user_config_method": os_props.get("user_config_method"), + "spi_config_method": os_props.get("spi_config_method"), + } + + +# ============================================================================ +# Profile Management +# ============================================================================ + + +class ProfileManager: + """Manage saving/loading configuration profiles.""" + + PROFILES_DIR = Path.home() / ".config" / "birdnetpi" / "profiles" + + @classmethod + def save_profile(cls, name: str, config: dict[str, Any]) -> None: + """Save configuration as named profile.""" + cls.PROFILES_DIR.mkdir(parents=True, exist_ok=True) + profile_path = cls.PROFILES_DIR / f"{name}.json" + with open(profile_path, "w") as f: + json.dump(config, f, indent=2) + + @classmethod + def load_profile(cls, name: str) -> dict[str, Any] | None: + """Load named profile.""" + profile_path = cls.PROFILES_DIR / f"{name}.json" + if profile_path.exists(): + try: + with open(profile_path) as f: + return json.load(f) + except Exception as e: + # Log error but return None + print(f"Error loading profile {name}: {e}") + return None + return None + + @classmethod + def list_profiles(cls) -> list[str]: + """List available profile names.""" + if not cls.PROFILES_DIR.exists(): + return [] + return sorted([p.stem for p in cls.PROFILES_DIR.glob("*.json")]) + + +# ============================================================================ +# Validators +# ============================================================================ + + +class HostnameValidator(Validator): + """Validator for hostname fields.""" + + def validate(self, value: str) -> ValidationResult: + """Validate hostname is alphanumeric and >= 3 chars.""" + if not value: + return self.failure("Hostname required") + if not value.replace("-", "").isalnum(): + return self.failure("Only alphanumeric and hyphens allowed") + if len(value) < 3: + return self.failure("At least 3 characters required") + return self.success() + + +class PasswordValidator(Validator): + """Validator for password fields.""" + + def validate(self, value: str) -> ValidationResult: + """Validate password is at least 4 characters.""" + if not value: + return self.failure("Password required") + if len(value) < 4: + return self.failure("At least 4 characters required") + return self.success() + + +class LatitudeValidator(Validator): + """Validator for latitude values.""" + + def validate(self, value: str) -> ValidationResult: + """Validate latitude is between -90 and 90.""" + if not value: + return self.success() # Optional field + try: + lat = float(value) + if not -90 <= lat <= 90: + return self.failure("Must be between -90 and 90") + return self.success() + except ValueError: + return self.failure("Must be a valid number") + + +class LongitudeValidator(Validator): + """Validator for longitude values.""" + + def validate(self, value: str) -> ValidationResult: + """Validate longitude is between -180 and 180.""" + if not value: + return self.success() # Optional field + try: + lon = float(value) + if not -180 <= lon <= 180: + return self.failure("Must be between -180 and 180") + return self.success() + except ValueError: + return self.failure("Must be a valid number") + + +# ============================================================================ +# TUI Screens +# ============================================================================ + + +class FlasherWizardApp(App[dict | None]): + """BirdNET-Pi SD Card Flasher Configuration Wizard. + + This TUI guides users through configuring all settings for flashing + an SD card. Upon completion, it returns a configuration dictionary + to the calling script for processing. + """ + + CSS_PATH = "flasher.tcss" + + def __init__( + self, + os_images: dict[str, Any], + device_properties: dict[str, Any], + os_properties: dict[str, Any] | None = None, + ) -> None: + """Initialize wizard with OS and device data.""" + super().__init__() + self.config: dict[str, Any] = {} + self.os_images = os_images + self.device_properties = device_properties + self.os_properties = os_properties or {} + 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 + + def on_mount(self) -> None: + """Start wizard with profile selection.""" + self.push_screen(ProfileLoadScreen(), self.handle_profile_load) + + def handle_profile_load(self, profile_config: dict[str, Any] | None) -> None: + """Handle profile selection result.""" + if profile_config is None: + # Start new configuration + self.is_loaded_profile = False + self.loaded_profile_name = None + self.push_screen(OSSelectionScreen(self.os_images), self.handle_os_selection) + elif profile_config == "CANCELLED": + # User cancelled + self.exit(None) + else: + # Loaded profile - extract and store profile name + self.loaded_profile_name = profile_config.pop("__profile_name__", None) + + # Normalize old keys for compatibility + if "os_key" in profile_config: + # Normalize old capitalized OS keys (e.g., "DietPi" -> "dietpi") + profile_config["os_key"] = profile_config["os_key"].lower() + elif "os_type" in profile_config: + # Old profiles used "os_type" instead of "os_key" + profile_config["os_key"] = profile_config["os_type"].lower() + + self.config = profile_config + self.is_loaded_profile = True # Mark as loaded profile + self.push_screen( + ConfirmationScreen(self.config, allow_edit=True, os_images=self.os_images), + self.handle_confirmation, + ) + + def handle_os_selection(self, result: dict[str, Any] | None) -> None: + """Handle OS selection result.""" + if result: + self.config.update(result) + self.push_screen( + DeviceSelectionScreen(self.os_images, result["os_key"], self.config), + self.handle_device_selection, + ) + else: + # Go back to profile screen + self.on_mount() + + def handle_device_selection(self, result: dict[str, Any] | None) -> None: + """Handle device selection result.""" + if result: + self.config.update(result) + # Determine capabilities for dynamic screen flow + os_key = self.config["os_key"] + device_key = result["device_key"] + self.push_screen( + NetworkConfigScreen(os_key, device_key, self.device_properties, self.config), + self.handle_network_config, + ) + else: + # Go back to OS selection + self.push_screen( + OSSelectionScreen(self.os_images, self.config), self.handle_os_selection + ) + + def handle_network_config(self, result: dict[str, Any] | None) -> None: + """Handle network configuration result.""" + if result: + self.config.update(result) + os_key = self.config["os_key"] + self.push_screen(SystemConfigScreen(os_key, self.config), self.handle_system_config) + else: + # Go back to device selection + self.push_screen( + DeviceSelectionScreen(self.os_images, self.config["os_key"], self.config), + self.handle_device_selection, + ) + + def handle_system_config(self, result: dict[str, Any] | None) -> None: + """Handle system configuration result.""" + if result: + self.config.update(result) + 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 + ), + self.handle_advanced_config, + ) + else: + # Go back to network config + 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), + self.handle_network_config, + ) + + def handle_advanced_config(self, result: dict[str, Any] | None) -> None: + """Handle advanced configuration result.""" + if result: + self.config.update(result) + self.push_screen(BirdNETConfigScreen(self.config), self.handle_birdnet_config) + else: + # Go back to system config + self.push_screen( + SystemConfigScreen(self.config["os_key"], self.config), self.handle_system_config + ) + + def handle_birdnet_config(self, result: dict[str, Any] | None) -> None: + """Handle BirdNET configuration result.""" + if result: + self.config.update(result) + self.push_screen( + ConfirmationScreen(self.config, allow_edit=False, os_images=self.os_images), + self.handle_confirmation, + ) + else: + # Go back to advanced config + 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 + ), + self.handle_advanced_config, + ) + + def handle_confirmation(self, confirmed: bool) -> None: + """Handle confirmation result.""" + if confirmed: + if not self.is_loaded_profile: + # Only ask to save for new configurations + self.push_screen( + ProfileSaveScreen(self.config, self.loaded_profile_name), + self.handle_profile_save, + ) + else: + # Already saved profile, just exit + self.exit(self.config) + else: + # User wants to edit - go back to start for full editing + was_loaded = self.is_loaded_profile + self.is_loaded_profile = False # Editing makes it a new config + # But keep the profile name for pre-filling save screen later + if not was_loaded: + self.loaded_profile_name = None + # Start from OS selection with current config pre-filled + self.push_screen( + OSSelectionScreen(self.os_images, self.config), self.handle_os_selection + ) + + def handle_profile_save(self, saved: bool) -> None: + """Handle profile save result.""" + # Whether saved or not, we're done + self.exit(self.config) + + +# ============================================================================ +# Profile Screens +# ============================================================================ + + +class ProfileLoadScreen(ModalScreen[dict | None]): + """Screen to load existing profile or start new.""" + + def __init__(self) -> None: + """Initialize screen.""" + super().__init__() + self.profile_data: dict[str, dict[str, Any]] = {} + self.profile_names: list[str] = [] # Map index to profile name + + def compose(self) -> ComposeResult: + """Compose the profile loading screen.""" + profiles = ProfileManager.list_profiles() + + with Container(id="dialog"): + yield Static("Load Configuration Profile", classes="screen-title") + + if profiles: + # Build list items + list_items = [] + + # Add "New Configuration" option (index 0) + self.profile_names.append("__new__") + list_items.append(ListItem(Label("→ Start New Configuration"))) + + # Add existing profiles with details + for name in profiles: + config = ProfileManager.load_profile(name) + if config: + # Store config and name by index + self.profile_names.append(name) + self.profile_data[name] = config + + # Build description + os_name = config.get("os_name", "Unknown OS") + device_name = config.get("device_name", "Unknown device") + hostname = config.get("hostname", "N/A") + wifi_ssid = config.get("wifi_ssid", "Not configured") + + description = ( + f"{name}\n" + f" OS: {os_name} | Device: {device_name}\n" + f" Hostname: {hostname} | WiFi: {wifi_ssid}" + ) + + list_items.append(ListItem(Label(description))) + + yield ListView(*list_items, id="profile_list") + with Horizontal(classes="button-group"): + yield Button("Cancel", id="cancel", variant="error") + yield Button("Continue", id="continue", variant="primary") + else: + yield Static("\nNo saved profiles found. Starting new configuration.\n") + yield Button("Continue", id="new", variant="primary") + + @on(Button.Pressed, "#continue") + def handle_continue(self) -> None: + """Handle continue button.""" + profile_list = self.query_one("#profile_list", ListView) + if profile_list.index is None: + self.notify("Please select a profile", severity="error") + return + + # Get profile name by index + selected_name = self.profile_names[profile_list.index] + + if selected_name == "__new__": + self.dismiss(None) + else: + config = self.profile_data.get(selected_name) + if config: + # Return both config and profile name + config["__profile_name__"] = selected_name + self.dismiss(config) + else: + self.notify("Failed to load profile", severity="error") + + @on(Button.Pressed, "#new") + def handle_new(self) -> None: + """Handle new configuration button.""" + self.dismiss(None) + + @on(Button.Pressed, "#cancel") + def handle_cancel(self) -> None: + """Handle cancel button.""" + self.dismiss("CANCELLED") # type: ignore[arg-type] + + +class ProfileSaveScreen(ModalScreen[bool]): + """Screen to save current configuration as profile.""" + + def __init__(self, config: dict[str, Any], initial_name: str | None = None) -> None: + """Initialize with config to save and optional initial profile name.""" + super().__init__() + self.config = config + self.initial_name = initial_name + + def compose(self) -> ComposeResult: + """Compose the profile save screen.""" + with Container(id="dialog"): + yield Static("Save Configuration Profile", classes="screen-title") + yield Input( + id="profile_name", + placeholder="Enter profile name...", + value=self.initial_name or "", + validators=[ + Function( + lambda s: all(c.isalnum() or c in "_-" for c in s), + "Use letters, numbers, - or _", + ) + ], + ) + with Horizontal(classes="button-group"): + yield Button("Skip", id="skip", variant="default") + yield Button("Save", id="save", variant="success") + + @on(Button.Pressed, "#save") + def handle_save(self) -> None: + """Handle save button.""" + name_input = self.query_one("#profile_name", Input) + + if not name_input.value: + self.notify("Profile name required", severity="error") + return + + if name_input.is_valid: + ProfileManager.save_profile(name_input.value, self.config) + self.notify(f"Profile '{name_input.value}' saved!", severity="information") + self.dismiss(True) + else: + self.notify("Invalid profile name", severity="error") + + @on(Button.Pressed, "#skip") + def handle_skip(self) -> None: + """Handle skip button.""" + self.dismiss(False) + + +# Placeholder for other screens - will continue in next messages +class OSSelectionScreen(ModalScreen[dict | None]): + """Screen for selecting the operating system.""" + + def __init__( + self, os_images: dict[str, Any], initial_config: dict[str, Any] | None = None + ) -> None: + """Initialize with OS images data and optional initial config.""" + super().__init__() + self.os_images = os_images + self.initial_config = initial_config or {} + + def compose(self) -> ComposeResult: + """Compose the OS selection screen.""" + with Container(id="dialog"): + yield Static("Step 1: Select Operating System", classes="screen-title") + + # Build options from os_images - use display name for both value and label + # This ensures the Select dropdown shows only friendly names + options = [(value["name"], value["name"]) for key, value in self.os_images.items()] + + # Pre-select OS if editing - use display text + initial_value = Select.BLANK + if self.initial_config: + initial_os_key = self.initial_config.get("os_key", "").lower() + if initial_os_key and initial_os_key in self.os_images: + initial_value = self.os_images[initial_os_key]["name"] + + yield Select( + options=options, + id="os_select", + prompt="Choose operating system...", + value=initial_value, + ) + + with Horizontal(classes="button-group"): + yield Button("Back", id="back") + yield Button("Next", id="next", variant="primary") + + @on(Button.Pressed, "#next") + def handle_next(self) -> None: + """Handle next button.""" + select = self.query_one("#os_select", Select) + if select.value == Select.BLANK: + self.notify("Please select an operating system", severity="error") + return + + # Debug: Check what we're actually getting + selected_value = str(select.value) + + # The Select widget returns the display text, not the key + # We need to reverse-lookup the key from the display text + os_key = None + for key, os_info in self.os_images.items(): + if os_info["name"] == selected_value: + os_key = key + break + + if not os_key: + self.notify(f"Invalid OS selection: {selected_value}", severity="error") + return + + self.dismiss({"os_key": os_key}) + + @on(Button.Pressed, "#back") + def handle_back(self) -> None: + """Handle back button.""" + self.dismiss(None) + + +class DeviceSelectionScreen(ModalScreen[dict | None]): + """Screen for selecting the target device.""" + + def __init__( + self, os_images: dict[str, Any], os_key: str, initial_config: dict[str, Any] | None = None + ) -> None: + """Initialize with OS images, selected OS, and optional initial config.""" + super().__init__() + self.os_images = os_images + self.os_key = os_key + self.initial_config = initial_config or {} + + def compose(self) -> ComposeResult: + """Compose the device selection screen.""" + with Container(id="dialog"): + yield Static("Step 2: Select Target Device", classes="screen-title") + + # Build options from devices for selected OS + # Use display name for both value and label to show only friendly names + devices = self.os_images[self.os_key]["devices"] + options = [] + for _key, value in devices.items(): + display_name = ( + f"{value['name']}{' - ' + value.get('note', '') if value.get('note') else ''}" + ) + options.append((display_name, display_name)) + + # Pre-select device if editing - use display text + initial_value = Select.BLANK + if self.initial_config: + initial_device_key = self.initial_config.get("device_key", "") + if initial_device_key and initial_device_key in devices: + device_info = devices[initial_device_key] + note_suffix = f" - {device_info['note']}" if device_info.get("note") else "" + initial_value = f"{device_info['name']}{note_suffix}" + + yield Select( + options=options, + id="device_select", + prompt="Choose target device...", + value=initial_value, + ) + + with Horizontal(classes="button-group"): + yield Button("Back", id="back") + yield Button("Next", id="next", variant="primary") + + @on(Button.Pressed, "#next") + def handle_next(self) -> None: + """Handle next button.""" + select = self.query_one("#device_select", Select) + if select.value == Select.BLANK: + self.notify("Please select a device", severity="error") + return + + # The Select widget returns the display text, not the key + # We need to reverse-lookup the key from the display text + selected_value = str(select.value) + devices = self.os_images[self.os_key]["devices"] + + device_key = None + for key, device_info in devices.items(): + # Match against the full display text (name + note if present) + note_suffix = f" - {device_info['note']}" if device_info.get("note") else "" + display_text = f"{device_info['name']}{note_suffix}" + if display_text == selected_value: + device_key = key + break + + if not device_key: + self.notify(f"Invalid device selection: {selected_value}", severity="error") + return + + self.dismiss({"device_key": device_key}) + + @on(Button.Pressed, "#back") + def handle_back(self) -> None: + """Handle back button.""" + self.dismiss(None) + + +class NetworkConfigScreen(ModalScreen[dict | None]): + """Screen for configuring network settings.""" + + def __init__( + self, + os_key: str, + device_key: str, + device_properties: dict[str, Any], + initial_config: dict[str, Any] | None = None, + ) -> None: + """Initialize with OS/device info and optional initial config.""" + 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: + """Compose the network config screen.""" + with Container(id="dialog"): + yield Static("Step 3: Network Configuration", classes="screen-title") + + # WiFi enable checkbox - pre-fill from config + wifi_enabled = self.initial_config.get("enable_wifi", False) + yield Checkbox("Enable WiFi", id="enable_wifi", value=wifi_enabled) + + # WiFi settings (conditionally enabled) - pre-fill from config + yield Input( + placeholder="WiFi SSID", + id="wifi_ssid", + value=self.initial_config.get("wifi_ssid", ""), + disabled=not wifi_enabled, + ) + yield Input( + placeholder="WiFi Password", + id="wifi_password", + value=self.initial_config.get("wifi_password", ""), + password=True, + disabled=not wifi_enabled, + ) + + # WiFi auth - pre-select using display text + wifi_auth_key = self.initial_config.get("wifi_auth", "WPA-PSK") + auth_options = [ + ("WPA-PSK", "WPA-PSK (most common)"), + ("WPA-EAP", "WPA-EAP (enterprise)"), + ("OPEN", "OPEN (no security)"), + ] + # Find display text for the key + wifi_auth_display = next( + (display for key, display in auth_options if key == wifi_auth_key), + "WPA-PSK (most common)", + ) + + yield Select( + options=auth_options, + id="wifi_auth", + prompt="WiFi Authentication...", + value=wifi_auth_display if wifi_enabled else Select.BLANK, + disabled=not wifi_enabled, + ) + + with Horizontal(classes="button-group"): + yield Button("Back", id="back") + yield Button("Next", id="next", variant="primary") + + @on(Checkbox.Changed, "#enable_wifi") + def handle_wifi_toggle(self, event: Checkbox.Changed) -> None: + """Enable/disable WiFi inputs based on checkbox.""" + ssid_input = self.query_one("#wifi_ssid", Input) + password_input = self.query_one("#wifi_password", Input) + auth_select = self.query_one("#wifi_auth", Select) + + ssid_input.disabled = not event.value + password_input.disabled = not event.value + auth_select.disabled = not event.value + + @on(Button.Pressed, "#next") + def handle_next(self) -> None: + """Handle next button.""" + wifi_enabled = self.query_one("#enable_wifi", Checkbox).value + + result: dict[str, Any] = {"enable_wifi": wifi_enabled} + + if wifi_enabled: + ssid = self.query_one("#wifi_ssid", Input).value + password = self.query_one("#wifi_password", Input).value + auth_display = self.query_one("#wifi_auth", Select).value + + if not ssid: + self.notify("WiFi SSID required when WiFi is enabled", severity="error") + return + + # Reverse lookup: display text -> key + auth_options = [ + ("WPA-PSK", "WPA-PSK (most common)"), + ("WPA-EAP", "WPA-EAP (enterprise)"), + ("OPEN", "OPEN (no security)"), + ] + auth_key = next( + (key for key, display in auth_options if display == str(auth_display)), "WPA-PSK" + ) + + result.update({"wifi_ssid": ssid, "wifi_password": password, "wifi_auth": auth_key}) + + self.dismiss(result) + + @on(Button.Pressed, "#back") + def handle_back(self) -> None: + """Handle back button.""" + self.dismiss(None) + + +class SystemConfigScreen(ModalScreen[dict | None]): + """Screen for configuring system settings.""" + + def __init__(self, os_key: str, initial_config: dict[str, Any] | None = None) -> None: + """Initialize with OS key and optional initial config.""" + super().__init__() + self.os_key = os_key + self.is_dietpi = os_key.lower().startswith("dietpi") + self.initial_config = initial_config or {} + + def compose(self) -> ComposeResult: + """Compose the system config screen.""" + with Container(id="dialog"): + yield Static("Step 4: System Configuration", classes="screen-title") + + # Hostname - pre-fill from config + yield Input( + placeholder="Hostname (e.g., birdnetpi)", + id="hostname", + value=self.initial_config.get("hostname", ""), + validators=[HostnameValidator()], + ) + + # Username (disabled for DietPi) - pre-fill from config + if self.is_dietpi: + yield Static("Username: root (DietPi default)", classes="info-label") + else: + yield Input( + placeholder="Username", + id="username", + value=self.initial_config.get("username", "birdnet"), + ) + + # Password - pre-fill from config + yield Input( + placeholder="Password", + id="password", + value=self.initial_config.get("password", ""), + password=True, + validators=[PasswordValidator()], + ) + + with Horizontal(classes="button-group"): + yield Button("Back", id="back") + yield Button("Next", id="next", variant="primary") + + @on(Button.Pressed, "#next") + def handle_next(self) -> None: + """Handle next button.""" + hostname_input = self.query_one("#hostname", Input) + password_input = self.query_one("#password", Input) + + # Validate hostname + if not hostname_input.is_valid or not hostname_input.value: + self.notify("Valid hostname required", severity="error") + return + + # Validate password + if not password_input.is_valid or not password_input.value: + self.notify("Valid password required", severity="error") + return + + result: dict[str, Any] = { + "hostname": hostname_input.value, + "password": password_input.value, + } + + # Add username for non-DietPi + if not self.is_dietpi: + username_input = self.query_one("#username", Input) + if not username_input.value: + self.notify("Username required", severity="error") + return + result["username"] = username_input.value + else: + result["username"] = "root" + + self.dismiss(result) + + @on(Button.Pressed, "#back") + def handle_back(self) -> None: + """Handle back button.""" + self.dismiss(None) + + +class AdvancedConfigScreen(ModalScreen[dict | None]): + """Screen for advanced configuration options.""" + + 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: + """Initialize with OS/device info and optional initial config.""" + 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 {} + + # Get combined capabilities (OS + device) + # 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 + ) + self.supports_spi = capabilities.get("supports_spi", False) + + def compose(self) -> ComposeResult: + """Compose the advanced config screen.""" + with Container(id="dialog"): + yield Static("Step 5: Advanced Configuration", classes="screen-title") + + # Copy installer checkbox - pre-fill from config + yield Checkbox( + "Preserve installer to /root/ after first boot", + id="copy_installer", + value=self.initial_config.get("copy_installer", False), + ) + + # Enable SPI checkbox (conditional on device support) - pre-fill from config + if self.supports_spi: + yield Checkbox( + "Enable SPI (Required for GPIO-wired displays)", + id="enable_spi", + value=self.initial_config.get("enable_spi", False), + ) + else: + yield Static("SPI: Not supported on this device", classes="info-label") + + # GPIO debug checkbox - pre-fill from config + yield Checkbox( + "Enable GPIO debugging output", + id="gpio_debug", + value=self.initial_config.get("gpio_debug", False), + ) + + with Horizontal(classes="button-group"): + yield Button("Back", id="back") + yield Button("Next", id="next", variant="primary") + + @on(Button.Pressed, "#next") + def handle_next(self) -> None: + """Handle next button.""" + result: dict[str, Any] = { + "copy_installer": self.query_one("#copy_installer", Checkbox).value, + "gpio_debug": self.query_one("#gpio_debug", Checkbox).value, + } + + # Add SPI setting if device supports it + if self.supports_spi: + result["enable_spi"] = self.query_one("#enable_spi", Checkbox).value + else: + result["enable_spi"] = False + + self.dismiss(result) + + @on(Button.Pressed, "#back") + def handle_back(self) -> None: + """Handle back button.""" + self.dismiss(None) + + +class BirdNETConfigScreen(ModalScreen[dict | None]): + """Screen for optional BirdNET-Pi configuration.""" + + def __init__(self, initial_config: dict[str, Any] | None = None) -> None: + """Initialize with optional initial config.""" + super().__init__() + self.initial_config = initial_config or {} + + def compose(self) -> ComposeResult: + """Compose the BirdNET config screen.""" + with Container(id="dialog"): + yield Static("Step 6: BirdNET-Pi Configuration (Optional)", classes="screen-title") + yield Static( + "These settings can be configured later through the web interface.", + classes="info-value", + ) + + # Advanced install options (for developers/testers) + yield Static("Advanced Options (for developers):", classes="info-label") + + # Repository URL - pre-fill from config + yield Input( + placeholder="Repository URL (optional, for testing branches)", + id="repo_url", + value=self.initial_config.get("repo_url", ""), + ) + + # Branch name - pre-fill from config + yield Input( + placeholder="Branch name (optional, default: main)", + id="branch", + value=self.initial_config.get("branch", ""), + ) + + yield Static("BirdNET-Pi Configuration:", classes="info-label") + + # Device name - pre-fill from config + yield Input( + placeholder="Device name (optional)", + id="device_name", + value=self.initial_config.get("device_name", ""), + ) + + # Location - pre-fill from config + lat_value = ( + str(self.initial_config.get("latitude", "")) + if self.initial_config.get("latitude") is not None + else "" + ) + lon_value = ( + str(self.initial_config.get("longitude", "")) + if self.initial_config.get("longitude") is not None + else "" + ) + + yield Input( + placeholder="Latitude (optional, e.g., 45.5231)", + id="latitude", + value=lat_value, + validators=[LatitudeValidator()], + ) + yield Input( + placeholder="Longitude (optional, e.g., -122.6765)", + id="longitude", + value=lon_value, + validators=[LongitudeValidator()], + ) + + # Timezone selection - pre-select using display text + common_timezones = [ + ("America/New_York", "America/New_York (ET)"), + ("America/Chicago", "America/Chicago (CT)"), + ("America/Denver", "America/Denver (MT)"), + ("America/Los_Angeles", "America/Los_Angeles (PT)"), + ("America/Anchorage", "America/Anchorage (AKT)"), + ("Pacific/Honolulu", "Pacific/Honolulu (HST)"), + ("Europe/London", "Europe/London (GMT)"), + ("Europe/Paris", "Europe/Paris (CET)"), + ("Asia/Tokyo", "Asia/Tokyo (JST)"), + ("Australia/Sydney", "Australia/Sydney (AEST)"), + ] + timezone_key = self.initial_config.get("timezone", "") + timezone_display = ( + next( + (display for key, display in common_timezones if key == timezone_key), + Select.BLANK, + ) + if timezone_key + else Select.BLANK + ) + + yield Select( + options=common_timezones, + id="timezone", + prompt="Timezone (optional)...", + value=timezone_display, + ) + + # Language selection - pre-select using display text + languages = [ + ("en", "English"), + ("de", "German (Deutsch)"), + ("fr", "French (Français)"), + ("es", "Spanish (Español)"), + ("pt", "Portuguese (Português)"), + ("it", "Italian (Italiano)"), + ("nl", "Dutch (Nederlands)"), + ("ja", "Japanese (日本語)"), + ("zh", "Chinese (中文)"), + ] + language_key = self.initial_config.get("language", "") + language_display = ( + next((display for key, display in languages if key == language_key), Select.BLANK) + if language_key + else Select.BLANK + ) + + yield Select( + options=languages, + id="language", + prompt="Language (optional)...", + value=language_display, + ) + + with Horizontal(classes="button-group"): + yield Button("Back", id="back") + yield Button("Continue", id="continue", variant="primary") + + @on(Button.Pressed, "#continue") + def handle_continue(self) -> None: # noqa: C901 + """Handle continue button.""" + result: dict[str, Any] = {} + + # Advanced install options + repo_url = self.query_one("#repo_url", Input).value + if repo_url: + result["birdnet_repo_url"] = repo_url + + branch = self.query_one("#branch", Input).value + if branch: + result["birdnet_branch"] = branch + + # Device name + device_name = self.query_one("#device_name", Input).value + if device_name: + result["device_name"] = device_name + + # Latitude/Longitude + lat_input = self.query_one("#latitude", Input) + lon_input = self.query_one("#longitude", Input) + + if lat_input.value or lon_input.value: + # Validate both are provided if either is + if not (lat_input.value and lon_input.value): + self.notify("Both latitude and longitude must be provided", severity="error") + return + + # Validate they're valid + if not (lat_input.is_valid and lon_input.is_valid): + self.notify("Invalid latitude or longitude", severity="error") + return + + result["latitude"] = float(lat_input.value) + result["longitude"] = float(lon_input.value) + + # Timezone - reverse lookup display text -> key + timezone_display = self.query_one("#timezone", Select).value + if timezone_display != Select.BLANK: + common_timezones = [ + ("America/New_York", "America/New_York (ET)"), + ("America/Chicago", "America/Chicago (CT)"), + ("America/Denver", "America/Denver (MT)"), + ("America/Los_Angeles", "America/Los_Angeles (PT)"), + ("America/Anchorage", "America/Anchorage (AKT)"), + ("Pacific/Honolulu", "Pacific/Honolulu (HST)"), + ("Europe/London", "Europe/London (GMT)"), + ("Europe/Paris", "Europe/Paris (CET)"), + ("Asia/Tokyo", "Asia/Tokyo (JST)"), + ("Australia/Sydney", "Australia/Sydney (AEST)"), + ] + timezone_key = next( + (key for key, display in common_timezones if display == str(timezone_display)), None + ) + if timezone_key: + result["timezone"] = timezone_key + + # Language - reverse lookup display text -> key + language_display = self.query_one("#language", Select).value + if language_display != Select.BLANK: + languages = [ + ("en", "English"), + ("de", "German (Deutsch)"), + ("fr", "French (Français)"), + ("es", "Spanish (Español)"), + ("pt", "Portuguese (Português)"), + ("it", "Italian (Italiano)"), + ("nl", "Dutch (Nederlands)"), + ("ja", "Japanese (日本語)"), + ("zh", "Chinese (中文)"), + ] + language_key = next( + (key for key, display in languages if display == str(language_display)), None + ) + if language_key: + result["language"] = language_key + + self.dismiss(result) + + @on(Button.Pressed, "#back") + def handle_back(self) -> None: + """Handle back button.""" + self.dismiss(None) + + +class ConfirmationScreen(ModalScreen[bool]): + """Screen to review and confirm configuration.""" + + def __init__( + self, + config: dict[str, Any], + allow_edit: bool = False, + os_images: dict[str, Any] | None = None, + ) -> None: + """Initialize with config to confirm.""" + super().__init__() + self.config = config + self.allow_edit = allow_edit + self.os_images = os_images or {} + + def compose(self) -> ComposeResult: # noqa: C901 + """Compose the confirmation screen.""" + with Container(id="dialog"): + yield Static("Confirm Configuration", classes="screen-title") + + # Build configuration summary + with Vertical(classes="config-table"): + # OS and Device - use display names from OS_IMAGES + os_key = self.config.get("os_key", "") + device_key = self.config.get("device_key", "") + + # Get OS display name + os_name = "N/A" + if os_key and self.os_images: + os_name = self.os_images.get(os_key, {}).get("name", os_key) + elif os_key: + os_name = os_key + + # Get device display name + device_name = "N/A" + if os_key and device_key and self.os_images: + device_name = ( + self.os_images.get(os_key, {}) + .get("devices", {}) + .get(device_key, {}) + .get("name", device_key) + ) + elif device_key: + device_name = device_key + + with Horizontal(classes="config-row"): + yield Static("Operating System:", classes="config-key") + yield Static(os_name, classes="config-value") + with Horizontal(classes="config-row"): + yield Static("Target Device:", classes="config-key") + yield Static(device_name, classes="config-value") + + # Network + if self.config.get("enable_wifi"): + with Horizontal(classes="config-row"): + yield Static("WiFi SSID:", classes="config-key") + yield Static(self.config.get("wifi_ssid", ""), classes="config-value") + with Horizontal(classes="config-row"): + yield Static("WiFi Auth:", classes="config-key") + yield Static( + self.config.get("wifi_auth", "WPA-PSK"), classes="config-value" + ) + else: + with Horizontal(classes="config-row"): + yield Static("WiFi:", classes="config-key") + yield Static("Disabled (Ethernet only)", classes="config-value") + + # System + with Horizontal(classes="config-row"): + yield Static("Hostname:", classes="config-key") + yield Static(self.config.get("hostname", ""), classes="config-value") + with Horizontal(classes="config-row"): + yield Static("Username:", classes="config-key") + yield Static(self.config.get("username", ""), classes="config-value") + + # Advanced + with Horizontal(classes="config-row"): + yield Static("Preserve Installer:", classes="config-key") + yield Static( + "Yes" if self.config.get("copy_installer") else "No", classes="config-value" + ) + with Horizontal(classes="config-row"): + yield Static("Enable SPI:", classes="config-key") + yield Static( + "Yes" if self.config.get("enable_spi") else "No", classes="config-value" + ) + with Horizontal(classes="config-row"): + yield Static("GPIO Debug:", classes="config-key") + yield Static( + "Yes" if self.config.get("gpio_debug") else "No", classes="config-value" + ) + + # BirdNET (optional fields) + if self.config.get("device_name"): + with Horizontal(classes="config-row"): + yield Static("Device Name:", classes="config-key") + yield Static(self.config["device_name"], classes="config-value") + if self.config.get("latitude") is not None: + with Horizontal(classes="config-row"): + yield Static("Location:", classes="config-key") + yield Static( + f"{self.config['latitude']}, {self.config['longitude']}", + classes="config-value", + ) + if self.config.get("timezone"): + with Horizontal(classes="config-row"): + yield Static("Timezone:", classes="config-key") + yield Static(self.config["timezone"], classes="config-value") + if self.config.get("language"): + with Horizontal(classes="config-row"): + yield Static("Language:", classes="config-key") + yield Static(self.config["language"], classes="config-value") + + with Horizontal(classes="button-group"): + if self.allow_edit: + yield Button("Edit", id="edit") + else: + yield Button("Back", id="back") + yield Button("Confirm", id="confirm", variant="success") + + @on(Button.Pressed, "#confirm") + def handle_confirm(self) -> None: + """Handle confirm button.""" + self.dismiss(True) + + @on(Button.Pressed, "#back") + def handle_back(self) -> None: + """Handle back button.""" + self.dismiss(False) + + @on(Button.Pressed, "#edit") + def handle_edit(self) -> None: + """Handle edit button.""" + self.dismiss(False) + + +# ============================================================================ +# Device Selection Screens +# ============================================================================ + + +class DeviceSelectionForFlashScreen(ModalScreen[dict | None]): + """Screen for selecting the SD card/block device to flash.""" + + def __init__(self, devices: list[dict[str, Any]]) -> None: + """Initialize with list of available block devices.""" + super().__init__() + self.devices = devices + + def compose(self) -> ComposeResult: + """Compose the device selection screen.""" + with Container(id="dialog"): + yield Static("Select SD Card to Flash", classes="screen-title") + + if not self.devices: + yield Static( + "⚠️ No removable devices found!\n\nPlease insert an SD card and try again.", + classes="info-section", + ) + with Horizontal(classes="button-group"): + yield Button("Cancel", id="cancel", variant="error") + else: + yield Static("Available removable devices:", classes="info-label") + + # Build device list + with ListView(id="device_list"): + for _idx, device in enumerate(self.devices): + device_text = ( + f"{device['device']}\n Size: {device['size']} | Type: {device['type']}" + ) + yield ListItem(Label(device_text)) + + with Horizontal(classes="button-group"): + yield Button("Cancel", id="cancel") + yield Button("Next", id="next", variant="primary") + + @on(Button.Pressed, "#next") + def handle_next(self) -> None: + """Handle next button.""" + device_list = self.query_one("#device_list", ListView) + if device_list.index is None: + self.notify("Please select a device", severity="error") + return + + selected = self.devices[device_list.index] + self.dismiss(selected) + + @on(Button.Pressed, "#cancel") + def handle_cancel(self) -> None: + """Handle cancel button.""" + self.dismiss(None) + + +class ConfirmFlashScreen(ModalScreen[bool]): + """Screen to confirm the destructive flash operation.""" + + def __init__(self, device_path: str) -> None: + """Initialize with device path to flash.""" + super().__init__() + self.device_path = device_path + + def compose(self) -> ComposeResult: + """Compose the confirmation screen.""" + with Container(id="dialog"): + yield Static("⚠️ CONFIRM FLASH OPERATION", classes="screen-title") + + yield Static( + f"WARNING: ALL DATA ON {self.device_path} WILL BE PERMANENTLY ERASED!\n\n" + "This action cannot be undone.\n\n" + "Are you absolutely sure you want to continue?", + classes="info-section", + ) + + with Horizontal(classes="button-group"): + yield Button("Cancel", id="cancel", variant="error") + yield Button("Yes, Flash Device", id="confirm", variant="success") + + @on(Button.Pressed, "#confirm") + def handle_confirm(self) -> None: + """Handle confirm button.""" + self.dismiss(True) + + @on(Button.Pressed, "#cancel") + def handle_cancel(self) -> None: + """Handle cancel button.""" + self.dismiss(False) + + +# ============================================================================ +# Device Selection Wizard App +# ============================================================================ + + +class DeviceSelectionApp(App[dict | None]): + """Standalone TUI for selecting a device to flash. + + This runs after configuration is complete to select the physical device. + """ + + CSS_PATH = "flasher.tcss" + + def __init__(self, devices: list[dict[str, Any]]) -> None: + """Initialize with list of available devices.""" + super().__init__() + self.devices = devices + self.selected_device: dict[str, Any] | None = None + + def on_mount(self) -> None: + """Start with device selection.""" + self.push_screen(DeviceSelectionForFlashScreen(self.devices), self.handle_device_selection) + + def handle_device_selection(self, device: dict[str, Any] | None) -> None: + """Handle device selection result.""" + if device is None: + # User cancelled + self.exit(None) + else: + # Show confirmation + self.selected_device = device + self.push_screen( + ConfirmFlashScreen(device["device"]), + self.handle_confirmation, + ) + + def handle_confirmation(self, confirmed: bool) -> None: + """Handle flash confirmation.""" + if confirmed: + # Return the selected device + self.exit(self.selected_device) + else: + # User cancelled + self.exit(None) From b701e55a90476fc6bb76d456cc89106fc9de4954 Mon Sep 17 00:00:00 2001 From: "M. de Verteuil" Date: Sun, 2 Nov 2025 01:13:10 -0500 Subject: [PATCH 48/48] fix: Add type ignore comments for textual imports in flasher TUI The textual library is an external dependency for the SD card flasher tool and not part of the main codebase dependencies. Add type ignore comments to suppress pyright import errors in CI where textual isn't installed. --- install/flasher_tui.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/install/flasher_tui.py b/install/flasher_tui.py index e95b28bd..beda513e 100644 --- a/install/flasher_tui.py +++ b/install/flasher_tui.py @@ -8,12 +8,21 @@ from pathlib import Path from typing import Any -from textual import on -from textual.app import App, ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.screen import ModalScreen -from textual.validation import Function, ValidationResult, Validator -from textual.widgets import Button, Checkbox, Input, Label, ListItem, ListView, Select, Static +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] +from textual.screen import ModalScreen # type: ignore[import-untyped] +from textual.validation import Function, ValidationResult, Validator # type: ignore[import-untyped] +from textual.widgets import ( # type: ignore[import-untyped] + Button, + Checkbox, + Input, + Label, + ListItem, + ListView, + Select, + Static, +) # ============================================================================ # Capability Calculation