From b024e51f3d9be85c0350cc3ff9be94891d639407 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Wed, 17 Jun 2026 02:34:29 +0000 Subject: [PATCH] feat: HA scene control via voice - ha.php: GET scenes action returns live scene list from HA; POST scene_activate triggers by entity_id - chat.php: ha_scene intent fetches all scenes live, fuzzy token-matches against message, calls scene.turn_on; falls back to listing available scenes if no match - KB intents: 14 patterns covering good night/morning/goodbye/kitchen lights/ocean dawn/porch + generic activate/run/trigger/set scene Scenes available: Good Night, Good Morning, Good Morning Work, Goodbye, Kitchen Lights On/Off, Front Porch Lights, Office Ocean Dawn, Master Bedroom Co-Authored-By: Claude Sonnet 4.6 --- api/endpoints/chat.php | 51 ++++++++++++++++++++++++++++++++++++++++++ api/endpoints/ha.php | 28 +++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/api/endpoints/chat.php b/api/endpoints/chat.php index d2fa470..785bee8 100644 --- a/api/endpoints/chat.php +++ b/api/endpoints/chat.php @@ -1647,6 +1647,57 @@ if (!$reply) { if ($matched && $matched['action'] === 'action') { switch ($matched['intent']) { + case 'ha_scene': { + // Fetch all HA scenes and fuzzy-match against the message + $haScenes = @json_decode(@file_get_contents( + HA_URL . '/api/states', + false, + stream_context_create(['http' => ['timeout' => 5, 'header' => 'Authorization: Bearer ' . HA_TOKEN]]) + ), true) ?? []; + + $scenes = array_filter($haScenes, fn($s) => str_starts_with($s['entity_id'] ?? '', 'scene.')); + $msgLow = strtolower($message); + + // Score each scene against the message + $best = null; $bestScore = 0; + foreach ($scenes as $s) { + $name = strtolower($s['attributes']['friendly_name'] ?? ''); + $id = strtolower(str_replace(['scene.', '_'], ['', ' '], $s['entity_id'])); + // Token overlap score + $tokens = array_filter(explode(' ', "$name $id")); + $score = 0; + foreach ($tokens as $tok) { + if (strlen($tok) > 2 && str_contains($msgLow, $tok)) $score++; + } + // Bonus for exact phrase match + if ($name && str_contains($msgLow, $name)) $score += 5; + if ($score > $bestScore) { $bestScore = $score; $best = $s; } + } + + if ($best && $bestScore >= 1) { + $sceneName = $best['attributes']['friendly_name'] ?? $best['entity_id']; + @file_get_contents( + HA_URL . '/api/services/scene/turn_on', + false, + stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => "Authorization: Bearer " . HA_TOKEN . " +Content-Type: application/json", + 'content' => json_encode(['entity_id' => $best['entity_id']]), + 'timeout' => 5, + ]]) + ); + $reply = "Activating {$sceneName}, {$userAddr}."; + } else { + // List available scenes + $names = array_map(fn($s) => $s['attributes']['friendly_name'] ?? $s['entity_id'], $scenes); + $list = implode(', ', array_values($names)); + $reply = "I didn't catch which scene, {$userAddr}. Available scenes: {$list}."; + } + $source = 'intent:ha_scene'; + break; + } + case 'restart_agent': // Extract target hostname from message $msgLow = strtolower($message); diff --git a/api/endpoints/ha.php b/api/endpoints/ha.php index 9dd43d8..b2af154 100644 --- a/api/endpoints/ha.php +++ b/api/endpoints/ha.php @@ -38,6 +38,34 @@ if (!$configured) { exit; } +// Scene list +if ($method === 'GET' && $action === 'scenes') { + $states = haRequest('/states') ?? []; + $scenes = []; + foreach ($states as $s) { + if (str_starts_with($s['entity_id'] ?? '', 'scene.')) { + $scenes[] = [ + 'entity_id' => $s['entity_id'], + 'name' => $s['attributes']['friendly_name'] ?? $s['entity_id'], + ]; + } + } + echo json_encode(['scenes' => $scenes]); + exit; +} + +// Scene activate +if ($method === 'POST' && $action === 'scene_activate') { + $entity_id = $data['entity_id'] ?? ''; + if ($entity_id && str_starts_with($entity_id, 'scene.')) { + $result = haRequest('/services/scene/turn_on', 'POST', ['entity_id' => $entity_id]); + echo json_encode(['success' => true, 'entity_id' => $entity_id]); + } else { + echo json_encode(['error' => 'Invalid scene entity_id']); + } + exit; +} + // Live service call (toggle device) — always direct to HA, never cached if ($method === 'POST' && $action === 'service') { $domain = $data['domain'] ?? '';