From 064ba9655aa162401e726e0ffd2a31919377b290 Mon Sep 17 00:00:00 2001 From: OlivierChiasson Date: Fri, 1 May 2026 17:25:23 -0300 Subject: [PATCH] Rebase to flake parts #4 --- modules/hosts/14900k/_private/displays.nix | 92 ++ modules/hosts/14900k/_private/nvidia.nix | 26 + modules/hosts/14900k/_private/peripherals.nix | 12 + modules/hosts/14900k/_private/platform.nix | 14 + .../hosts/uConsole/_private/4g/default.nix | 37 + .../uConsole/_private/4g/uconsole-4g-cm5.sh | 1415 +++++++++++++++++ .../hosts/uConsole/_private/activation.nix | 16 + modules/hosts/uConsole/_private/platform.nix | 48 + modules/hosts/uConsole/_private/services.nix | 6 + modules/lib/ssh-inventory.nix | 113 ++ modules/system/caching/attic.nix | 240 +++ 11 files changed, 2019 insertions(+) create mode 100644 modules/hosts/14900k/_private/displays.nix create mode 100644 modules/hosts/14900k/_private/nvidia.nix create mode 100644 modules/hosts/14900k/_private/peripherals.nix create mode 100644 modules/hosts/14900k/_private/platform.nix create mode 100644 modules/hosts/uConsole/_private/4g/default.nix create mode 100644 modules/hosts/uConsole/_private/4g/uconsole-4g-cm5.sh create mode 100644 modules/hosts/uConsole/_private/activation.nix create mode 100644 modules/hosts/uConsole/_private/platform.nix create mode 100644 modules/hosts/uConsole/_private/services.nix create mode 100644 modules/lib/ssh-inventory.nix create mode 100644 modules/system/caching/attic.nix diff --git a/modules/hosts/14900k/_private/displays.nix b/modules/hosts/14900k/_private/displays.nix new file mode 100644 index 0000000..2b69386 --- /dev/null +++ b/modules/hosts/14900k/_private/displays.nix @@ -0,0 +1,92 @@ +# Monitor layout for 14900k (ported from NixOS-New `hosts/clients/14900k/home.nix`). +# Niri: `chiasson.desktop.niri.extraSettings.extraConfig` (wrapper-modules / system package). +# Hyprland: `chiasson.desktop.hyprland.settings` (merged in HM when `chiasson.desktop.hyprland.enable`). + +#TODO[epic=Moderate] Clean this up, move to host's configuration.nix. +{ config, lib, ... }: +let + gpuPassthrough = config.chiasson.system.vm.gpuPassthrough.enable; +in +{ + chiasson.desktop.niri.extraSettings.extraConfig = + if gpuPassthrough then + '' + output "DP-1" { + mode "2560x1080@144" + scale 1.0 + position x=1920 y=0 + focus-at-startup + } + output "HDMI-A-2" { + mode "1920x1080@60" + scale 1.0 + position x=0 y=0 + } + '' + else + '' + output "DP-2" { + mode "2560x1080@144" + scale 1.0 + position x=0 y=0 + focus-at-startup + } + output "HDMI-A-3" { + mode "1920x1080@60" + scale 1.0 + position x=-1920 y=0 + } + output "DP-4" { + mode "1920x1080@144" + scale 1.0 + position x=0 y=-1080 + } + + ''; + + chiasson.desktop.hyprland.settings = lib.mkIf config.chiasson.desktop.hyprland.enable ( + let + monitorList = + if gpuPassthrough then + [ + "DP-1, 2560x1080@144, 0x0, 1" + "HDMI-A-2, 1920x1080@60, auto-up, 1" + ] + else + [ + "DP-2, 2560x1080@144, 0x0, 1" + "DP-4, 1920x1080@144, 0x-1080, 1" + "HDMI-A-3, 1920x1080@60, -1920x0, 1" + ]; + workspaceList = + if gpuPassthrough then + [ + "1, monitor:DP-1, default:true" + "2, monitor:DP-1" + "3, monitor:DP-1" + "4, monitor:DP-1" + "5, monitor:HDMI-A-2, default:true" + "6, monitor:HDMI-A-2" + "7, monitor:HDMI-A-2" + "8, monitor:HDMI-A-2" + "9, monitor:DP-1" + ] + else + [ + "1, monitor:DP-3, default:true" + "2, monitor:DP-3" + "3, monitor:DP-3" + "4, monitor:Unknown-2, default:true" + "5, monitor:Unknown-2" + "6, monitor:Unknown-2" + "7, monitor:DP-4" + "8, monitor:DP-4" + "9, monitor:DP-4" + ]; + in + { + monitor = lib.mkBefore monitorList; + workspace = workspaceList; + } + ); +} diff --git a/modules/hosts/14900k/_private/nvidia.nix b/modules/hosts/14900k/_private/nvidia.nix new file mode 100644 index 0000000..123f653 --- /dev/null +++ b/modules/hosts/14900k/_private/nvidia.nix @@ -0,0 +1,26 @@ +# NVIDIA for host desktop; when `chiasson.system.vm.gpuPassthrough` is enabled, drop NVIDIA for VFIO (port later). +{ config, lib, pkgs, ... }: +let + passthrough = config.chiasson.system.vm.gpuPassthrough.enable; +in +{ + boot.kernelParams = [ "snd_hda_core.gpu_bind=0" ]; + boot.kernelPackages = lib.mkDefault pkgs.linuxPackages_latest; + + services.xserver.videoDrivers = if passthrough then [ "modesetting" ] else [ "nvidia" ]; + + hardware.nvidia = + if passthrough then + lib.mkForce { } + else { + modesetting.enable = true; + powerManagement.enable = false; + powerManagement.finegrained = false; + open = true; + nvidiaSettings = true; + package = config.boot.kernelPackages.nvidiaPackages.stable; + }; + + # Needed for `docker compose` GPU passthrough (e.g. `--gpus all` / DEVICE=gpu). + hardware.nvidia-container-toolkit.enable = !passthrough; +} diff --git a/modules/hosts/14900k/_private/peripherals.nix b/modules/hosts/14900k/_private/peripherals.nix new file mode 100644 index 0000000..a5261de --- /dev/null +++ b/modules/hosts/14900k/_private/peripherals.nix @@ -0,0 +1,12 @@ +# Logitech Unifying / Bolt receivers; Keychron VIA/VIAL on hidraw. +{ ... }: +{ + hardware.logitech.wireless.enable = true; + + services.udev.extraRules = '' + # Keychron VIA/VIAL on hidraw + KERNEL=="hidraw*", ATTRS{idVendor}=="3434", MODE="0660", GROUP="users", TAG+="uaccess", TAG+="udev-acl" + # PS5 DualSense & DualSense Edge controllers over USB hidraw + KERNEL=="hidraw*", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0ce6|0df2", MODE="0660", TAG+="uaccess" + ''; +} diff --git a/modules/hosts/14900k/_private/platform.nix b/modules/hosts/14900k/_private/platform.nix new file mode 100644 index 0000000..72705ee --- /dev/null +++ b/modules/hosts/14900k/_private/platform.nix @@ -0,0 +1,14 @@ +{ ... }: { + #TODO[epic=Moderate] Clean this up, move to host's configuration.nix. + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + + powerManagement.cpuFreqGovernor = "performance"; + + hardware.enableRedistributableFirmware = true; + hardware.enableAllFirmware = true; + + nix.settings.experimental-features = [ "nix-command" "flakes" ]; + + system.stateVersion = "25.11"; +} diff --git a/modules/hosts/uConsole/_private/4g/default.nix b/modules/hosts/uConsole/_private/4g/default.nix new file mode 100644 index 0000000..b298d08 --- /dev/null +++ b/modules/hosts/uConsole/_private/4g/default.nix @@ -0,0 +1,37 @@ +{ lib, pkgs, ... }: +let + uconsole4gRuntime = with pkgs; [ + bash + coreutils + gnugrep + gnused + gawk + util-linux + usbutils + pciutils + lsb-release + libgpiod + modemmanager + iproute2 + iputils + busybox + socat + systemd + ]; + + # Use a lightweight wrapper to avoid ShellCheck gating the build. + uconsole4gCm5 = pkgs.writeShellScriptBin "uconsole-4g-cm5" '' + export PATH=${lib.makeBinPath uconsole4gRuntime}:$PATH + exec ${pkgs.bash}/bin/bash ${./uconsole-4g-cm5.sh} "$@" + ''; +in +{ + networking.modemmanager.enable = true; + + environment.systemPackages = with pkgs; [ + libgpiod + uconsole4gCm5 + socat + ripgrep + ]; +} diff --git a/modules/hosts/uConsole/_private/4g/uconsole-4g-cm5.sh b/modules/hosts/uConsole/_private/4g/uconsole-4g-cm5.sh new file mode 100644 index 0000000..cacf3ff --- /dev/null +++ b/modules/hosts/uConsole/_private/4g/uconsole-4g-cm5.sh @@ -0,0 +1,1415 @@ +#!/usr/bin/env bash + +# 4G Modem Control Script for uConsole CM5 +# Optimized version for CM5 with Bookworm Ubuntu + +# --- Configuration --- +# Logging +LOG_FILE_BASE="/tmp/uconsole-4g" # Base name for log files (date/time will be appended) +DEBUG=1 # Enable debug logging (1=yes, 0=no) + +# GPIO Pins - Specific for CM5 +GPIO_CHIP="gpiochip0" +GPIO_MODEM_POWER=24 # POWER_MCU for CM5 +GPIO_MODEM_RESET=15 # RESET_MCU for CM5 + +# Modem/Network Settings +DEFAULT_APNS=("internet" "data") # List of APNs to try for connection +MODEM_INIT_WAIT_SECONDS=30 # Time (seconds) to wait for modem initialization after power on +MODEM_DETECT_ATTEMPTS=5 # Number of attempts to detect modem after enabling +MODEM_DETECT_DELAY_SECONDS=5 # Delay (seconds) between detection attempts +CONNECT_BEARER_WAIT_SECONDS=10 # Time (seconds) to wait for bearer connection +DHCLIENT_TIMEOUT_SECONDS=15 # Timeout (seconds) for dhclient +UDHCPC_TIMEOUT_SECONDS=10 # Timeout (seconds) for udhcpc (if used) +PING_TARGET="8.8.8.8" # IP address for connectivity tests +PING_COUNT=1 +PING_TIMEOUT_SECONDS=5 + +# Retry Logic Defaults +RETRY_MAX_ATTEMPTS=3 +RETRY_DELAY_SECONDS=2 + +# --- Strict Mode & Error Handling --- +# Exit immediately if a command exits with a non-zero status. +# Treat unset variables as an error when substituting. +# Pipelines return the exit status of the last command to exit non-zero. +set -euo pipefail + +# --- Global Variables --- +# Log file path will be set in main execution flow +LOG_FILE="" +SCRIPT_NAME="$(basename "$0")" + +# --- Logging Functions --- +_log() { + local level="$1" + local message="$2" + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + # Log to file + printf "[%s] [%s] %s\n" "$timestamp" "$level" "$message" >> "$LOG_FILE" +} + +log_info() { + echo "[INFO] $1" + _log "INFO" "$1" +} + +log_warning() { + echo "[WARNING] $1" >&2 + _log "WARNING" "$1" +} + +log_error() { + # Log to stderr and file + echo "[ERROR] $1" >&2 + _log "ERROR" "$1" +} + +log_debug() { + # Log debug to stderr if DEBUG=1, similar to log_error + if [[ "$DEBUG" -eq 1 ]]; then + echo "[DEBUG] $1" >&2 + fi + # Always log debug messages to file regardless of DEBUG setting + _log "DEBUG" "$1" +} + +# --- Safe Command Execution & Logging --- +# Executes a command, logs details, and returns its exit status. +# Usage: run_command command arg1 arg2 ... +# Output (stdout and stderr combined) is captured and printed to stdout. +run_command() { + local cmd_str + cmd_str=$(printf '%q ' "$@") # Safely quote command and args for logging + local output # Combined stdout and stderr + local status=0 + + log_debug "Executing command: $cmd_str" + + # Execute command, capturing combined stdout and stderr + # This avoids subshell scope issues with set -u + output=$("$@" 2>&1) + status=$? + + # Print combined output to the console + if [[ -n "$output" ]]; then + echo "$output" + fi + + # Log command details + log_debug "Command finished with status: $status" + _log "CMD" "($status) $cmd_str" + if [[ -n "$output" ]]; then + # Log combined output + _log "OUTPUT/STDERR" "$output" + fi + + return $status +} + +# --- Retry Logic --- +# Retries a command if it fails. +# Usage: retry_command max_attempts delay_seconds command arg1 arg2 ... +retry_command() { + local max_attempts="$1" + local delay="$2" + shift 2 # Remove max_attempts and delay from arguments + local cmd_str + cmd_str=$(printf '%q ' "$@") # Safely quote command and args for logging + local attempt=1 + local status=0 + + log_debug "Trying command with retry: $cmd_str (max attempts: $max_attempts, delay: $delay)" + + while [[ $attempt -le $max_attempts ]]; do + log_debug "Attempt $attempt/$max_attempts: $cmd_str" + # Use run_command to execute and log safely + if run_command "$@"; then + log_debug "Command succeeded on attempt $attempt" + return 0 + else + status=$? # Capture the exit status of the failed command + log_error "Command failed on attempt $attempt with status $status" + if [[ $attempt -lt $max_attempts ]]; then + log_debug "Waiting $delay seconds before retrying..." + sleep "$delay" + fi + fi + attempt=$((attempt + 1)) + done + + log_error "Command failed after $max_attempts attempts: $cmd_str" + return $status # Return the status of the last failed attempt +} + +# --- Helper Functions --- + +# Check if essential commands are available +_check_dependencies() { + local missing=0 + for cmd in "$@"; do + if ! command -v "$cmd" &>/dev/null; then + log_error "$cmd command not found. Please ensure required packages (like gpiod, ModemManager, iproute2) are installed." + missing=1 + fi + done + return $missing +} + +# Find the first available ModemManager modem index +_find_modem_index() { + local modem_list + local modem_index="" + + log_debug "Attempting to find modem index..." + # Run mmcli -L safely, suppress stderr on initial check + if modem_list=$(mmcli -L 2>/dev/null); then + # Extract the exact modem index after "Modem/" + modem_index=$(echo "$modem_list" | grep -o "/org/freedesktop/ModemManager1/Modem/[0-9]*" | sed 's/.*Modem\///' || true) + fi + + if [[ -z "$modem_index" ]]; then + log_debug "No modem found in standard list, trying fallback indices..." + for idx in 0 1 2 3; do + log_debug "Checking modem index $idx" + # Check if modem exists without producing error output if it doesn't + if mmcli -m "$idx" --command="" &>/dev/null; then + modem_index="$idx" + log_info "Found modem at index $modem_index via fallback check." + break + fi + done + fi + + if [[ -n "$modem_index" ]]; then + log_debug "Found modem index: $modem_index" + # Only print the index to stdout for command substitution + echo "$modem_index" + return 0 + else + log_error "Could not find any modem index." + # Ensure nothing is printed to stdout on failure + return 1 + fi +} + +# Find the cellular network interface name (e.g., wwan0, rmnet_data0) +_find_wwan_interface() { + local interface="" + log_debug "Detecting network interface..." + + # Prioritize 'wwan' interfaces + interface=$(ip link | grep -A 1 'wwan' | grep -o "wwan[0-9]*" | head -1 || true) + + if [[ -z "$interface" ]]; then + log_debug "No wwan interface found, trying other common names (rmnet, ppp, usb)..." + # Look for interfaces associated with common cellular drivers/protocols + interface=$(ip link | grep -E 'rmnet|ppp|usb' | head -1 | awk -F': ' '{print $2}' | awk '{print $1}' || true) + fi + + if [[ -n "$interface" ]]; then + log_info "Detected network interface: $interface" + # Return ONLY the interface name, not the log message + echo "$interface" + return 0 + else + # Fallback to a default if absolutely necessary, but log a warning + log_warning "No specific cellular interface detected, using default 'wwan0'. This might need adjustment." + echo "wwan0" + return 0 # Return success, but the interface might not be correct + fi +} + +# Check SIM lock status for a given modem index +_check_sim_lock() { + local modem_index="$1" + local modem_info + local lock_status="unlocked" # Assume unlocked unless proven otherwise + + log_debug "Checking SIM lock status for modem $modem_index" + if ! modem_info=$(mmcli -m "$modem_index" 2>/dev/null); then + log_error "Could not retrieve modem information for index $modem_index to check SIM lock." + # Can't determine status, assume unlocked but log error + return 0 + fi + + # Extract and log unlock retries information regardless of lock status + local retries + retries=$(echo "$modem_info" | grep "unlock retries" || echo "Unlock retries: Unknown") + + # Be more specific in checking for sim-pin (PIN1) lock + # Only match exact "lock: sim-pin" pattern, not sim-pin2 + if echo "$modem_info" | grep -qE "lock: sim-pin([^2]|$)"; then + lock_status="locked" + log_error "SIM card is locked (pin). $retries" + log_error "Critical SIM lock detected: sim-pin. Connection may fail." + return 1 # Return locked status for PIN1 + elif echo "$modem_info" | grep -q "lock: sim-pin2"; then + lock_status="locked" + log_error "SIM card is locked (pin2). $retries" + log_info "Non-critical SIM lock detected: sim-pin2. Attempting to connect anyway." + # Logged the error, but return success (0) so connect can proceed + return 0 + else + log_debug "SIM card appears to be unlocked. $retries" + return 0 # Indicates unlocked + fi +} + +# Check if ModemManager service is running, optionally restart it +_ensure_modemmanager_active() { + log_debug "Checking ModemManager service status..." + if systemctl is-active --quiet ModemManager; then + log_debug "ModemManager service is active." + return 0 + else + log_error "ModemManager service is not active." + log_info "Attempting to restart ModemManager service..." + if retry_command "$RETRY_MAX_ATTEMPTS" "$RETRY_DELAY_SECONDS" systemctl restart ModemManager; then + log_info "ModemManager restarted successfully." + log_debug "Waiting a few seconds for service to stabilize..." + sleep 3 + return 0 + else + log_error "Failed to restart ModemManager service." + return 1 + fi + fi +} + +# Helper function to clean interface names (remove log messages, newlines) +_clean_interface_name() { + local dirty_interface="$1" + # Extract just the interface name (wwan0, etc) by removing any log messages and newlines + echo "$dirty_interface" | grep -o "[a-zA-Z0-9_]*[0-9]" | head -1 | tr -d '\n' +} + +# --- GPIO helpers (libgpiod v1 vs v2 CLI compatibility) --- +# NixOS commonly ships libgpiod v2, where `gpioset` holds the line until the +# process exits. To avoid hanging the script, we keep per-line gpioset holder +# processes in the background and replace them when changing the same line. +# +# On older libgpiod (v1), `gpioset =` typically exits +# immediately, so we just call it directly. + +# line -> pid of background holder (libgpiod v2 path) +declare -A GPIOSET_PIDS=() + +_gpioset_stop_holder() { + local line="$1" + local pid="${GPIOSET_PIDS[$line]-}" + if [[ -n "${pid}" ]]; then + log_debug "Stopping existing gpioset holder for line ${line} (pid ${pid})" + kill "${pid}" 2>/dev/null || true + wait "${pid}" 2>/dev/null || true + unset "GPIOSET_PIDS[$line]" + fi +} + +_gpioset_line() { + local line="$1" + local value="$2" + + # Stop any previous holder for this line (v2 path) + _gpioset_stop_holder "$line" + + # libgpiod v2 syntax (non-blocking): keep it running in background + log_debug "Executing command (hold): gpioset --chip \"$GPIO_CHIP\" \"${line}=${value}\"" + gpioset --chip "$GPIO_CHIP" "${line}=${value}" >>"$LOG_FILE" 2>&1 & + local pid=$! + GPIOSET_PIDS["$line"]="$pid" + + # If it immediately died (e.g. old gpioset doesn't understand --chip), + # fall back to legacy syntax. + sleep 0.1 + if kill -0 "$pid" 2>/dev/null; then + return 0 + fi + + # Fallback: libgpiod v1 syntax (Debian-style) + unset "GPIOSET_PIDS[$line]" || true + log_warning "gpioset --chip didn't stay running; retrying with legacy syntax." + run_command gpioset "$GPIO_CHIP" "${line}=${value}" || return $? + return 0 +} + +_gpioinfo_chip() { + # Most versions accept `gpioinfo `, but keep a fallback just in case. + if run_command gpioinfo "$GPIO_CHIP"; then + return 0 + fi + log_warning "gpioinfo failed; retrying with --chip." + run_command gpioinfo --chip "$GPIO_CHIP" +} + +# --- Core Functions --- + +function show_help { + echo "uConsole 4G Modem Manager for CM5" + echo "===============================" + printf "Usage: %s [COMMAND]\n" "$SCRIPT_NAME" + echo "" + echo "Commands:" + echo " status - Check the current 4G modem status" + echo " enable - Power on the 4G module" + echo " disable - Power off the 4G module" + echo " connect - Connect to the internet using the 4G modem" + echo " disconnect - Disconnect from the internet" + echo " unlock - Unlock SIM card with PIN code" + echo " diagnose - Run comprehensive modem diagnostics" + echo " reset - Perform a full disconnect, disable, enable, connect cycle" + echo " help - Show this help message" + echo "" + printf "Log file: %s-.log\n" "$LOG_FILE_BASE" + echo "" +} + +function check_modem_status { + log_info "Checking 4G modem status..." + local modem_index + local interface + local modem_info + local ip_address + local signal + + if ! _ensure_modemmanager_active; then + return 1 + fi + + modem_index=$(_find_modem_index) || return 1 + log_info "Using modem index: $modem_index" + + # Get modem info once + if ! modem_info=$(mmcli -m "$modem_index" 2>/dev/null); then + log_error "Could not retrieve modem information for index $modem_index." + # Attempt restart as a potential fix + log_info "Attempting to restart ModemManager service..." + if systemctl restart ModemManager &>/dev/null; then + sleep 5 + modem_info=$(mmcli -m "$modem_index" 2>/dev/null) || true + fi + if [[ -z "$modem_info" ]]; then + log_error "Still unable to retrieve modem info after restart attempt." + return 1 + fi + fi + + # Check SIM Lock + if ! _check_sim_lock "$modem_index"; then + log_info "SIM is locked. Use '$SCRIPT_NAME unlock' to unlock." + # Continue checking other statuses + else + log_info "SIM status: Unlocked or N/A" + fi + + # Check Modem Power/Enable State + if echo "$modem_info" | grep -q "state: 'registered'"; then + log_info "Modem state: Registered with network" + elif echo "$modem_info" | grep -q "state: 'connected'"; then + log_info "Modem state: Connected to network" + elif echo "$modem_info" | grep -q "state: 'enabled'"; then + log_info "Modem state: Enabled" + elif echo "$modem_info" | grep -q "state: 'disabled'"; then + log_info "Modem state: Disabled. Use '$SCRIPT_NAME enable' to enable." + else + # Try to extract the state from the modem info + log_info "Modem state: $(echo "$modem_info" | grep "state:" | head -1 | sed 's/.*state: //' || echo "Unknown")" + fi + + # Check Network Connection Status + interface=$(_find_wwan_interface) || interface="wwan0" # Use default if detection fails + # Clean the interface name properly + interface=$(_clean_interface_name "$interface") + log_info "Using network interface: $interface" + + # Check IP address + ip_address=$(ip addr show "$interface" 2>/dev/null | grep "inet " | awk '{print $2}' || true) + if [[ -n "$ip_address" ]]; then + log_info "✓ Connected to network" + log_info " Interface: $interface" + log_info " IP address: $ip_address" + # Check ModemManager bearer status as fallback + elif echo "$modem_info" | grep -q "bearer path"; then + log_info "✓ Connected to network (Bearer active in ModemManager)" + log_info " Interface: $interface (IP address not found via 'ip addr')" + else + log_info "✗ Not connected to network" + fi + + # Get Signal Quality + signal=$(echo "$modem_info" | grep "signal quality" | sed 's/.*signal quality: \([0-9]*\)%.*/\1/g' || true) + if [[ -n "$signal" ]]; then + log_info "Signal quality: $signal%" + else + log_info "Signal quality: Not available" + fi + + return 0 +} + +function enable_4g { + log_info "Powering on the 4G module on CM5..." + if ! _check_dependencies gpioset mmcli systemctl; then return 1; fi + + # Check if already enabled first, before stopping MM + local modem_index + if modem_index=$(_find_modem_index 2>/dev/null); then + if mmcli -m "$modem_index" 2>/dev/null | grep -q -E "state: 'enabled'|state: registered|state: connected"; then + log_info "Modem is already enabled/active." + return 0 + fi + fi + + # Stop ModemManager temporarily to release GPIO control + log_info "Stopping ModemManager temporarily..." + run_command systemctl stop ModemManager + + log_debug "Current GPIO state before enabling:" + _gpioinfo_chip | grep -E "line ${GPIO_MODEM_POWER}:|line ${GPIO_MODEM_RESET}:" || true + + # CM5-specific GPIO sequence based on successful patterns from tests + log_info "Applying CM5-specific GPIO sequence for power on..." + + # Set power pin high first + log_debug "Setting POWER pin (GPIO ${GPIO_MODEM_POWER}) high" + if ! _gpioset_line "$GPIO_MODEM_POWER" 1; then + log_error "Failed to set POWER pin high." + return 1 + fi + + # Set reset pin high + log_debug "Setting RESET pin (GPIO ${GPIO_MODEM_RESET}) high" + if ! _gpioset_line "$GPIO_MODEM_RESET" 1; then + log_error "Failed to set RESET pin high." + return 1 + fi + + # Allow time for GPIO state to stabilize + sleep 5 + + # Pull reset low to toggle the modem + log_debug "Setting RESET pin low (active) to reset modem" + if ! _gpioset_line "$GPIO_MODEM_RESET" 0; then + log_error "Failed to set RESET pin low." + return 1 + fi + + log_info "Waiting for the modem to initialize..." + sleep 13 + + log_debug "Current GPIO state after enabling sequence:" + _gpioinfo_chip | grep -E "line ${GPIO_MODEM_POWER}:|line ${GPIO_MODEM_RESET}:" || true + + # Wait for USB enumeration before restarting ModemManager + log_debug "Waiting 3 seconds for USB enumeration..." + sleep 3 + + # Restart ModemManager + log_info "Restarting ModemManager..." + if ! run_command systemctl start ModemManager; then + log_error "Failed to restart ModemManager after GPIO sequence!" + return 1 + fi + log_debug "ModemManager started. Waiting for it to initialize..." + sleep 5 # Give MM time to start up + + log_info "Waiting for the modem to be detected..." + local attempt + for attempt in $(seq 1 "$MODEM_DETECT_ATTEMPTS"); do + if _find_modem_index &>/dev/null; then + log_info "✓ 4G modem successfully detected/enabled on attempt $attempt." + run_command mmcli -L + return 0 + else + if [[ $attempt -lt $MODEM_DETECT_ATTEMPTS ]]; then + log_info "Modem not detected yet, waiting $MODEM_DETECT_DELAY_SECONDS seconds (attempt $attempt/$MODEM_DETECT_ATTEMPTS)..." + sleep "$MODEM_DETECT_DELAY_SECONDS" + fi + fi + done + + log_error "✗ 4G modem not detected after enabling sequence and retries." + log_debug "Final attempt to detect modems:" + run_command mmcli -L || true + log_debug "USB devices:" + run_command lsusb || true + return 1 +} + +function disable_4g { + log_info "Powering off the 4G module..." + if ! _check_dependencies gpioset mmcli systemctl; then return 1; fi + + local modem_index + local modem_info + + # Try to find modem to disconnect first + if modem_index=$(_find_modem_index 2>/dev/null); then + log_debug "Found modem index: $modem_index" + if modem_info=$(mmcli -m "$modem_index" 2>/dev/null); then + # Check if connected and try to disconnect + if echo "$modem_info" | grep -q "bearer path"; then + log_info "Disconnecting active connection before power off..." + run_command mmcli -m "$modem_index" --simple-disconnect || true + else + log_debug "Modem found but no active connection detected." + fi + else + log_debug "Could not get modem info for index $modem_index, skipping disconnect attempt." + fi + else + log_debug "No modem detected, skipping disconnect attempt." + fi + + # Stop ModemManager temporarily before GPIO manipulation + log_info "Stopping ModemManager temporarily..." + run_command systemctl stop ModemManager + + log_debug "Current GPIO state before disabling:" + _gpioinfo_chip | grep -E "line ${GPIO_MODEM_POWER}:|line ${GPIO_MODEM_RESET}:" || true + + # CM5-specific GPIO sequence for power off + log_info "Applying CM5-specific GPIO sequence for power off..." + + # Toggle power pin (set low, then high after delay) + log_debug "Setting POWER pin low for power off" + if ! _gpioset_line "$GPIO_MODEM_POWER" 0; then + log_error "Failed to set POWER pin low." + # Continue anyway to try complete power off + fi + + sleep 3 + + log_debug "Setting POWER pin high to complete power cycle" + if ! _gpioset_line "$GPIO_MODEM_POWER" 1; then + log_error "Failed to set POWER pin high." + # Continue anyway + fi + + log_debug "Current GPIO state after disabling sequence:" + _gpioinfo_chip | grep -E "line ${GPIO_MODEM_POWER}:|line ${GPIO_MODEM_RESET}:" || true + + # Ensure ModemManager remains stopped after power-off sequence + log_info "Ensuring ModemManager is stopped..." + run_command systemctl is-active --quiet ModemManager && run_command systemctl stop ModemManager + + log_info "Waiting 10 seconds for modem to fully power down..." + sleep 10 + + # Verify modem is no longer detected + log_info "Verifying modem is powered off..." + if mmcli -L &>/dev/null; then + if _find_modem_index &>/dev/null; then + log_error "✗ Modem still detected after disable sequence. Power off may have failed." + run_command mmcli -L || true + return 1 + fi + fi + + log_info "✓ 4G module appears powered off successfully." + return 0 +} + +function unlock_sim { + log_info "Attempting to unlock SIM card..." + if ! _check_dependencies mmcli; then return 1; fi + if ! _ensure_modemmanager_active; then return 1; fi + + local modem_index + modem_index=$(_find_modem_index) || return 1 + log_info "Using modem index: $modem_index" + + local modem_info + if ! modem_info=$(mmcli -m "$modem_index" 2>/dev/null); then + log_error "Could not retrieve modem information for index $modem_index." + return 1 + fi + + local lock_type="" + if echo "$modem_info" | grep -q "lock: sim-pin2"; then + lock_type="pin2" + elif echo "$modem_info" | grep -q "lock: sim-pin"; then + lock_type="pin" + fi + + if [[ -z "$lock_type" ]]; then + log_info "✓ SIM card is already unlocked or does not require a PIN." + return 0 + fi + + local retries + retries=$(echo "$modem_info" | grep "unlock retries" || echo "Unlock retries: Unknown") + log_info "SIM card is locked ($lock_type). $retries" + + local pin_code + # Prompt for PIN using read -s for security + read -sp "Enter SIM $lock_type code: " pin_code + echo # Add a newline after the prompt + + if [[ -z "$pin_code" ]]; then + log_error "No PIN provided. Cannot unlock SIM." + return 1 + fi + + log_info "Attempting to unlock with provided $lock_type..." + local unlock_cmd + if [[ "$lock_type" == "pin2" ]]; then + # For PIN2, we need to specify an action (enable/disable/verify) + # Since we just want to verify/unlock, we'll use --verify-pin2 + unlock_cmd=(mmcli -m "$modem_index" "--verify-pin2=$pin_code") + else + # For regular PIN1 + unlock_cmd=(mmcli -m "$modem_index" "--pin=$pin_code") + fi + + # Use run_command to attempt unlock and log output/errors + if run_command "${unlock_cmd[@]}"; then + # Verify unlock status + sleep 2 # Give modem time to update state + if ! mmcli -m "$modem_index" 2>/dev/null | grep -q "lock: sim-pin[2]*"; then + log_info "✓ SIM card successfully unlocked." + # Check if modem needs re-enabling + if ! mmcli -m "$modem_index" 2>/dev/null | grep -q -E "state: 'enabled'|state: registered|state: connected"; then + log_info "Modem is disabled, attempting to re-enable..." + run_command mmcli -m "$modem_index" --enable || log_error "Failed to re-enable modem after unlock." + sleep 2 + fi + return 0 + else + log_error "✗ Unlock command succeeded, but modem still reports locked status." + return 1 + fi + else + log_error "✗ Failed to unlock SIM card. Please check your PIN code." + # Display updated unlock retries if possible + modem_info=$(mmcli -m "$modem_index" 2>/dev/null) || true + retries=$(echo "$modem_info" | grep "unlock retries" || echo "Unlock retries: Unknown") + log_info "Updated unlock attempts remaining: $retries" + return 1 + fi +} + +function connect_4g { + log_info "Connecting to the internet using 4G..." + # On NixOS, dhclient may not be available; the script can use udhcpc and/or + # bearer-provided IP info. Don't hard-require dhclient here. + if ! _check_dependencies mmcli ip ping; then return 1; fi + if ! _ensure_modemmanager_active; then return 1; fi + + local modem_index + local interface + + # 1. Ensure modem is enabled + log_info "Step 1: Ensuring modem is enabled..." + if ! mmcli -L &>/dev/null || ! _find_modem_index &>/dev/null; then + log_info "Modem not detected. Attempting to enable..." + if ! enable_4g; then + log_error "Failed to enable modem. Cannot proceed with connection." + return 1 + fi + # Wait a bit longer after explicit enable + log_info "Waiting 10 seconds for modem to be fully ready after enabling..." + sleep 10 + fi + + modem_index=$(_find_modem_index) || return 1 + log_info "Using modem index: $modem_index" + + # Re-check if enabled after potential enable_4g call + if ! mmcli -m "$modem_index" 2>/dev/null | grep -q -E "state: 'enabled'|state: registered|state: connected"; then + log_info "Modem is detected but disabled. Attempting to enable..." + if ! run_command mmcli -m "$modem_index" --enable; then + log_error "Failed to enable modem $modem_index." + return 1 + fi + log_info "Waiting 5 seconds after enabling..." + sleep 5 + fi + + # 2. Check SIM Lock + log_info "Step 2: Checking SIM lock status..." + local sim_lock_result + _check_sim_lock "$modem_index" + sim_lock_result=$? + + if [[ $sim_lock_result -eq 1 ]]; then + log_error "SIM card is locked. Please unlock using '$SCRIPT_NAME unlock' before connecting." + return 1 # Hard fail only for SIM PIN1 lock + fi + log_info "SIM status: OK (PIN1 unlocked or no PIN required)" + + # 3. Check if already connected + log_info "Step 3: Checking existing connection..." + interface=$(_find_wwan_interface) || interface="wwan0" # Use default if detection fails + # Clean the interface name to ensure it's just the name, no logs or newlines + interface=$(_clean_interface_name "$interface") + log_info "Using network interface: $interface" + + if ip addr show "$interface" 2>/dev/null | grep -q "inet "; then + local current_ip + current_ip=$(ip addr show "$interface" 2>/dev/null | grep "inet " | awk '{print $2}') + log_info "✓ Already connected to the internet." + log_info " Interface: $interface" + log_info " IP address: $current_ip" + # Optionally verify with ping + log_info "Verifying connectivity..." + if run_command ping -c "$PING_COUNT" -W "$PING_TIMEOUT_SECONDS" "$PING_TARGET"; then + log_info "✓ Connectivity test successful." + return 0 + else + log_warning "✗ Already have IP, but ping test failed. Proceeding to reconnect..." + # Fall through to attempt reconnection + fi + else + log_info "Not currently connected via IP address." + fi + + # 4. Create Bearer Connection + log_info "Step 4: Creating connection bearer..." + local apn_connected=0 + local apn + # Try configured APNs first + for apn in "${DEFAULT_APNS[@]}"; do + log_info "Attempting connection with APN '$apn'..." + if run_command mmcli -m "$modem_index" --simple-connect="apn=$apn"; then + apn_connected=1 + log_info "✓ Bearer connection initiated with APN '$apn'." + break + else + log_warning "APN '$apn' failed. Trying next..." + fi + done + + # Try default APN if others failed + if [[ $apn_connected -eq 0 ]]; then + log_info "Attempting connection with default APN..." + if run_command mmcli -m "$modem_index" --simple-connect; then + apn_connected=1 + log_info "✓ Bearer connection initiated with default APN." + else + log_error "✗ All APN connection attempts failed." + # Check modem state for clues + run_command mmcli -m "$modem_index" || true + return 1 + fi + fi + + # Wait for bearer to be fully connected + log_info "Waiting up to $CONNECT_BEARER_WAIT_SECONDS seconds for bearer connection..." + local connected_state=0 + for i in $(seq 1 "$CONNECT_BEARER_WAIT_SECONDS"); do + printf "\rWaiting for connection... %2d/%d" "$i" "$CONNECT_BEARER_WAIT_SECONDS" + + # Check multiple state indicators that could indicate connection + if mmcli -m "$modem_index" 2>/dev/null | grep -q -E "state: connected|packet service state: attached|initial bearer"; then + connected_state=1 + echo # Newline + log_info "✓ Bearer connection detected." + break + fi + sleep 1 + done + + # Don't fail just because we can't detect the state correctly + # Let's check for a bearer path directly instead + if [[ $connected_state -eq 0 ]]; then + echo # Newline + log_warning "Didn't detect 'connected' state in time window, but continuing anyway..." + local bearer_info + if bearer_info=$(run_command mmcli -m "$modem_index" --bearer 2>&1) && + echo "$bearer_info" | grep -q "Bearer"; then + log_info "✓ Bearer exists, proceeding with connection" + connected_state=1 + else + run_command mmcli -m "$modem_index" --list-bearers || true + bearer_index=$(mmcli -m "$modem_index" --list-bearers 2>/dev/null | grep -o "/org/freedesktop/ModemManager1/Bearer/[0-9]*" | grep -o "[0-9]*" | head -1 || true) + if [[ -n "$bearer_index" ]]; then + log_info "✓ Found bearer $bearer_index, proceeding with connection" + connected_state=1 + else + log_error "✗ No bearer found after connecting. Connection appears to have failed." + run_command mmcli -m "$modem_index" || true # Show final state + return 1 + fi + fi + fi + + # 5. Configure Network Interface + log_info "Step 5: Configuring network interface $interface..." + + # Double check interface is clean using our helper function + interface=$(_clean_interface_name "$interface") + + # Ensure interface exists and is up + log_debug "Ensuring interface $interface is up..." + if ! retry_command "$RETRY_MAX_ATTEMPTS" "$RETRY_DELAY_SECONDS" ip link set "$interface" up; then + log_error "Could not bring interface $interface up. Network configuration might fail." + # Attempt to continue, DHCP might still work + else + log_debug "Interface $interface is up." + fi + sleep 1 # Short delay after bringing interface up + + # Flush any old IP addresses + log_debug "Flushing existing IP addresses from $interface..." + run_command ip addr flush dev "$interface" || log_warning "Failed to flush IP addresses (maybe interface was down)." + + # For QMI modems in raw-IP mode, DHCP clients often fail because they expect an Ethernet device + # Instead, we should use the IP information from the ModemManager bearer directly + log_info "Configuring network using IP information from ModemManager bearer..." + + # Find active bearer - we need to search more thoroughly + local bearer_index + local bearer_info + local found_bearer=0 + + # First method: Check the modem's list-bearers + log_debug "Searching for active bearers using mmcli -m $modem_index --list-bearers" + if bearer_index=$(mmcli -m "$modem_index" --list-bearers 2>/dev/null | grep -o "/org/freedesktop/ModemManager1/Bearer/[0-9]*" | grep -o "[0-9]*" | head -1); then + log_info "Found bearer $bearer_index from list-bearers" + if bearer_info=$(mmcli -b "$bearer_index" 2>/dev/null) && [[ -n "$bearer_info" ]]; then + log_info "Using bearer $bearer_index to extract IP configuration" + found_bearer=1 + fi + fi + + # Second method: Check for initial bearer in modem info + if [[ $found_bearer -eq 0 ]]; then + log_debug "Searching for initial bearer in modem info" + local modem_info + modem_info=$(mmcli -m "$modem_index" 2>/dev/null) + bearer_index=$(echo "$modem_info" | grep "initial bearer path" | grep -o "/org/freedesktop/ModemManager1/Bearer/[0-9]*" | grep -o "[0-9]*" | head -1 || true) + + if [[ -n "$bearer_index" ]]; then + log_info "Found initial bearer $bearer_index from modem info" + if bearer_info=$(mmcli -b "$bearer_index" 2>/dev/null) && [[ -n "$bearer_info" ]]; then + log_info "Using bearer $bearer_index to extract IP configuration" + found_bearer=1 + fi + fi + fi + + # Third method: Try to directly check some common bearer indexes + if [[ $found_bearer -eq 0 ]]; then + log_debug "Trying common bearer indexes directly" + for idx in 1 2 3 0; do + if bearer_info=$(mmcli -b "$idx" 2>/dev/null) && [[ -n "$bearer_info" ]]; then + log_info "Found active bearer with index $idx by direct check" + bearer_index=$idx + found_bearer=1 + break + fi + done + fi + + # If we still haven't found a bearer, try one more approach from the logs + if [[ $found_bearer -eq 0 ]]; then + log_debug "Checking recent ModemManager logs for bearer information" + local mm_logs + mm_logs=$(journalctl -u ModemManager -n 100 2>/dev/null) + + # Look for IP information in logs + if [[ -n "$mm_logs" ]]; then + local ip_addr + local ip_gw + local ip_dns1 + local ip_dns2 + + if ip_addr=$(echo "$mm_logs" | grep -A10 "QMI IPv4 Settings" | grep "address:" | tail -1 | awk '{print $3}' | sed 's/\/.*//g' || true) && [[ -n "$ip_addr" ]]; then + log_info "Found IP address $ip_addr directly from ModemManager logs" + # Try to find prefix, gateway, dns from logs too + local ip_prefix + ip_prefix=$(echo "$mm_logs" | grep -A10 "QMI IPv4 Settings" | grep "address:" | tail -1 | awk '{print $3}' | grep -o "/.*" | tr -d "/" || echo "30") + ip_gw=$(echo "$mm_logs" | grep -A10 "QMI IPv4 Settings" | grep "gateway:" | tail -1 | awk '{print $3}' || true) + ip_dns1=$(echo "$mm_logs" | grep -A10 "QMI IPv4 Settings" | grep "DNS #1:" | tail -1 | awk '{print $3}' || true) + ip_dns2=$(echo "$mm_logs" | grep -A10 "QMI IPv4 Settings" | grep "DNS #2:" | tail -1 | awk '{print $3}' || true) + + # Return early with this information - create a stub bearer_info + bearer_info="address: $ip_addr/$ip_prefix\ngateway: $ip_gw\nDNS #1: $ip_dns1\nDNS #2: $ip_dns2" + found_bearer=1 + fi + fi + fi + + if [[ $found_bearer -eq 0 ]]; then + # Fall back to trying DHCP as a last resort + log_info "No bearer IP information found. Falling back to DHCP method..." + + # Try udhcpc first (better for raw-IP interfaces) + if command -v udhcpc &>/dev/null; then + log_info "Trying udhcpc DHCP client (timeout ${UDHCPC_TIMEOUT_SECONDS}s)..." + run_command ip link set "$interface" up || true + if timeout "$UDHCPC_TIMEOUT_SECONDS" udhcpc -i "$interface" -t 5 -T 3 -n >> "$LOG_FILE" 2>&1; then + log_info "udhcpc succeeded." + ip_address=$(ip addr show "$interface" 2>/dev/null | grep "inet " | awk '{print $2}' || true) + if [[ -n "$ip_address" ]]; then + log_info "✓ Successfully obtained IP address via udhcpc: $ip_address" + + # Try ping with IP to test basic connectivity + if run_command ping -c "$PING_COUNT" -W "$PING_TIMEOUT_SECONDS" "$PING_TARGET"; then + log_info "✓ Ping to IP $PING_TARGET successful." + return 0 + else + log_warning "✗ Ping test failed despite having IP address." + # Try to add a default route + run_command ip route add default dev "$interface" || true + if run_command ping -c "$PING_COUNT" -W "$PING_TIMEOUT_SECONDS" "$PING_TARGET"; then + log_info "✓ Ping successful after adding route manually!" + return 0 + else + log_error "✗ Connection failed after adding manual route." + return 1 + fi + fi + return 0 + fi + fi + fi + + # Try dhclient as fallback + log_info "Trying dhclient as fallback (timeout ${DHCLIENT_TIMEOUT_SECONDS}s)..." + if timeout "$DHCLIENT_TIMEOUT_SECONDS" dhclient -v "$interface" >> "$LOG_FILE" 2>&1; then + log_info "dhclient completed successfully." + ip_address=$(ip addr show "$interface" 2>/dev/null | grep "inet " | awk '{print $2}' || true) + if [[ -n "$ip_address" ]]; then + log_info "✓ Successfully obtained IP address via dhclient: $ip_address" + + # Try ping with IP to test basic connectivity + if run_command ping -c "$PING_COUNT" -W "$PING_TIMEOUT_SECONDS" "$PING_TARGET"; then + log_info "✓ Ping to IP $PING_TARGET successful." + return 0 + else + log_warning "✗ Ping test failed despite having IP address." + # Try to add a default route + run_command ip route add default dev "$interface" || true + if run_command ping -c "$PING_COUNT" -W "$PING_TIMEOUT_SECONDS" "$PING_TARGET"; then + log_info "✓ Ping successful after adding route manually!" + return 0 + else + log_error "✗ Connection failed after adding manual route." + return 1 + fi + fi + return 0 + fi + fi + + log_error "✗ Failed to configure network interface with IP address." + return 1 + fi + + # Extract IP configuration from bearer or logs + local bearer_ip + local bearer_prefix + local bearer_gateway + local bearer_dns1 + local bearer_dns2 + + # Debug the bearer information for troubleshooting + log_debug "Bearer info content: $bearer_info" + + # Manually dump the bearer information for better analysis + run_command mmcli -b "$bearer_index" || true + + # Try different patterns for extracting IP info since formatting can vary + # Try various IP address patterns + bearer_ip=$(echo "$bearer_info" | grep -o '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}' | head -1 || true) + + # Set default prefix if we don't have one + bearer_prefix="24" + + # Extract the gateway directly from bearer info (looking for IP addresses) + bearer_gateway=$(echo "$bearer_info" | grep -A1 "gateway:" | grep -o '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}' | head -1 || true) + + # Extract DNS servers + dns_servers=$(echo "$bearer_info" | grep -A1 "dns:" | grep -o '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}' || true) + bearer_dns1=$(echo "$dns_servers" | head -1 || true) + bearer_dns2=$(echo "$dns_servers" | sed -n '2p' || true) + + # Ensure we have the required minimum info + if [[ -z "$bearer_ip" || -z "$bearer_prefix" ]]; then + log_error "Could not extract IP configuration from bearer. Missing IP address or prefix." + log_debug "Bearer info: $bearer_info" + + # Try one more extraction method with a simpler pattern + bearer_ip=$(echo "$bearer_info" | grep -o '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}' | head -1 || true) + + if [[ -n "$bearer_ip" ]]; then + log_info "Extracted IP address using fallback method: $bearer_ip" + # Set a default prefix if we found an IP but no prefix + bearer_prefix="24" + log_info "Using default prefix: $bearer_prefix" + else + log_error "Failed to extract any IP address. Connection cannot be configured." + return 1 + fi + fi + + # Manual IP configuration using information from bearer + log_info "Configuring interface with static IP information from bearer" + + # Make sure interface is up again + run_command ip link set "$interface" up || true + + # Flush any existing IP configuration one more time + run_command ip addr flush dev "$interface" || true + + # Apply IP address and prefix + log_info "Setting IP address $bearer_ip/$bearer_prefix on $interface" + if ! run_command ip addr add "$bearer_ip/$bearer_prefix" dev "$interface"; then + log_error "Failed to set IP address on interface" + return 1 + fi + + # Set default route via gateway if available + if [[ -n "$bearer_gateway" ]]; then + log_info "Setting default route via $bearer_gateway" + run_command ip route add default via "$bearer_gateway" dev "$interface" || true + else + log_warning "No gateway found in bearer info, attempting to add route without gateway" + run_command ip route add default dev "$interface" || true + fi + + # Add a debug message to show what we're working with + log_debug "IP Address: $bearer_ip, Gateway: $bearer_gateway, DNS: $bearer_dns1, $bearer_dns2" + + # Configure DNS if available + if [[ -n "$bearer_dns1" ]]; then + log_info "Configuring DNS servers: $bearer_dns1, $bearer_dns2" + # Backup existing resolv.conf + if [[ -f /etc/resolv.conf ]]; then + cp /etc/resolv.conf /etc/resolv.conf.bak || true + fi + + # Create new resolv.conf + { + echo "nameserver $bearer_dns1" + [[ -n "$bearer_dns2" ]] && echo "nameserver $bearer_dns2" + echo "# Added by uconsole-4g script using bearer DNS" + } > /etc/resolv.conf || log_error "Failed to write DNS config" + fi + + # Verify the interface has the IP we set + sleep 2 + local ip_address + ip_address=$(ip addr show "$interface" 2>/dev/null | grep "inet " | awk '{print $2}' || true) + + if [[ -z "$ip_address" ]]; then + log_error "✗ Failed to verify IP address on interface after configuration" + log_debug "Final interface status:" + run_command ip addr show "$interface" || true + log_debug "Final routing table:" + run_command ip route || true + return 1 + fi + + log_info "✓ Successfully configured IP address: $ip_address" + + # 6. Verify Connectivity and Setup DNS if needed + log_info "Step 6: Setting up DNS and testing connectivity..." + log_debug "Current routing table:" + run_command ip route || true + + # Check DNS configuration + local dns_set=0 + if [[ -f /etc/resolv.conf ]]; then + if grep -q "nameserver" /etc/resolv.conf; then + log_debug "DNS configuration detected in /etc/resolv.conf" + dns_set=1 + fi + fi + + # Only set up Google DNS if no DNS is configured and we didn't set bearer DNS + if [[ $dns_set -eq 0 && -z "$bearer_dns1" ]]; then + log_info "No DNS configuration detected, setting up Google DNS..." + # Backup existing resolv.conf + if [[ -f /etc/resolv.conf ]]; then + cp /etc/resolv.conf /etc/resolv.conf.bak || true + fi + # Add Google DNS + { + echo "nameserver 8.8.8.8" + echo "nameserver 8.8.4.4" + echo "# Added by uconsole-4g script using Google DNS" + } > /etc/resolv.conf || log_error "Failed to write DNS config" + fi + + # Try ping with IP first to test basic connectivity + if run_command ping -c "$PING_COUNT" -W "$PING_TIMEOUT_SECONDS" "$PING_TARGET"; then + log_info "✓ Ping to IP $PING_TARGET successful." + + # Try DNS resolution next + log_info "Testing DNS resolution..." + if run_command ping -c "$PING_COUNT" -W "$PING_TIMEOUT_SECONDS" "www.google.com"; then + log_info "✓ DNS resolution successful. Connection fully established!" + else + log_warning "DNS resolution failed. Connection may be limited to IP-only access." + # Add specific DNS instruction + log_info "Consider manually adding DNS servers to /etc/resolv.conf if needed." + fi + + # Log signal quality + local signal + signal=$(mmcli -m "$modem_index" 2>/dev/null | grep "signal quality" | sed 's/.*signal quality: \([0-9]*\)%.*/\1/g' || true) + if [[ -n "$signal" ]]; then + log_info "Signal quality: $signal%" + fi + return 0 + else + log_error "✗ Ping to IP $PING_TARGET failed. Connection might be incomplete (check routing)." + log_info "Trying to add default route manually..." + # Try to add a default route via the detected interface + run_command ip route add default dev "$interface" || true + # Try one more ping after adding route + if run_command ping -c "$PING_COUNT" -W "$PING_TIMEOUT_SECONDS" "$PING_TARGET"; then + log_info "✓ Ping successful after adding route manually!" + return 0 + else + return 1 + fi + fi +} + +function disconnect_4g { + log_info "Disconnecting from the internet..." + # dhclient is optional; only use it if present. + if ! _check_dependencies mmcli ip; then return 1; fi + + local modem_index + local interface + local bearer_index + + modem_index=$(_find_modem_index) || { log_info "No modem detected, nothing to disconnect."; return 0; } + log_info "Using modem index: $modem_index" + + interface=$(_find_wwan_interface) || interface="wwan0" # Use default if detection fails + # Clean the interface name properly + interface=$(_clean_interface_name "$interface") + log_info "Using network interface: $interface" + + # 1. Release DHCP lease + if ip link show "$interface" &>/dev/null; then + if ip addr show "$interface" 2>/dev/null | grep -q "inet "; then + log_info "Releasing DHCP lease for $interface..." + # Use run_command, ignore errors if lease already gone + if command -v dhclient &>/dev/null; then + run_command dhclient -r "$interface" || log_debug "dhclient -r failed (maybe no lease)." + else + log_debug "dhclient not found; skipping DHCP release step." + fi + else + log_debug "Interface $interface has no IP address, skipping DHCP release." + fi + else + log_debug "Interface $interface not found, skipping DHCP release." + fi + + # 2. Disconnect Bearer + log_info "Disconnecting bearer..." + # Find active bearer first + bearer_index=$(mmcli -m "$modem_index" --list-bearers 2>/dev/null | grep -o "/org/freedesktop/ModemManager1/Bearer/[0-9]*" | grep -o "[0-9]*" | head -1 || true) + + if [[ -n "$bearer_index" ]]; then + log_info "Found active bearer: $bearer_index. Disconnecting..." + if run_command mmcli -m "$modem_index" --simple-disconnect; then + log_info "✓ Successfully disconnected bearer using simple-disconnect." + else + log_warning "Simple-disconnect failed, trying specific bearer $bearer_index..." + if run_command mmcli -b "$bearer_index" --disconnect; then + log_info "✓ Successfully disconnected specific bearer $bearer_index." + else + log_error "✗ Failed to disconnect specific bearer $bearer_index." + # Don't fail the whole script, proceed to interface down + fi + fi + else + log_info "No active connection bearer found to disconnect." + fi + + # 3. Bring Interface Down + # Make sure interface is clean one last time + interface=$(_clean_interface_name "$interface") + + if ip link show "$interface" &>/dev/null; then + log_info "Bringing interface $interface down..." + if run_command ip link set "$interface" down; then + log_debug "Interface $interface set down." + else + log_error "Failed to set interface $interface down." + fi + else + log_debug "Interface $interface not found, skipping set down." + fi + + log_info "✓ Disconnect sequence completed." + return 0 +} + +function diagnose_modem { + log_info "Running comprehensive modem diagnostics..." + + log_info "=== System Information ===" + run_command uname -a || true + run_command lsb_release -a || true + run_command ip addr || true + run_command ip route || true + run_command systemctl status ModemManager || true + run_command mmcli -L || true + log_info "=== End System Information ===" + + log_info "=== Hardware Detection ===" + run_command lsusb | grep -i 'modem\|huawei\|zte\|sierra\|quectel\|simcom' || log_debug "No known modem strings found in lsusb." + run_command lspci | grep -i 'network\|modem' || log_debug "No known modem strings found in lspci." + log_info "Checking recent dmesg for modem/tty/wwan..." + run_command dmesg | tail -n 100 | grep -i 'tty\|modem\|wwan\|sim' || log_debug "No relevant strings found in recent dmesg." + + log_info "=== ModemManager Status ===" + run_command systemctl is-active ModemManager || true + run_command systemctl status ModemManager || true + log_info "Fetching last 50 lines of ModemManager journal..." + run_command journalctl --no-pager -n 50 -u ModemManager || true + + log_info "=== Modem Information (mmcli) ===" + run_command mmcli -L || true + local modem_index + if modem_index=$(_find_modem_index 2>/dev/null); then + log_info "Detailed modem information for index $modem_index:" + run_command mmcli -m "$modem_index" --verbose || true + else + log_info "No modem index found for detailed info." + fi + + log_info "=== Network Interface Status ===" + run_command ip addr || true + run_command ip route || true + # ifconfig is often not installed by default, make it optional + if command -v ifconfig &>/dev/null; then + run_command ifconfig -a || true + else + log_debug "ifconfig command not found, skipping." + fi + + log_info "=== Network Connectivity Test ===" + run_command ping -c 3 "$PING_TARGET" || log_error "Ping test failed." + + log_info "=== uConsole CM5 GPIO Test ===" + if command -v gpioinfo &>/dev/null; then + _gpioinfo_chip | grep -E "line ${GPIO_MODEM_POWER}:|line ${GPIO_MODEM_RESET}:" || log_debug "Could not get GPIO info." + else + log_debug "gpioinfo command not found, skipping GPIO test." + fi + + log_info "Diagnostics complete. Log file: $LOG_FILE" + echo "Diagnostics complete. See log file for details: $LOG_FILE" +} + +# --- Main Execution --- + +# Check for root privileges early +if [[ "$EUID" -ne 0 ]]; then + printf "Error: This script requires root privileges.\nPlease run with sudo: sudo %s %s\n" "$SCRIPT_NAME" "$*" >&2 + exit 1 +fi + +# Initialize Log File +# Use a predictable timestamp format suitable for filenames +timestamp=$(date +%Y%m%d_%H%M%S) +LOG_FILE="${LOG_FILE_BASE}-${timestamp}.log" +# Create/truncate log file and write header +echo "=== uConsole CM5 4G Modem Manager Log - $(date) ===" > "$LOG_FILE" +echo "Script: $SCRIPT_NAME" >> "$LOG_FILE" +echo "Command: $*" >> "$LOG_FILE" +echo "--------------------------------------------------" >> "$LOG_FILE" +# Ensure log file is writable +if ! touch "$LOG_FILE"; then + printf "Error: Cannot write to log file: %s\nCheck permissions or path.\n" "$LOG_FILE" >&2 + exit 1 +fi +log_info "Starting script..." +log_info "Log file: $LOG_FILE" +log_debug "Debug mode enabled." + +# Process command line arguments +if [[ $# -eq 0 ]]; then + log_error "No command provided." + show_help + exit 1 +fi + +COMMAND="$1" +log_info "Processing command: $COMMAND" +exit_status=0 + +case "$COMMAND" in + status) + check_modem_status + exit_status=$? + ;; + enable) + enable_4g + exit_status=$? + ;; + disable) + disable_4g + exit_status=$? + ;; + connect) + connect_4g + exit_status=$? + ;; + disconnect) + disconnect_4g + exit_status=$? + ;; + unlock) + unlock_sim + exit_status=$? + ;; + diagnose) + diagnose_modem + exit_status=$? + ;; + reset) + log_info "--- Performing full modem reset cycle ---" + log_info "Step 1: Disconnecting..." + disconnect_4g || log_warning "Disconnect failed, continuing reset..." + log_info "Step 2: Disabling modem..." + disable_4g || log_warning "Disable failed, continuing reset..." + log_info "Step 3: Restarting ModemManager..." + _ensure_modemmanager_active || log_warning "ModemManager restart failed, continuing reset..." + log_info "Step 4: Enabling modem..." + enable_4g || log_error "Enable failed during reset cycle." + exit_status=$? + if [[ $exit_status -eq 0 ]]; then + log_info "Step 5: Connecting to internet..." + connect_4g || log_error "Connect failed during reset cycle." + exit_status=$? + fi + log_info "--- Reset cycle complete ---" + ;; + help|--help|-h) + show_help + exit_status=0 + ;; + *) + log_error "Unknown command: $COMMAND" + show_help + exit_status=1 + ;; +esac + +if [[ $exit_status -eq 0 ]]; then + log_info "Command '$COMMAND' completed successfully." +else + log_error "Command '$COMMAND' failed with exit code $exit_status." +fi + +# Cleanup: stop any background gpioset holders (libgpiod v2 path) +for _line in "${!GPIOSET_PIDS[@]}"; do + _gpioset_stop_holder "${_line}" || true +done + +log_info "Script execution finished." +exit $exit_status + diff --git a/modules/hosts/uConsole/_private/activation.nix b/modules/hosts/uConsole/_private/activation.nix new file mode 100644 index 0000000..08c27b7 --- /dev/null +++ b/modules/hosts/uConsole/_private/activation.nix @@ -0,0 +1,16 @@ +{ pkgs, ... }: { + # Keep Raspberry Pi kernel cmdline in sync with current system profile. + system.activationScripts.updateRpiCmdline.text = '' + export PATH="${pkgs.gnused}/bin:''${PATH}" + for cmdline in /boot/firmware/nixos/*/cmdline.txt; do + [ -e "$cmdline" ] || continue + current_init="$(readlink -f /nix/var/nix/profiles/system)/init" + if grep -q 'init=/nix/store/' "$cmdline"; then + sed -i "s#init=/nix/store/[^ ]*/init#init=$current_init#" "$cmdline" + fi + if ! grep -q 'root=' "$cmdline"; then + sed -i 's/ init=/ root=PARTUUID=4d44c78a-ee3c-4e3e-9eee-0f2eb10347b6 rootfstype=ext4 init=/' "$cmdline" + fi + done + ''; +} diff --git a/modules/hosts/uConsole/_private/platform.nix b/modules/hosts/uConsole/_private/platform.nix new file mode 100644 index 0000000..ed28309 --- /dev/null +++ b/modules/hosts/uConsole/_private/platform.nix @@ -0,0 +1,48 @@ +{ ... }: { + #TODO[epic=Moderate] Clean this up, move to host's configuration.nix. + # Native Raspberry Pi boot flow. + boot.loader.raspberry-pi.bootloader = "kernel"; + boot.loader.grub.enable = false; + boot.consoleLogLevel = 7; + boot.kernelModules = [ "nvme" ]; + + # Root device for kernel cmdline (NVMe boot). + boot.kernelParams = [ + "root=PARTUUID=4d44c78a-ee3c-4e3e-9eee-0f2eb10347b6" + "rootfstype=ext4" + ]; + + console = { + earlySetup = true; + font = "ter-v32n"; + }; + + # Enable PCIe x1 for CM5 NVMe adapter. + hardware.raspberry-pi.config.cm5.base-dt-params.pciex1 = { + enable = true; + value = "on"; + }; + + powerManagement.cpuFreqGovernor = "powersave"; + + # Root + firmware as boot-critical mounts. + fileSystems."/".neededForBoot = true; + fileSystems."/boot/firmware".neededForBoot = true; + + # Pi generational boot expects boot-firmware.mount to exist. + systemd.mounts = [ + { + what = "/dev/disk/by-label/FIRMWARE"; + where = "/boot/firmware"; + type = "vfat"; + options = "noatime,fmask=0022,dmask=0022"; + wantedBy = [ "local-fs.target" ]; + after = [ "local-fs-pre.target" ]; + } + ]; + + # There is no serial console on uConsole CM5 by default. + systemd.services."serial-getty@ttyS0".enable = false; + + system.stateVersion = "25.11"; +} diff --git a/modules/hosts/uConsole/_private/services.nix b/modules/hosts/uConsole/_private/services.nix new file mode 100644 index 0000000..fcfa6f9 --- /dev/null +++ b/modules/hosts/uConsole/_private/services.nix @@ -0,0 +1,6 @@ +{ ... }: { + #TODO[epic=Moderate] Clean this up, move to host's configuration.nix. + # Enable uinput for gamepad remap. + hardware.uinput.enable = true; + programs.mosh.enable = true; +} diff --git a/modules/lib/ssh-inventory.nix b/modules/lib/ssh-inventory.nix new file mode 100644 index 0000000..4e9c256 --- /dev/null +++ b/modules/lib/ssh-inventory.nix @@ -0,0 +1,113 @@ +{ lib, ... }: { + flake.lib.sshInventory = + let + hosts = { + "14900k" = { + hostName = "192.168.2.25"; + aliases = [ "14900k" "nixdesk" ]; + publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILwUevBGnf+Y/sL1ZsB4bt0c50a89iqwPRoYUGP4UHsL 14900k"; + }; + + ideapad = { + hostName = "192.168.2.113"; + aliases = [ "ideapad" ]; + publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQwaaI90xIMjZ46EcMyO8kBwGCxf7qVL75IYhw8Ssze ideapad"; + }; + + t2mbp = { + hostName = "192.168.2.15"; + aliases = [ "t2mbp" ]; + publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMhVWB9YXl/FuQvufle4VWUas/QM8qCKoRd5a83Tt3S6 t2mbp"; + }; + + uConsole = { + hostName = "192.168.2.99"; + aliases = [ "uConsole" "uconsole" ]; + publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAVPR0lRAcywPR7iTchM3+eO7NCdXAR6NPzYXxalr+dP uConsole"; + }; + + test = { + hostName = "test"; + aliases = [ "test" ]; + publicKey = null; + }; + + nix-server = { + hostName = "192.168.2.238"; + aliases = [ "nix-server" ]; + publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL3KDicMjtOFR6LfZrFzfAD1gdYUdwv6ZM4PSgtmIuzd nix-server"; + }; + }; + + mkIdentityFileName = hostName: ".ssh/id_ed25519_${lib.strings.toLower hostName}.pub"; + activeHosts = builtins.removeAttrs hosts ( + builtins.filter (name: hosts.${name}.publicKey == null) (builtins.attrNames hosts) + ); + + mkIdentityFiles = selectedHosts: + builtins.listToAttrs ( + builtins.map + (hostName: { + name = mkIdentityFileName hostName; + value.text = "${selectedHosts.${hostName}.publicKey}\n"; + }) + (builtins.attrNames selectedHosts) + ); + + # Must come before inventory `Host` blocks and before `Host *`: LAN Gitea SSH is not a catalog PC, + # and `Host *` sets `IdentityAgent none` — without this, git@192.168.2.103 never sees rbw keys. + giteaSshBlock = identityAgent: '' + Host git.chiasson.cloud gitea casaos 192.168.2.103 + HostName 192.168.2.103 + Port 222 + User git + IdentityAgent ${identityAgent} + IdentitiesOnly no + ''; + + mkSshConfigTemplate = + { + selectedHosts ? activeHosts, + user ? null, + identityAgent ? "__RBW_SSH_SOCK__", + }: + let + hostBlocks = builtins.map + (hostName: + let + entry = selectedHosts.${hostName}; + hostPatterns = builtins.concatStringsSep " " (entry.aliases ++ [ entry.hostName ]); + userLine = if user == null then "" else " User ${user}\n"; + in + '' + Host ${hostPatterns} + HostName ${entry.hostName} +${userLine} IdentityFile ~/${mkIdentityFileName hostName} + IdentityAgent ${identityAgent} + IdentitiesOnly yes + '') + (builtins.attrNames selectedHosts); + in + builtins.concatStringsSep "\n" ( + [ + (giteaSshBlock identityAgent) + ] + ++ hostBlocks + ++ [ + '' + Host * + IdentitiesOnly yes + IdentityAgent none + '' + ] + ); + in + { + inherit hosts activeHosts mkIdentityFiles mkSshConfigTemplate; + authorizedKeys = lib.unique ( + builtins.map (entry: entry.publicKey) (builtins.attrValues activeHosts) + ); + identityFiles = mkIdentityFiles activeHosts; + sshConfigTemplate = mkSshConfigTemplate { }; + }; +} diff --git a/modules/system/caching/attic.nix b/modules/system/caching/attic.nix new file mode 100644 index 0000000..4c77d43 --- /dev/null +++ b/modules/system/caching/attic.nix @@ -0,0 +1,240 @@ +{ ... }: { + flake.nixosModules.systemCachingAttic = + { config, lib, pkgs, options, ... }: + let + cfg = config.chiasson.system.caching.attic; + in + { + options.chiasson.system.caching.attic = with lib; { + enable = mkOption { + type = types.bool; + default = false; + description = "Enable Attic cache integration for this host (substituters + trusted key)."; + }; + + cacheName = mkOption { + type = types.str; + default = ""; + example = "nixos-new"; + description = "Attic cache name; required when `chiasson.system.caching.attic.enable = true`."; + }; + + endpoint = mkOption { + type = types.str; + default = ""; + example = "http://nix-server:8080"; + description = "Attic API endpoint base URL (no cache segment)."; + }; + + publicKey = mkOption { + type = types.str; + default = ""; + example = "nixos-new:WcnO6s4aVkB6CKRaPPpKvHLZykWXASV6c+/Ssg8uQEY="; + description = "Cache public signing key from `attic cache info`."; + }; + + tokenFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/secrets/caching/attic/token"; + description = '' + Default token path; push/cli token options override when set. + ''; + }; + + push = { + enable = mkEnableOption '' + Post-build hook → Attic push (needs a token file somewhere). + ''; + + tokenFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/secrets/caching/attic/push-token"; + description = '' + Push token file (sops path). Falls back to `chiasson.system.caching.attic.tokenFile`. + ''; + }; + + excludedPatterns = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "wallpaper" "hm_wallpapers" ".iso" "-source" "large-assets" ]; + description = "Substring patterns: closures matching any pattern are skipped for Attic push."; + }; + }; + + userCli = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Install `attic-client` + HM `~/.config/attic/config.toml` when a token path resolves. + ''; + }; + + serverName = mkOption { + type = types.str; + default = "attic"; + description = "Local label for this Attic server (default-server and [servers.] in config.toml)."; + }; + + tokenFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/secrets/caching/attic/cli-token"; + description = '' + CLI token file; falls back to `chiasson.system.caching.attic.tokenFile`. No file -> no config.toml. + ''; + }; + + }; + }; + + config = let + endpointBase = lib.strings.removeSuffix "/" cfg.endpoint; + enabled = cfg.enable && cfg.cacheName != "" && endpointBase != "" && cfg.publicKey != ""; + cacheUrl = "${endpointBase}/${cfg.cacheName}"; + pushTokenFile = if cfg.push.tokenFile != null then cfg.push.tokenFile else cfg.tokenFile; + hmAtticCliModule = + { lib, osConfig ? { }, ... }: + let + ac = osConfig.chiasson.system.caching.attic or { }; + cli = ac.userCli or { }; + cliTokenFile = if (cli.tokenFile or null) != null then cli.tokenFile else (ac.tokenFile or null); + hmEnabled = + (cli.enable or false) + && (ac.cacheName or "") != "" + && (lib.removeSuffix "/" (ac.endpoint or "")) != "" + && cliTokenFile != null; + srv = cli.serverName or "attic"; + ep = lib.removeSuffix "/" ac.endpoint; + in + { + xdg.configFile."attic/config.toml" = lib.mkIf hmEnabled { + text = '' + default-server = ${builtins.toJSON srv} + + [servers.${srv}] + endpoint = ${builtins.toJSON ep} + token-file = ${builtins.toJSON cliTokenFile} + ''; + }; + }; + in + lib.mkMerge [ + { + assertions = [ + { + assertion = !cfg.push.enable || pushTokenFile != null; + message = "chiasson.system.caching.attic.push: when push.enable is true, set push.tokenFile or chiasson.system.caching.attic.tokenFile."; + } + { + assertion = !cfg.push.enable || enabled; + message = "chiasson.system.caching.attic.push.enable requires a fully configured chiasson.system.caching.attic (enable, cacheName, endpoint, publicKey)."; + } + ]; + } + + (lib.mkIf enabled { + nix.settings = { + substituters = lib.mkAfter [ cacheUrl ]; + trusted-public-keys = lib.mkAfter [ cfg.publicKey ]; + }; + + nix.settings.post-build-hook = lib.mkIf cfg.push.enable (pkgs.writeShellScript "upload-to-attic" '' + set -eu + set -f + + echo "attic: hook start drv=''${DRV_PATH:-}" >&2 + echo "attic: endpoint=${lib.escapeShellArg endpointBase} cache=${lib.escapeShellArg cfg.cacheName}" >&2 + + export PATH="${lib.makeBinPath [ pkgs.attic-client pkgs.nix pkgs.gnused ]}:$PATH" + ${lib.optionalString (pushTokenFile != null) '' + token_path=${lib.escapeShellArg pushTokenFile} + if [ ! -r "$token_path" ]; then + echo "attic: skipping push (token not readable at $token_path)" >&2 + exit 0 + fi + + ATTIC_TOKEN="$(tr -d '\n' < "$token_path")" + ''} + if [ -z "$ATTIC_TOKEN" ]; then + echo "attic: skipping push (token is empty)" >&2 + exit 0 + fi + + ATTIC_CONFIG_HOME="$(mktemp -d /tmp/attic-hook-XXXXXX)" + export XDG_CONFIG_HOME="$ATTIC_CONFIG_HOME" + cleanup() { + rm -rf "$ATTIC_CONFIG_HOME" + } + trap cleanup EXIT + + if ! attic login --set-default ci ${lib.escapeShellArg endpointBase} "$ATTIC_TOKEN" >/dev/null 2>&1; then + echo "attic: login failed (build succeeded; check token/server URL)" >&2 + exit 0 + fi + + push_paths="" + skipped_roots=0 + pushed_roots=0 + seen_roots=0 + for path in $OUT_PATHS; do + seen_roots=$((seen_roots + 1)) + echo "attic: evaluating OUT_PATH $path" >&2 + skip=0 + skip_reason="" + + closure_paths="$(nix-store -qR "$path" 2>/dev/null || true)" + for candidate in "$path" $closure_paths; do + while IFS= read -r pat; do + pat="$(printf '%s' "$pat" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [ -z "$pat" ] && continue + case "$candidate" in + *"$pat"*) + skip=1 + skip_reason="$pat ($candidate)" + break + ;; + esac + done << 'EXCLUDE_PATTERNS' + ${lib.concatStringsSep "\n" cfg.push.excludedPatterns} + EXCLUDE_PATTERNS + if [ "$skip" -eq 1 ]; then + break + fi + done + + if [ "$skip" -eq 0 ]; then + push_paths="$push_paths $path" + pushed_roots=$((pushed_roots + 1)) + else + skipped_roots=$((skipped_roots + 1)) + echo "attic: skipping root $path (matches exclude pattern via $skip_reason)" >&2 + fi + done + + echo "attic: summary seen=$seen_roots selected=$pushed_roots skipped=$skipped_roots" >&2 + + if [ -n "$push_paths" ]; then + echo "attic: pushing to ci:${cfg.cacheName}" >&2 + if ! attic push ${lib.escapeShellArg "ci:${cfg.cacheName}"} $push_paths; then + echo "attic: push failed (build succeeded; check token/network)" >&2 + else + echo "attic: push succeeded" >&2 + fi + else + echo "attic: nothing selected for push" >&2 + fi + exit 0 + ''); + + environment.systemPackages = lib.mkIf cfg.userCli.enable [ pkgs.attic-client ]; + }) + (lib.optionalAttrs (lib.hasAttrByPath [ "home-manager" "sharedModules" ] options) { + "home-manager".sharedModules = lib.mkIf (enabled && cfg.userCli.enable) [ hmAtticCliModule ]; + }) + ]; + }; +}