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/flash_sdcard.py b/install/flash_sdcard.py index ee3218c0..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,49 +102,502 @@ def find_command(cmd: str, homebrew_paths: list[str] | None = None) -> str: return cmd -# Raspberry Pi OS image URLs (Lite 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 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", + "pi_3": { + "has_wifi": True, + "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", + "pi_4": { + "has_wifi": True, + "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", + "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, + }, + "orangepi5pro": { + "has_wifi": True, # Built-in WiFi + "has_spi": True, + }, + "rock5b": { + "has_wifi": True, # M.2 WiFi module support + "has_spi": True, + }, +} + + +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, + }, + ), + ] + ), + }, + "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, + }, + ), + ] + ), + }, + "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" -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: - return json.load(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 saved config: {e}[/yellow]") - return None + console.print( + f"[yellow]Warning: Could not load profile {profile_file.name}: {e}[/yellow]" + ) + return profiles -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: - json.dump(config, f, indent=2) - console.print(f"[green]Configuration saved to {CONFIG_FILE}[/green]") + +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 profile {profile_name}: {e}[/yellow]") + return None def parse_size_to_gb(size_str: str) -> float | None: @@ -234,56 +692,107 @@ 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: + """Select a block device to flash using TUI or command-line option. + + Args: + device_index: Optional 1-based index to select device without TUI + + 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) - 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"]) + # 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]") + console.print(f"[yellow]Available indices: 1-{len(devices)}[/yellow]") + sys.exit(1) - console.print(table) - console.print() + selected = devices[device_index - 1] + console.print(f"[cyan]Using device {device_index}: {selected['device']}[/cyan]") - choice = Prompt.ask( - "[bold]Select device to flash[/bold]", - choices=[str(i) for i in range(1, len(devices) + 1)], - ) + console.print() + console.print( + Panel( + f"[bold yellow]WARNING: ALL DATA ON {selected['device']} " + "WILL BE ERASED![/bold yellow]", + border_style="red", + ) + ) - 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", + # 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) + + return selected["device"] + + # Otherwise, use TUI for device selection + from flasher_tui import DeviceSelectionApp + + 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() -> str: - """Prompt user to select Raspberry Pi version.""" +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 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,36 +800,83 @@ def select_pi_version() -> str: "4": "Pi 4", "3": "Pi 3", "0": "Pi Zero 2 W", + "R": "Le Potato (Raspbian)", + "A": "Le Potato (Armbian)", } - # 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, R, A) + display_order = [ + ("0", "Pi Zero 2 W", ""), + ("3", "Pi 3", ""), + ("4", "Pi 4", ""), + ("5", "Pi 5", ""), + ("R", "Le Potato (Raspbian)", "Two-step boot required"), + ("A", "Le Potato (Armbian)", "Native Armbian, direct boot"), + ] + + for version, model, notes in display_order: + table.add_row(version, model, notes) console.print(table) console.print() + # Use saved value as default in edit mode + default_choice = None + if edit_mode and saved_device_type: + # Find the key for the saved device type + for key, model in version_map.items(): + if model == saved_device_type: + default_choice = key + break + choice = Prompt.ask( - "[bold]Select Raspberry Pi model[/bold]", + "[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.""" +def download_image(pi_version: str, download_dir: Path) -> Path: # noqa: C901 + """Download Raspberry Pi OS or Armbian image if not already cached. + + Args: + pi_version: Device model name (e.g., "Pi 4", "Le Potato (Armbian)") + download_dir: Directory to store downloaded images + + Returns: + Path to the downloaded image file + """ image_info = PI_IMAGES[pi_version] url = image_info["url"] - 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}"), @@ -330,6 +886,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() @@ -342,146 +899,209 @@ def download_image(pi_version: str, download_dir: Path) -> Path: progress.update(task, advance=len(chunk)) console.print(f"[green]Downloaded: {filepath}[/green]") - return filepath + # Verify SHA256 for Armbian (download .sha file from same location) + if is_armbian: + console.print("[cyan]Verifying image integrity...[/cyan]") + sha_url = f"{url}.sha" + try: + sha_response = requests.get(sha_url, timeout=30) + sha_response.raise_for_status() + + # Parse SHA file - handle different formats + sha_content = sha_response.text.strip() + + # Check if this looks like binary data (not a text SHA file) + if not sha_content.isprintable() or len(sha_content) < 64: + raise ValueError("SHA file does not contain valid text") + + # Try to extract hash - handle formats like: + # "hash filename" or just "hash" + parts = sha_content.split() + if parts: + expected_sha = parts[0] + # Validate it looks like a hex hash (64 chars for SHA256) + if not ( + len(expected_sha) == 64 + and all(c in "0123456789abcdefABCDEF" for c in expected_sha) + ): + raise ValueError(f"Invalid SHA256 hash format: {expected_sha[:20]}...") + else: + raise ValueError("Could not extract hash from SHA file") -def get_config_from_prompts(saved_config: dict[str, Any] | None) -> dict[str, Any]: # noqa: C901 - """Prompt user for configuration options.""" - config: dict[str, Any] = {} + # Calculate actual SHA256 + import hashlib - console.print() - console.print("[bold cyan]SD Card Configuration:[/bold cyan]") - console.print() + 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() - # WiFi settings - if saved_config and "enable_wifi" in saved_config: - 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) + 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]") - if config["enable_wifi"]: - if saved_config and "wifi_ssid" in saved_config: - 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") + return filepath - if saved_config and "wifi_auth" in saved_config: - config["wifi_auth"] = saved_config["wifi_auth"] - console.print(f"[dim]Using saved WiFi Auth: {config['wifi_auth']}[/dim]") - else: - config["wifi_auth"] = Prompt.ask( - "WiFi Auth Type", choices=["WPA", "WPA2", "WPA3"], default="WPA2" - ) - if saved_config and "wifi_password" in saved_config: - 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) +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. - # User settings - if saved_config and "admin_user" in saved_config: - 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") + Args: + 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 - if saved_config and "admin_password" in saved_config: - 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) + 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) + + # 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() + + # Extract final URL and filename after redirect + final_url = head_response.url + url = final_url # Use the actual file URL for download + + # 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 "hostname" in saved_config: - config["hostname"] = saved_config["hostname"] - console.print(f"[dim]Using saved hostname: {config['hostname']}[/dim]") - else: - config["hostname"] = Prompt.ask("Device Hostname", default="birdnetpi") + # 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" - # Advanced settings - if saved_config and "gpio_debug" in saved_config: - config["gpio_debug"] = saved_config["gpio_debug"] - console.print(f"[dim]Using saved GPIO debug: {config['gpio_debug']}[/dim]") + console.print(f"[dim]Resolved to: {filename}[/dim]") else: - config["gpio_debug"] = Confirm.ask("Enable GPIO Debugging (Advanced)?", default=False) + filename = url.split("/")[-1] - if saved_config and "copy_installer" in saved_config: - 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) + filepath = download_dir / filename - # 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() + if filepath.exists(): + console.print(f"[green]Using cached image: {filepath}[/green]") + return filepath - # 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"[cyan]Downloading {os_name} image for {device_name}...[/cyan]") + + with Progress( + TextColumn("[bold blue]{task.description}"), + BarColumn(), + DownloadColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + TimeElapsedColumn(), + console=console, + ) as progress: + # Download with SSL verification enabled + response = requests.get(url, stream=True, timeout=30) + response.raise_for_status() - 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 + total = int(response.headers.get("content-length", 0)) + task = progress.add_task(f"Downloading {filename}", total=total) - # 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, ""): - 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]") + with open(filepath, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + progress.update(task, advance=len(chunk)) + + console.print(f"[green]Downloaded: {filepath}[/green]") + + # Verify SHA256 for Armbian (download .sha file from same location) + if is_armbian: + console.print("[cyan]Verifying image integrity...[/cyan]") + sha_url = f"{url}.sha" + try: + sha_response = requests.get(sha_url, timeout=30) + sha_response.raise_for_status() + + # Parse SHA file - handle different formats + sha_content = sha_response.text.strip() + + # Check if this looks like binary data (not a text SHA file) + if not sha_content.isprintable() or len(sha_content) < 64: + raise ValueError("SHA file does not contain valid text") + + # Try to extract hash - handle formats like: + # "hash filename" or just "hash" + parts = sha_content.split() + if parts: + expected_sha = parts[0] + # Validate it looks like a hex hash (64 chars for SHA256) + if not ( + len(expected_sha) == 64 + and all(c in "0123456789abcdefABCDEF" for c in expected_sha) + ): + raise ValueError(f"Invalid SHA256 hash format: {expected_sha[:20]}...") + else: + raise ValueError("Could not extract hash from SHA file") - # Prompt user - user_input = Prompt.ask(prompt_config["prompt"], default="", show_default=False) - config[key] = user_input if user_input else None + # Calculate actual SHA256 + import hashlib - return config + sha256_hash = hashlib.sha256() + with open(filepath, "rb") as f: + for byte_block in iter(lambda: f.read(8192), b""): + sha256_hash.update(byte_block) + actual_sha = sha256_hash.hexdigest() + + if actual_sha == expected_sha: + console.print("[green]✓ SHA256 verification passed[/green]") + else: + console.print("[red]✗ SHA256 verification failed![/red]") + console.print(f"[red]Expected: {expected_sha}[/red]") + console.print(f"[red]Got: {actual_sha}[/red]") + filepath.unlink() # Delete corrupted file + sys.exit(1) + except Exception as e: + console.print(f"[yellow]Warning: Could not verify SHA256: {e}[/yellow]") + console.print("[yellow]Proceeding anyway, but file integrity is not verified[/yellow]") + + return filepath def flash_image(image_path: Path, device: str) -> None: @@ -561,8 +1181,574 @@ 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_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 (legacy interface).""" console.print() console.print("[cyan]Configuring boot partition...[/cyan]") @@ -742,69 +1928,400 @@ def configure_boot_partition(device: str, config: dict[str, Any]) -> None: # no ) console.print("[green]✓ GPIO debugging enabled[/green]") - # Copy installer script if requested - if config.get("copy_installer"): - install_script = Path(__file__).parent / "install.sh" - if install_script.exists(): + # 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]") + + # Clone Waveshare ePaper library to boot partition for offline installation + console.print() + waveshare_dest = boot_mount / "waveshare-epd" + temp_waveshare = Path("/tmp/waveshare_clone") + + # Remove old temp clone if it exists + if temp_waveshare.exists(): + shutil.rmtree(temp_waveshare) + + # Clone only the Python subdirectory using sparse-checkout (~45MB vs full repo) + # This is small enough to fit on the boot partition + with console.status( + "[cyan]Downloading Waveshare ePaper library " + "(Python subdirectory, ~6MB transfer)...[/cyan]" + ): + # Initialize sparse checkout subprocess.run( - ["sudo", "cp", str(install_script), str(boot_mount / "install.sh")], + [ + "git", + "clone", + "--depth", + "1", + "--filter=blob:none", + "--no-checkout", + "--quiet", + "https://github.com/waveshareteam/e-Paper.git", + str(temp_waveshare), + ], 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]" + + # Configure sparse checkout for Python subdirectory only + subprocess.run( + ["git", "-C", str(temp_waveshare), "sparse-checkout", "init", "--cone"], + check=True, + stdout=subprocess.DEVNULL, + ) + subprocess.run( + [ + "git", + "-C", + str(temp_waveshare), + "sparse-checkout", + "set", + "RaspberryPi_JetsonNano/python", + ], + check=True, + stdout=subprocess.DEVNULL, + ) + subprocess.run( + ["git", "-C", str(temp_waveshare), "checkout", "--quiet"], + check=True, ) - # Create BirdNET-Pi pre-configuration file if any settings provided - birdnet_config_lines = ["# BirdNET-Pi boot configuration"] - has_birdnet_config = False + # Copy only the Python subdirectory to boot partition + python_dir = temp_waveshare / "RaspberryPi_JetsonNano" / "python" + subprocess.run( + ["sudo", "cp", "-r", str(python_dir), str(waveshare_dest)], + check=True, + ) + shutil.rmtree(temp_waveshare) + console.print("[green]✓ Waveshare ePaper library downloaded to boot partition[/green]") + + # Copy installer script if requested + 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)": + console.print() + console.print("[cyan]Installing LibreComputer Raspbian Portability Script...[/cyan]") - if config.get("birdnet_device_name"): - birdnet_config_lines.append(f"device_name={config['birdnet_device_name']}") - has_birdnet_config = True + # Clone the portability repo to boot partition + lrp_dest = boot_mount / "lrp" + temp_clone = Path("/tmp/lrp_clone") - if config.get("birdnet_latitude"): - birdnet_config_lines.append(f"latitude={config['birdnet_latitude']}") - has_birdnet_config = True + # Remove any existing temp directory + if temp_clone.exists(): + subprocess.run(["rm", "-rf", str(temp_clone)], check=True) - if config.get("birdnet_longitude"): - birdnet_config_lines.append(f"longitude={config['birdnet_longitude']}") - has_birdnet_config = 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, + ) - if config.get("birdnet_timezone"): - birdnet_config_lines.append(f"timezone={config['birdnet_timezone']}") - has_birdnet_config = True + # 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" + # 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' + ) + 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", + ) - if config.get("birdnet_language"): - birdnet_config_lines.append(f"language={config['birdnet_language']}") - has_birdnet_config = True + # 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 "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..." +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 + 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 - if has_birdnet_config: - temp_birdnet_config = Path("/tmp/birdnetpi_config.txt") - temp_birdnet_config.write_text("\n".join(birdnet_config_lines) + "\n") +""" + # 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) + + # 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", + oneshot_content, + 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]" + ) + else: + console.print("[yellow]Warning: Could not find version check to patch[/yellow]") + + # 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", "cp", str(temp_birdnet_config), str(boot_mount / "birdnetpi_config.txt")], + ["sudo", "chmod", "+x", str(boot_mount / "lepotato_setup.sh")], check=True, ) - temp_birdnet_config.unlink() - console.print("[green]✓ BirdNET-Pi configuration written to boot partition[/green]") + 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]") + + # 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" ) -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: # noqa: C901 """Flash Raspberry Pi OS to SD card and configure for BirdNET-Pi.""" console.print() console.print( @@ -815,30 +2332,28 @@ 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]") + # Run TUI wizard to gather configuration + config = run_configuration_wizard() + + # Handle cancellation + if config is None: + console.print("[yellow]Configuration cancelled[/yellow]") + return - # Select device - device = select_device() + # Print configuration summary to terminal scrollback + print_config_summary(config) - # Select Pi version - pi_version = select_pi_version() + # 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 - config = get_config_from_prompts(saved_config) - - # Save configuration if requested - if save_config_flag or ( - not saved_config and Confirm.ask("Save this configuration for future use?") - ): - save_config(config) + image_path = download_image_new(os_key, device_key, download_dir) # Flash image console.print() @@ -856,22 +2371,55 @@ def main(save_config_flag: bool) -> None: flash_image(image_path, device) # Configure boot partition - configure_boot_partition(device, config) + image_info = OS_IMAGES[os_key]["devices"][device_key] + is_armbian = image_info.get("is_armbian", False) + 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: + # 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"Raspberry Pi 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]", @@ -883,8 +2431,46 @@ 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 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" + ) + 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]" + ) + # 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") summary_parts.append( "[dim]Insert the SD card into your Raspberry Pi and power it on.\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..beda513e --- /dev/null +++ b/install/flasher_tui.py @@ -0,0 +1,1417 @@ +"""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 # 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 +# ============================================================================ + + +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) diff --git a/install/install.sh b/install/install.sh index 2d6b8746..39dc2aa8 100644 --- a/install/install.sh +++ b/install/install.sh @@ -6,17 +6,73 @@ # # Or download first: # curl -fsSL -o install.sh && bash install.sh +# +# Test ePaper HAT only: +# bash install.sh --test-epaper 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" -# Check if running as root +# Parse command line arguments +TEST_EPAPER=false +if [ "$1" = "--test-epaper" ]; then + TEST_EPAPER=true +fi + +# 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 "========================================" @@ -29,10 +85,42 @@ 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 -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 @@ -60,15 +148,123 @@ 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 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 (or force for test mode) +EPAPER_EXTRAS="" +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 + echo "No e-paper HAT detected, skipping epaper extras" +fi + +# 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 + # 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 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 + +# 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 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 UV_HTTP_TIMEOUT=300 /opt/uv/uv lock --quiet + + echo "✓ Configured to use local Waveshare library" +fi + +# Install Python dependencies with retry mechanism (for network issues) +echo "Installing Python dependencies..." +cd "$INSTALL_DIR" +UV_CMD="sudo -u birdnetpi UV_HTTP_TIMEOUT=300 UV_EXTRA_INDEX_URL=https://www.piwheels.org/simple /opt/uv/uv sync --locked --no-dev --quiet" +if [ -n "$EPAPER_EXTRAS" ]; then + UV_CMD="$UV_CMD $EPAPER_EXTRAS" +fi + +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)) + 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." + exit 1 + fi + 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) echo "" echo "Starting installation..." -cd "$INSTALL_DIR" -python3.11 "$INSTALL_DIR/install/setup_app.py" +"$INSTALL_DIR/.venv/bin/python" "$INSTALL_DIR/install/setup_app.py" diff --git a/install/setup_app.py b/install/setup_app.py index d94c4a24..22090b4d 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 @@ -9,6 +10,54 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from pathlib import Path +from typing import Any, ClassVar, 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 + + +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() @@ -69,6 +118,68 @@ def get_ip_address() -> str: return "unknown" +def detect_device_specs() -> DeviceSpecs: + """Detect device type and memory specifications. + + Returns: + 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: + 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 = [ @@ -137,79 +248,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 has_waveshare_epaper_hat() -> bool: """Detect if a Waveshare e-paper HAT is connected. @@ -227,45 +265,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 - - # 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: - 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" @@ -289,38 +288,70 @@ 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 - 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, ) - # Copy our optimized Redis configuration - subprocess.run( - ["sudo", "cp", str(repo_root / "config_templates" / "redis.conf"), str(redis_conf)], - check=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - subprocess.run( - ["sudo", "chown", "redis:redis", str(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: @@ -374,6 +405,81 @@ def configure_caddy() -> None: ) +def disable_unnecessary_services(total_ram_mb: int) -> None: + """Disable unnecessary system services and swap 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) - 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 + 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/" @@ -431,14 +537,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", }, - { + ] + + # Conditionally add epaper display service if hardware detected + if has_waveshare_epaper_hat(): + log("ℹ", "Installing epaper display service (hardware detected)") # noqa: RUF001 + 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 for service_config in services: service_name = service_config["name"] @@ -503,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, @@ -546,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 @@ -580,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}") @@ -605,20 +693,69 @@ def show_final_summary(ip_address: str) -> None: print("=" * 60) +class _SubprocessWrapper: + """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, converting sudo -u to su when running as root.""" + if isinstance(cmd, list) and cmd and cmd[0] == "sudo": + user = None + actual_cmd = [] + i = 1 + + # Parse sudo arguments + while i < len(cmd): + 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("-"): + i += 1 # Skip other flags + else: + # Found the actual command + actual_cmd = cmd[i:] + break + + 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: + return getattr(self._original, name) + + def main() -> None: """Run the main installer with parallel execution.""" - # Check not running as root - 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.") + global 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) print("BirdNET-Pi SBC Installer") print("=" * 60) print() + # 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) print() @@ -631,19 +768,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( @@ -660,6 +786,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 = [ diff --git a/install/test_epaper.py b/install/test_epaper.py new file mode 100755 index 00000000..46db9398 --- /dev/null +++ b/install/test_epaper.py @@ -0,0 +1,217 @@ +#!/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 +from typing import Any + + +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 # type: ignore[import-not-found] + + 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[Any, 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 + # Try B (three-color) versions first as they're more common + models = [ + "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 + "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: Any, 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: 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) + + # 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...") + # 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() # type: ignore[attr-defined] + epd.Clear() # type: ignore[attr-defined] + + print(" Putting display to 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.") + 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 or not model: + 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..02912ebd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,9 +89,12 @@ dependencies = [ [project.optional-dependencies] epaper = [ - "waveshare-epd @ git+https://github.com/waveshareteam/e-Paper.git#subdirectory=RaspberryPi_JetsonNano/python", + "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'" + "spidev>=3.6; platform_machine=='armv7l' or platform_machine=='aarch64'", + "Pillow>=10.0.0; platform_machine=='armv7l' or platform_machine=='aarch64'" ] [project.scripts] @@ -188,9 +191,13 @@ 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"} [tool.setuptools.packages.find] where = ["src"] + +[tool.uv.sources] +waveshare-epd = {git = "https://github.com/waveshareteam/e-Paper.git", subdirectory = "RaspberryPi_JetsonNano/python"} diff --git a/src/birdnetpi/cli/setup_system.py b/src/birdnetpi/cli/setup_system.py index f3f8d516..bccb5ab0 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 @@ -148,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. @@ -369,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], @@ -426,6 +554,64 @@ 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 + + # Try timedatectl first (requires systemd/DBus) + 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}") + 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: + click.echo(f" ! Error setting system timezone: {e}") + + @click.command() @click.option( "--non-interactive", @@ -470,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) @@ -480,6 +677,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!") diff --git a/src/birdnetpi/display/epaper.py b/src/birdnetpi/display/epaper.py index a6ed1ff5..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 @@ -74,6 +75,14 @@ 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 + + # 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) @@ -117,9 +126,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 @@ -181,8 +197,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 { @@ -237,10 +264,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) @@ -248,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}%", @@ -280,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 @@ -354,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}", @@ -371,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: @@ -400,53 +473,117 @@ 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, 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 - # 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: - 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)") + if self._is_color_display: + self._update_3color_display(black_image, red_image) else: - # 2-color display: send only black buffer - self._epd.display(self._epd.getbuffer(black_image)) - logger.debug("2-color display updated") + 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. @@ -491,18 +628,30 @@ 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 ) 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 - 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") 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"