&lines=N: last N lines of that unit's journal // restart - GET/POST &unit=: restart a single service (whitelisted) // diag - everything in one go (system + services + recent logs) // // Default LAN deploy: returns data immediately, no auth. // Forwarded deploy: set AV_REQUIRE_AUTH=1 (env) AND configure Caddy // basic_auth on /avian/api/ to gate everything. // // Service restart + journalctl need passwordless sudo for the caddy // user that runs php-fpm. install_services.sh drops the matching // sudoers rule at /etc/sudoers.d/020_avian-admin with an explicit // command allowlist. declare(strict_types=1); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); if (getenv('AV_REQUIRE_AUTH') === '1' && empty($_SERVER['HTTP_AUTHORIZATION'])) { http_response_code(401); echo json_encode(['error' => 'unauthorized']); exit; } $action = $_GET['action'] ?? 'diag'; // Path layout: /home/{USER}/BirdNET-Pi/avian/api/birdnet-status.php // __DIR__ -> .../BirdNET-Pi/avian/api // dirname(__DIR__, 2) -> .../BirdNET-Pi // dirname(__DIR__, 3) -> /home/{USER} $BIRDNETPI_DIR = dirname(__DIR__, 2); $BIRDSONGS_DIR = dirname(__DIR__, 3) . '/BirdSongs'; $DB_PATH = "$BIRDNETPI_DIR/scripts/birds.db"; $CONF_PATH = "$BIRDNETPI_DIR/birdnet.conf"; $STREAM_DIR = "$BIRDSONGS_DIR/StreamData"; function shellout(string $cmd): string { // Always merge stderr so a broken command shows what failed. $rc = 0; $out = []; exec($cmd . ' 2>&1', $out, $rc); return implode("\n", $out); } function read_uptime(): array { $up = @file_get_contents('/proc/uptime'); $sec = $up ? (float)explode(' ', trim($up))[0] : 0; return [ 'seconds' => $sec, 'pretty' => human_duration((int)$sec), 'load' => sys_getloadavg(), 'now' => date('c'), ]; } function human_duration(int $s): string { $d = intdiv($s, 86400); $s -= $d * 86400; $h = intdiv($s, 3600); $s -= $h * 3600; $m = intdiv($s, 60); $parts = []; if ($d) $parts[] = $d . 'd'; if ($h) $parts[] = $h . 'h'; if ($m && !$d) $parts[] = $m . 'm'; return $parts ? implode(' ', $parts) : '<1m'; } function read_mem(): array { $info = @file_get_contents('/proc/meminfo') ?: ''; preg_match('/MemTotal:\s+(\d+)/', $info, $t); preg_match('/MemAvailable:\s+(\d+)/', $info, $a); $tot = isset($t[1]) ? (int)$t[1] * 1024 : 0; $avail = isset($a[1]) ? (int)$a[1] * 1024 : 0; $used = $tot - $avail; return [ 'total_bytes' => $tot, 'used_bytes' => $used, 'used_pct' => $tot ? round($used / $tot * 100, 1) : 0, ]; } function read_disk(string $path): array { if (!is_dir($path)) return ['path' => $path, 'error' => 'not found']; $tot = @disk_total_space($path); $free = @disk_free_space($path); if (!$tot) return ['path' => $path, 'error' => 'stat failed']; return [ 'path' => $path, 'total_bytes' => (int)$tot, 'free_bytes' => (int)$free, 'used_pct' => round(($tot - $free) / $tot * 100, 1), ]; } function read_temp(): ?float { $f = '/sys/class/thermal/thermal_zone0/temp'; if (!is_readable($f)) return null; $raw = trim((string)@file_get_contents($f)); return $raw === '' ? null : round((int)$raw / 1000, 1); } function read_audio(): array { // Read /proc/asound/cards directly - works even when the capture // device is busy (arecord -l would fail with "no soundcards" if // birdnet_recording holds the mic). The file is two lines per card. $raw = @file_get_contents('/proc/asound/cards') ?: ''; $lines = array_values(array_filter(array_map('rtrim', explode("\n", $raw)), 'strlen')); $cards = []; for ($i = 0; $i < count($lines); $i += 2) { $head = trim($lines[$i]); $detail = isset($lines[$i + 1]) ? trim($lines[$i + 1]) : ''; $cards[] = $detail !== '' ? "$head - $detail" : $head; } $usb = shellout('lsusb'); return [ 'arecord_l' => $cards, 'usb' => array_values(array_filter(explode("\n", $usb), function ($l) { return $l !== '' && ( stripos($l, 'audio') !== false || stripos($l, 'microphone') !== false || stripos($l, 'mic') !== false ); })), ]; } function read_streamdata(string $dir): array { if (!is_dir($dir)) return ['exists' => false]; $files = @scandir($dir, SCANDIR_SORT_DESCENDING) ?: []; $wav = array_values(array_filter($files, function ($f) { return $f !== '.' && $f !== '..' && preg_match('/\.(wav|mp3|raw)$/i', $f); })); $newest_age = null; if (count($wav) > 0) { $newest_age = time() - (int)@filemtime("$dir/" . $wav[0]); } return [ 'exists' => true, 'file_count' => count($wav), 'newest_age_s' => $newest_age, 'newest_name' => $wav[0] ?? null, ]; } function read_db_age(string $db): array { if (!is_file($db)) return ['exists' => false]; return [ 'exists' => true, 'size_bytes' => (int)filesize($db), 'modified_s' => time() - (int)filemtime($db), 'mtime' => date('c', (int)filemtime($db)), ]; } function read_conf_summary(string $p): array { if (!is_readable($p)) return ['readable' => false]; $keys = [ 'CONFIDENCE','SENSITIVITY','OVERLAP','REC_CARD','LATITUDE','LONGITUDE', 'MODEL','SITE_NAME','RTSP_STREAM', ]; $vals = []; foreach (file($p, FILE_IGNORE_NEW_LINES) as $line) { if (!$line || $line[0] === '#') continue; if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i', $line, $m)) { if (in_array($m[1], $keys, true)) { $v = trim($m[2]); if (strlen($v) >= 2 && $v[0] === '"' && substr($v, -1) === '"') $v = substr($v, 1, -1); $vals[$m[1]] = $v; } } } return ['readable' => true, 'values' => $vals]; } // Whitelisted units we'll surface in the system page + allow restart on. // Includes both 8.2 and 8.4 php-fpm so older Debian + Trixie both report // the right unit name; missing units come back as "inactive (not-found)". const ALLOWED_UNITS = [ 'birdnet_recording', 'birdnet_analysis', 'birdnet_log', 'birdnet_stats', 'spectrogram_viewer', 'livestream', 'chart_viewer', 'icecast2', 'caddy', 'php8.4-fpm', 'php8.3-fpm', 'php8.2-fpm', ]; function services_status(): array { $out = []; foreach (ALLOWED_UNITS as $u) { $state = trim(shellout('systemctl is-active ' . escapeshellarg($u))); // Skip units that systemd doesn't know about at all (e.g. php8.2-fpm // on a Trixie box that ships php8.4). Keeps the table tidy. if ($state === 'inactive') { $exists = trim(shellout('systemctl cat ' . escapeshellarg($u) . ' >/dev/null 2>&1 && echo Y || echo N')); if ($exists !== 'Y') continue; } $enabled = trim(shellout('systemctl is-enabled ' . escapeshellarg($u))); $since = trim(shellout("systemctl show -p ActiveEnterTimestamp --value " . escapeshellarg($u))); $out[$u] = [ 'active' => $state, 'enabled' => $enabled, 'since' => $since ?: null, ]; } return $out; } function logs_for(string $unit, int $lines): array { if (!in_array($unit, ALLOWED_UNITS, true)) { http_response_code(400); return ['error' => 'unit not allowed', 'allowed' => ALLOWED_UNITS]; } $lines = max(10, min(500, $lines)); $out = shellout( 'sudo /bin/journalctl -u ' . escapeshellarg($unit) . ' --no-pager -n ' . $lines . ' -o short-iso' ); return [ 'unit' => $unit, 'lines' => $lines, 'text' => $out, ]; } switch ($action) { case 'system': { echo json_encode([ 'uptime' => read_uptime(), 'mem' => read_mem(), 'disk_root' => read_disk('/'), 'disk_birds' => read_disk($BIRDSONGS_DIR), 'temp_c' => read_temp(), 'audio' => read_audio(), 'stream_data' => read_streamdata($STREAM_DIR), 'birds_db' => read_db_age($DB_PATH), 'conf' => read_conf_summary($CONF_PATH), 'hostname' => trim(shellout('hostname')), 'kernel' => trim(shellout('uname -r')), 'as_of' => date('c'), ]); break; } case 'services': { echo json_encode(['services' => services_status(), 'as_of' => date('c')]); break; } case 'logs': { $unit = (string)($_GET['unit'] ?? 'birdnet_recording'); $lines = (int)($_GET['lines'] ?? 60); echo json_encode(logs_for($unit, $lines)); break; } case 'restart': { // POST-only: blocks a stray // tag on any LAN-reachable page from disrupting the recording // pipeline. The frontend already POSTs; only thing this rejects // is a passive cross-page GET. if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); echo json_encode(['error' => 'POST required']); break; } $unit = (string)($_GET['unit'] ?? ''); if (!in_array($unit, ALLOWED_UNITS, true)) { http_response_code(400); echo json_encode(['error' => 'unit not allowed', 'allowed' => ALLOWED_UNITS]); break; } // Sudoers rule (dropped in by install_services.sh): // caddy ALL=(root) NOPASSWD: /bin/systemctl restart birdnet_*, ... $rc = 0; $out = []; exec('sudo /bin/systemctl restart ' . escapeshellarg($unit) . ' 2>&1', $out, $rc); echo json_encode([ 'unit' => $unit, 'ok' => $rc === 0, 'rc' => $rc, 'out' => implode("\n", $out), ]); break; } case 'diag': { // Everything a /system page wants in one fetch. $svc = services_status(); $key_units = ['birdnet_recording', 'birdnet_analysis']; $recent_logs = []; foreach ($key_units as $u) { $recent_logs[$u] = trim(shellout( 'sudo /bin/journalctl -u ' . escapeshellarg($u) . ' --no-pager -n 20 -o short-iso' )); } echo json_encode([ 'system' => [ 'uptime' => read_uptime(), 'mem' => read_mem(), 'disk_root' => read_disk('/'), 'disk_birds' => read_disk($BIRDSONGS_DIR), 'temp_c' => read_temp(), 'audio' => read_audio(), 'stream_data' => read_streamdata($STREAM_DIR), 'birds_db' => read_db_age($DB_PATH), 'conf' => read_conf_summary($CONF_PATH), 'hostname' => trim(shellout('hostname')), 'kernel' => trim(shellout('uname -r')), ], 'services' => $svc, 'recent_logs' => $recent_logs, 'as_of' => date('c'), ]); break; } default: http_response_code(404); echo json_encode(['error' => 'unknown action']); }