true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, ]); $out = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($err) { echo "[cache] curl error: $err\n"; return null; } return ($code >= 200 && $code < 300) ? $out : null; } function cacheStore(string $key, $data): void { $json = is_string($data) ? $data : json_encode($data); JarvisDB::execute( 'INSERT INTO api_cache (cache_key, data, updated_at) VALUES (?,?,NOW()) ON DUPLICATE KEY UPDATE data=VALUES(data), updated_at=NOW()', [$key, $json] ); } // ── Proxmox ────────────────────────────────────────────────────────────── if (PROXMOX_HOST !== '10.48.200.X' && PROXMOX_TOKEN_VAL !== 'YOUR_TOKEN_VALUE_HERE') { $pveBase = 'https://10.48.200.90:' . PROXMOX_PORT . '/api2/json'; $pveAuth = ['Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL]; // Cluster resources API — returns all VMs/CTs from ALL nodes (pve + pve2) $clusterRaw = curlGet("$pveBase/cluster/resources?type=vm", $pveAuth); $nodeStatusRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/status", $pveAuth); $nodeStatus = $nodeStatusRaw ? (json_decode($nodeStatusRaw, true)['data'] ?? null) : null; $allResources = $clusterRaw ? (json_decode($clusterRaw, true)['data'] ?? []) : []; function fmtUptime(int $sec): string { $d = intdiv($sec, 86400); $h = intdiv($sec % 86400, 3600); $m = intdiv($sec % 3600, 60); return ($d > 0 ? "{$d}d " : '') . "{$h}h {$m}m"; } function fmtBytes(int $b): string { if ($b >= 1073741824) return round($b/1073741824, 1) . ' GB'; if ($b >= 1048576) return round($b/1048576, 1) . ' MB'; return $b . ' B'; } $vmDetails = []; $lxcDetails = []; foreach ($allResources as $r) { $memPct = ($r['maxmem'] ?? 0) > 0 ? round($r['mem'] / $r['maxmem'] * 100, 1) : 0; $diskGb = round(($r['maxdisk'] ?? 0) / 1073741824, 1); $cpuPct = round(($r['cpu'] ?? 0) * 100, 1); $upSec = (int)($r['uptime'] ?? 0); $entry = [ 'vmid' => $r['vmid'], 'name' => $r['name'] ?? ($r['type'] === 'lxc' ? 'CT-' : 'VM-') . $r['vmid'], 'node' => $r['node'] ?? 'pve', 'type' => $r['type'] ?? 'qemu', 'status' => $r['status'] ?? 'unknown', 'cpu_pct' => $cpuPct, 'cpus' => $r['maxcpu'] ?? 1, 'mem_pct' => $memPct, 'mem_used_mb' => round(($r['mem'] ?? 0) / 1048576), 'mem_total_mb' => round(($r['maxmem'] ?? 0) / 1048576), 'disk_gb' => $diskGb, 'uptime_s' => $upSec, 'uptime_human' => $upSec > 0 ? fmtUptime($upSec) : '—', 'netin' => $r['netin'] ?? 0, 'netout' => $r['netout'] ?? 0, 'netin_fmt' => fmtBytes((int)($r['netin'] ?? 0)), 'netout_fmt' => fmtBytes((int)($r['netout'] ?? 0)), ]; if ($r['type'] === 'lxc') $lxcDetails[] = $entry; else $vmDetails[] = $entry; } // Sort by node then vmid usort($vmDetails, fn($a,$b) => $a['node'] <=> $b['node'] ?: $a['vmid'] <=> $b['vmid']); usort($lxcDetails, fn($a,$b) => $a['node'] <=> $b['node'] ?: $a['vmid'] <=> $b['vmid']); // Node summary for both nodes $nodeInfo = []; if ($nodeStatus) { $ns = $nodeStatus; $nodeInfo['pve'] = [ 'cpu_pct' => round(($ns['cpu'] ?? 0) * 100, 1), 'mem_pct' => ($ns['memory']['total'] ?? 0) > 0 ? round($ns['memory']['used'] / $ns['memory']['total'] * 100, 1) : 0, 'mem_used_gb' => round(($ns['memory']['used'] ?? 0) / 1073741824, 1), 'mem_total_gb' => round(($ns['memory']['total'] ?? 0) / 1073741824, 1), 'uptime' => fmtUptime((int)($ns['uptime'] ?? 0)), 'disk_used_gb' => round(($ns['rootfs']['used'] ?? 0) / 1073741824, 1), 'disk_total_gb'=> round(($ns['rootfs']['total'] ?? 0) / 1073741824, 1), ]; } cacheStore('proxmox', [ 'configured' => true, 'node' => PROXMOX_NODE, 'node_status' => $nodeStatus, 'node_info' => $nodeInfo, 'vms' => $vmDetails, 'containers' => $lxcDetails, 'vm_count' => count($vmDetails), 'ct_count' => count($lxcDetails), 'cached_at' => date('c'), ]); echo '[cache] Proxmox: ' . count($vmDetails) . ' VMs, ' . count($lxcDetails) . " CTs cached (both nodes)\n"; } // ── Home Assistant ──────────────────────────────────────────────────────── if (HA_TOKEN !== 'YOUR_HA_TOKEN_HERE' && strpos(HA_URL, '10.48.200.X') === false) { $haHeaders = [ 'Authorization: Bearer ' . HA_TOKEN, 'Content-Type: application/json', ]; $statesRaw = curlGet(HA_URL . '/api/states', $haHeaders, 15); $configRaw = curlGet(HA_URL . '/api/config', $haHeaders, 8); $states = $statesRaw ? json_decode($statesRaw, true) : []; $config = $configRaw ? json_decode($configRaw, true) : []; // Controllable domains only — skip read-only sensors to keep list manageable $interesting = ['light','switch','alarm_control_panel', 'lawn_mower','water_heater','fan','lock','cover','climate','input_boolean']; // Switches that are HA internals / camera settings, not physical devices $skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone', '_siren_on','_email_on','_manual_record','_infrared_', 'do_not_disturb','matter_server','zerotier','mariadb', 'spotify_connect','file_editor','ssh_web','uptime_kuma', 'folding_home','music_assistant','get_hacs','mealie', 'mosquitto','social_to','esphome_device','motion_detection', 'front_yard_record','down_hill_record','camera1_record', 'back_yard_record','nvr_','assist_microphone','cec_scanner', 'kiosker','hacs_pre','adguard']; $grouped = []; foreach (($states ?? []) as $entity) { $domain = explode('.', $entity['entity_id'])[0]; if (!in_array($domain, $interesting)) continue; if ($domain === 'switch') { $skip = false; foreach ($skipKeywords as $kw) { if (strpos($entity['entity_id'], $kw) !== false) { $skip = true; break; } } if ($skip) continue; } if (!isset($grouped[$domain])) $grouped[$domain] = []; $grouped[$domain][] = [ 'entity_id' => $entity['entity_id'], 'name' => $entity['attributes']['friendly_name'] ?? $entity['entity_id'], 'state' => $entity['state'], 'last_changed' => $entity['last_changed'] ?? null, ]; } cacheStore('ha_entities', [ 'configured' => true, 'ha_version' => $config['version'] ?? 'unknown', 'location' => $config['location_name'] ?? 'Home', 'entity_count' => count($states ?? []), 'entities' => $grouped, 'cached_at' => date('c'), ]); $total = array_sum(array_map('count', $grouped)); echo "[cache] HA: $total entities across " . count($grouped) . " domains cached\n"; } // ── Weather (wttr.in — refresh every 30 min) ────────────────────────────── $weatherRow = JarvisDB::query( "SELECT UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key='weather' LIMIT 1" ); $weatherAge = $weatherRow ? (time() - (int)$weatherRow[0]['ts']) : PHP_INT_MAX; if ($weatherAge > 1800) { // wttr.in code → icon mapping $wttrIcon = function(int $code): string { if ($code === 113) return 'SUNNY'; if ($code === 116) return 'PARTLY CLOUDY'; if (in_array($code, [119,122])) return 'CLOUDY'; if (in_array($code, [143,248,260])) return 'FOGGY'; if (in_array($code, [176,263,266,293,296])) return 'LIGHT RAIN'; if (in_array($code, [299,302,305,308,353,356,359])) return 'RAIN'; if (in_array($code, [317,320,362,365])) return 'SLEET'; if (in_array($code, [323,326,329,332,335,338,368,371])) return 'SNOW'; if (in_array($code, [386,389,392,395,200])) return 'STORMS'; if (in_array($code, [281,284,311,314])) return 'FREEZING RAIN'; return 'MIXED'; }; $wttrEmoji = function(int $code): string { if ($code === 113) return 'Sunny'; if ($code === 116) return 'Partly Cloudy'; if (in_array($code, [119,122])) return 'Cloudy'; if (in_array($code, [143,248,260])) return 'Foggy'; if (in_array($code, [176,263,266,293,296])) return 'Light Rain'; if (in_array($code, [299,302,305,308,353,356,359])) return 'Rain'; if (in_array($code, [317,320,362,365])) return 'Sleet'; if (in_array($code, [323,326,329,332,335,338,368,371])) return 'Snow'; if (in_array($code, [386,389,392,395,200])) return 'Thunderstorm'; return 'Mixed'; }; $weatherRaw = curlGet( 'https://wttr.in/FortWorth,TX?format=j1', ['User-Agent: curl/7.88 Jarvis/1.0'], 15 ); if ($weatherRaw) { $w = json_decode($weatherRaw, true); $cu = $w['current_condition'][0] ?? []; $days = $w['weather'] ?? []; $curCode = (int)($cu['weatherCode'] ?? 113); $forecast = []; foreach (array_slice($days, 0, 4) as $day) { // max rain chance across 8 hourly slots $rainPct = 0; foreach ($day['hourly'] ?? [] as $h) { $rainPct = max($rainPct, (int)($h['chanceofrain'] ?? 0)); } $dayCode = (int)($day['hourly'][4]['weatherCode'] ?? 113); $forecast[] = [ 'date' => $day['date'] ?? '', 'day' => date('D', strtotime($day['date'] ?? 'now')), 'high' => (int)($day['maxtempF'] ?? 0), 'low' => (int)($day['mintempF'] ?? 0), 'rain_pct' => $rainPct, 'desc' => $wttrEmoji($dayCode), 'icon' => $wttrIcon($dayCode), ]; } cacheStore('weather', [ 'source' => 'wttr.in', 'location' => 'Fort Worth, TX', 'current' => [ 'temp' => (int)($cu['temp_F'] ?? 0), 'feels' => (int)($cu['FeelsLikeF'] ?? 0), 'humidity' => (int)($cu['humidity'] ?? 0), 'wind' => (int)($cu['windspeedMiles'] ?? 0), 'desc' => $wttrEmoji($curCode), 'icon' => $wttrIcon($curCode), 'cloud' => (int)($cu['cloudcover'] ?? 0), 'vis' => (int)($cu['visibility'] ?? 0), ], 'forecast' => $forecast, 'cached_at' => date('c'), ]); echo '[cache] Weather: ' . ($cu['temp_F'] ?? '?') . "°F, " . $wttrEmoji($curCode) . " (wttr.in) cached\n"; } else { echo "[cache] Weather: wttr.in fetch failed\n"; } } else { echo '[cache] Weather: fresh (' . round($weatherAge/60) . " min old)\n"; } // ── News (RSS feeds — refresh every 30 min) ─────────────────────────────── $newsRow = JarvisDB::query( "SELECT UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key='news' LIMIT 1" ); $newsAge = $newsRow ? (time() - (int)$newsRow[0]['ts']) : PHP_INT_MAX; if ($newsAge > 1800) { $feeds = [ 'headlines' => [ 'https://feeds.bbci.co.uk/news/rss.xml', 'https://feeds.npr.org/1001/rss.xml', 'https://feeds.abcnews.com/abcnews/topstories', ], 'technology' => [ 'http://feeds.arstechnica.com/arstechnica/index', 'https://www.theverge.com/rss/index.xml', ], ]; function parseRss(string $xml, int $max = 5): array { $items = []; if (!$xml) return $items; libxml_use_internal_errors(true); $dom = new DOMDocument(); $dom->loadXML($xml); $nodes = $dom->getElementsByTagName('item'); $count = 0; foreach ($nodes as $node) { if ($count >= $max) break; $title = $node->getElementsByTagName('title')->item(0)?->textContent ?? ''; $link = $node->getElementsByTagName('link')->item(0)?->textContent ?? ''; $pub = $node->getElementsByTagName('pubDate')->item(0)?->textContent ?? ''; $title = html_entity_decode(trim($title), ENT_QUOTES | ENT_HTML5, 'UTF-8'); if ($title && strlen($title) > 5) { $items[] = [ 'title' => $title, 'link' => trim($link), 'pub' => $pub ? date('M j g:ia', strtotime($pub)) : '', 'source' => '', ]; $count++; } } return $items; } $allNews = []; foreach ($feeds as $category => $urls) { $allNews[$category] = []; foreach ($urls as $url) { $xml = curlGet($url, ['User-Agent: Mozilla/5.0 Jarvis/1.0'], 12); $items = parseRss($xml, 4); // Tag source from URL $src = preg_match('/bbc/i', $url) ? 'BBC' : (preg_match('/npr/i', $url) ? 'NPR' : (preg_match('/abcnews/i', $url) ? 'ABC' : (preg_match('/arstechnica/i', $url) ? 'Ars Technica' : (preg_match('/theverge/i', $url) ? 'The Verge' : 'News')))); foreach ($items as &$it) { $it['source'] = $src; } $allNews[$category] = array_merge($allNews[$category], $items); if (count($allNews[$category]) >= 8) break; } // Trim to 8 per category $allNews[$category] = array_slice($allNews[$category], 0, 8); } $totalItems = array_sum(array_map('count', $allNews)); cacheStore('news', [ 'categories' => $allNews, 'cached_at' => date('c'), 'total' => $totalItems, ]); echo "[cache] News: $totalItems articles cached\n"; } else { echo '[cache] News: fresh (' . round($newsAge/60) . " min old)\n"; } echo '[cache] Done at ' . date('Y-m-d H:i:s') . "\n";