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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 02:34:29 +00:00
parent c29d1bf4c7
commit b024e51f3d
2 changed files with 79 additions and 0 deletions
+51
View File
@@ -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);