mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
fix: HA toggles — real-time ha_entities table, optimistic update, renderHATable extracted
This commit is contained in:
+44
-21
@@ -52,26 +52,49 @@ if ($method === 'POST' && $action === 'service') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve entities from cache (populated by stats_cache.php cron, every 5 min)
|
// Serve entities from ha_entities table (real-time agent push data)
|
||||||
$cached = JarvisDB::query(
|
$skipDomains = ['sensor','binary_sensor','button','update','select','number',
|
||||||
'SELECT data, UNIX_TIMESTAMP(updated_at) as updated_ts FROM api_cache WHERE cache_key=? LIMIT 1',
|
'device_tracker','event','image','person','zone','tts','conversation',
|
||||||
['ha_entities']
|
'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'])) {
|
$rows = JarvisDB::query(
|
||||||
$row = $cached[0];
|
"SELECT entity_id, entity_name, domain, state, UNIX_TIMESTAMP(updated_at) as updated_ts
|
||||||
$data_out = json_decode($row['data'], true);
|
FROM ha_entities
|
||||||
$data_out['cache_age_s'] = (int)(time() - (int)$row['updated_ts']);
|
WHERE state NOT IN ('unavailable','unknown')
|
||||||
echo json_encode($data_out);
|
ORDER BY domain, entity_name"
|
||||||
} else {
|
) ?? [];
|
||||||
echo json_encode([
|
|
||||||
'configured' => true,
|
$grouped = [];
|
||||||
'ha_version' => 'unknown',
|
$latestTs = 0;
|
||||||
'location' => 'Home',
|
foreach ($rows as $e) {
|
||||||
'entity_count' => 0,
|
$dom = $e['domain'];
|
||||||
'entities' => [],
|
if (in_array($dom, $skipDomains)) continue;
|
||||||
'cached_at' => null,
|
$skip = false;
|
||||||
'cache_age_s' => -1,
|
if ($dom === 'switch') {
|
||||||
'message' => 'Cache warming up — first update in under 5 minutes.',
|
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,
|
||||||
|
]);
|
||||||
|
|||||||
+22
-4
@@ -1165,6 +1165,7 @@ function toggleCamera() {
|
|||||||
let _refreshTick = 0;
|
let _refreshTick = 0;
|
||||||
let selectedContext = null;
|
let selectedContext = null;
|
||||||
const _panelCtx = {};
|
const _panelCtx = {};
|
||||||
|
let _haEntities = {};
|
||||||
const _svcLabels = {lshttpd:'WEB',mysql:'MYSQL',redis:'REDIS',memcached:'MEMCACHE',postfix:'POSTFIX',dovecot:'DOVECOT','jarvis-agent':'AGENT'};
|
const _svcLabels = {lshttpd:'WEB',mysql:'MYSQL',redis:'REDIS',memcached:'MEMCACHE',postfix:'POSTFIX',dovecot:'DOVECOT','jarvis-agent':'AGENT'};
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
@@ -1436,21 +1437,29 @@ async function loadHA() {
|
|||||||
dot.className='bb-dot online'; sta.textContent='ONLINE';
|
dot.className='bb-dot online'; sta.textContent='ONLINE';
|
||||||
|
|
||||||
const entities = data.entities || {};
|
const entities = data.entities || {};
|
||||||
|
_haEntities = entities;
|
||||||
if (!Object.keys(entities).length) {
|
if (!Object.keys(entities).length) {
|
||||||
el.innerHTML = '<div class="text-dim" style="font-size:0.75rem">No entities found.</div>';
|
el.innerHTML = '<div class="text-dim" style="font-size:0.75rem">No entities found.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const domainIcon = {
|
renderHATable(entities);
|
||||||
|
}}
|
||||||
|
|
||||||
|
const _domainIcon = {
|
||||||
light:'\u{1F4A1}', switch:'\u{1F50C}', scene:'\u{1F3AC}',
|
light:'\u{1F4A1}', switch:'\u{1F50C}', scene:'\u{1F3AC}',
|
||||||
media_player:'\u{1F4FA}', alarm_control_panel:'\u{1F512}',
|
media_player:'\u{1F4FA}', alarm_control_panel:'\u{1F512}',
|
||||||
lawn_mower:'\u{1F33F}', water_heater:'\u{1F321}', fan:'\u{1F4A8}',
|
lawn_mower:'\u{1F33F}', water_heater:'\u{1F321}', fan:'\u{1F4A8}',
|
||||||
lock:'\u{1F511}', cover:'\u{1FA9F}', climate:'☃', input_boolean:'⚙'
|
lock:'\u{1F511}', cover:'\u{1FA9F}', climate:'☃', input_boolean:'⚙'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function renderHATable(entities) {
|
||||||
|
const el = document.getElementById('ha-list');
|
||||||
|
if (!el) return;
|
||||||
let rows = '';
|
let rows = '';
|
||||||
let totalShown = 0;
|
let totalShown = 0;
|
||||||
for (const [domain, items] of Object.entries(entities)) {
|
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');
|
const available = items.filter(e => e.state !== 'unavailable' && e.state !== 'unknown');
|
||||||
if (!available.length) continue;
|
if (!available.length) continue;
|
||||||
available.forEach(e => {
|
available.forEach(e => {
|
||||||
@@ -1485,16 +1494,25 @@ async function loadHA() {
|
|||||||
|
|
||||||
async function toggleHA(entityId, domain, currentState) {
|
async function toggleHA(entityId, domain, currentState) {
|
||||||
let service;
|
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') {
|
if (domain === 'scene') {
|
||||||
service = 'turn_on';
|
service = 'turn_on';
|
||||||
} else if (domain === 'alarm_control_panel') {
|
} else if (domain === 'alarm_control_panel') {
|
||||||
service = currentState === 'disarmed' ? 'alarm_arm_away' : 'alarm_disarm';
|
service = currentState === 'disarmed' ? 'alarm_arm_away' : 'alarm_disarm';
|
||||||
} else {
|
} else {
|
||||||
service = currentState === 'on' ? 'turn_off' : 'turn_on';
|
service = wasOn ? 'turn_off' : 'turn_on';
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api('ha/service', 'POST', {domain, service, entity_id: entityId});
|
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) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user