Files
jarvis/api/endpoints/stats_cache.php
myron 499adadc6d feat: HA smart home control improvements
- stats_cache: swap interesting domains to controllable-only (remove sensors/binary_sensors)
- stats_cache: filter pre-release/camera/HA-addon switches from entity list
- index.html: add visual toggle switch buttons per entity
- index.html: fix scene activation (was returning early; now calls scene/turn_on)
- index.html: remove 8-per-domain cap on entity display
- index.html: add domain icons and unavailable state handling
- index.html: alarm panel arm/disarm toggle logic
2026-05-31 05:05:44 +00:00

348 lines
15 KiB
PHP

<?php
/**
* JARVIS Stats Cache Collector
* Runs every 5 min via cron. Fetches Proxmox + HA data and stores in api_cache.
* Keeps live API calls out of the request path.
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../../api/lib/db.php';
function curlGet(string $url, array $headers, int $timeout = 10): ?string {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => 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://orbisne.fortiddns.com:' . 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','scene','media_player','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";