From aa622e97a55e74136a1412ae4dbdd8b24a4915ab Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sun, 31 May 2026 16:34:01 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20HA=20toggles=20=E2=80=94=20real-time=20h?= =?UTF-8?q?a=5Fentities=20table,=20optimistic=20update,=20renderHATable=20?= =?UTF-8?q?extracted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/endpoints/ha.php | 65 ++++++++++++++++++++++++++++-------------- public_html/index.html | 26 ++++++++++++++--- 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/api/endpoints/ha.php b/api/endpoints/ha.php index 697b7ad..9dd43d8 100644 --- a/api/endpoints/ha.php +++ b/api/endpoints/ha.php @@ -52,26 +52,49 @@ if ($method === 'POST' && $action === 'service') { exit; } -// Serve entities from cache (populated by stats_cache.php cron, every 5 min) -$cached = JarvisDB::query( - 'SELECT data, UNIX_TIMESTAMP(updated_at) as updated_ts FROM api_cache WHERE cache_key=? LIMIT 1', - ['ha_entities'] -); +// Serve entities from ha_entities table (real-time agent push data) +$skipDomains = ['sensor','binary_sensor','button','update','select','number', + 'device_tracker','event','image','person','zone','tts','conversation', + 'assist_satellite','input_button']; +$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', + 'adguard_','folding_home','music_assistant','get_hacs','mealie', + 'mosquitto','social_to','motion_detection', + 'front_yard_record','down_hill_record','camera1_record', + 'back_yard_record','nvr_','assist_microphone']; -if ($cached && !empty($cached[0]['data'])) { - $row = $cached[0]; - $data_out = json_decode($row['data'], true); - $data_out['cache_age_s'] = (int)(time() - (int)$row['updated_ts']); - echo json_encode($data_out); -} else { - echo json_encode([ - 'configured' => true, - 'ha_version' => 'unknown', - 'location' => 'Home', - 'entity_count' => 0, - 'entities' => [], - 'cached_at' => null, - 'cache_age_s' => -1, - 'message' => 'Cache warming up — first update in under 5 minutes.', - ]); +$rows = JarvisDB::query( + "SELECT entity_id, entity_name, domain, state, UNIX_TIMESTAMP(updated_at) as updated_ts + FROM ha_entities + WHERE state NOT IN ('unavailable','unknown') + ORDER BY domain, entity_name" +) ?? []; + +$grouped = []; +$latestTs = 0; +foreach ($rows as $e) { + $dom = $e['domain']; + if (in_array($dom, $skipDomains)) continue; + $skip = false; + if ($dom === 'switch') { + foreach ($skipKeywords as $kw) { + if (strpos($e['entity_id'], $kw) !== false) { $skip = true; break; } + } + } + if ($skip) continue; + if ((int)$e['updated_ts'] > $latestTs) $latestTs = (int)$e['updated_ts']; + $grouped[$dom][] = [ + 'entity_id' => $e['entity_id'], + 'name' => $e['entity_name'], + 'state' => $e['state'], + ]; } + +echo json_encode([ + 'configured' => true, + 'entities' => $grouped, + 'cache_age_s' => $latestTs > 0 ? (int)(time() - $latestTs) : -1, + 'cached_at' => $latestTs > 0 ? date('c', $latestTs) : null, +]); diff --git a/public_html/index.html b/public_html/index.html index e6a71f0..ae1bde1 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -1165,6 +1165,7 @@ function toggleCamera() { let _refreshTick = 0; let selectedContext = null; const _panelCtx = {}; +let _haEntities = {}; const _svcLabels = {lshttpd:'WEB',mysql:'MYSQL',redis:'REDIS',memcached:'MEMCACHE',postfix:'POSTFIX',dovecot:'DOVECOT','jarvis-agent':'AGENT'}; async function refreshAll() { @@ -1436,21 +1437,29 @@ async function loadHA() { dot.className='bb-dot online'; sta.textContent='ONLINE'; const entities = data.entities || {}; + _haEntities = entities; if (!Object.keys(entities).length) { el.innerHTML = '
No entities found.
'; return; } - const domainIcon = { + renderHATable(entities); +}} + +const _domainIcon = { light:'\u{1F4A1}', switch:'\u{1F50C}', scene:'\u{1F3AC}', media_player:'\u{1F4FA}', alarm_control_panel:'\u{1F512}', lawn_mower:'\u{1F33F}', water_heater:'\u{1F321}', fan:'\u{1F4A8}', lock:'\u{1F511}', cover:'\u{1FA9F}', climate:'☃', input_boolean:'⚙' }; + +function renderHATable(entities) { + const el = document.getElementById('ha-list'); + if (!el) return; let rows = ''; let totalShown = 0; for (const [domain, items] of Object.entries(entities)) { - const icon = domainIcon[domain] || '•'; + const icon = _domainIcon[domain] || '•'; const available = items.filter(e => e.state !== 'unavailable' && e.state !== 'unknown'); if (!available.length) continue; available.forEach(e => { @@ -1485,16 +1494,25 @@ async function loadHA() { async function toggleHA(entityId, domain, currentState) { let service; + const ON_STATES = ['on','home','open','locked','playing','mowing','armed_home','armed_away','armed_night','active']; + const wasOn = ON_STATES.includes(currentState); if (domain === 'scene') { service = 'turn_on'; } else if (domain === 'alarm_control_panel') { service = currentState === 'disarmed' ? 'alarm_arm_away' : 'alarm_disarm'; } else { - service = currentState === 'on' ? 'turn_off' : 'turn_on'; + service = wasOn ? 'turn_off' : 'turn_on'; } try { await api('ha/service', 'POST', {domain, service, entity_id: entityId}); - setTimeout(loadHA, 1500); + // Optimistic update — flip state immediately so toggle doesn't snap back + if (_haEntities[domain]) { + const ent = _haEntities[domain].find(e => e.entity_id === entityId); + if (ent && domain !== 'scene') ent.state = wasOn ? 'off' : 'on'; + } + renderHATable(_haEntities); + // Full sync after 4s — HA executes + agent pushes new state + setTimeout(loadHA, 4000); } catch(e) {} }