Files
Unraid-AppOps-Gateway-Public/backend/app/services/home_assistant_voice_readiness.py
T

198 lines
8.3 KiB
Python

from __future__ import annotations
from typing import Any
import httpx
from sqlmodel import Session
from ..core.config import settings
from ..core.time import utc_now
from .secret_store import get_secret
from .voice_catalog import list_commands
def _timeout_seconds() -> float:
try:
return max(2.0, min(20.0, float(settings.apis.get('HOME_ASSISTANT_TIMEOUT_SECONDS') or 8)))
except (TypeError, ValueError):
return 8.0
def _get_json(url: str, token: str, path: str, verify_tls: bool) -> tuple[Any, str, int | None]:
try:
with httpx.Client(timeout=_timeout_seconds(), verify=verify_tls) as client:
response = client.get(
f'{url.rstrip("/")}{path}',
headers={
'Authorization': f'Bearer {token}',
'Accept': 'application/json',
},
)
except httpx.RequestError:
return None, 'unreachable', None
if response.status_code in {401, 403}:
return None, 'auth_failed', response.status_code
if response.status_code >= 400:
return None, 'error', response.status_code
try:
return response.json(), 'ok', response.status_code
except ValueError:
return None, 'invalid_response', response.status_code
def _step(step_id: str, label: str, status: str, detail: str) -> dict[str, str]:
return {'id': step_id, 'label': label, 'status': status, 'detail': detail}
def build_readiness(session: Session) -> dict[str, Any]:
url = str(settings.apis.get('HOME_ASSISTANT_URL') or '').strip().rstrip('/')
token = get_secret(session, 'HOME_ASSISTANT_TOKEN', touch=False) or str(settings.apis.get('HOME_ASSISTANT_TOKEN') or '').strip()
verify_tls = bool(settings.apis.get('HOME_ASSISTANT_VERIFY_TLS', False))
commands = list_commands(session)
expected_scripts = sorted({
f'script.{command["homeAssistantScriptName"]}'
for command in commands
if command.get('homeAssistantScriptName')
})
core_script = 'script.appops_check_setup'
if not url or not token:
missing = [
name for name, value in (
('HOME_ASSISTANT_URL', url),
('HOME_ASSISTANT_TOKEN', token),
)
if not value
]
return {
'status': 'missing_config',
'operational': False,
'apiReachable': False,
'homeAssistantUrl': url,
'missing': missing,
'expectedScriptCount': len(expected_scripts),
'detectedScriptCount': 0,
'detectedScripts': [],
'missingScripts': expected_scripts,
'conversationAvailable': False,
'geminiConversationAvailable': False,
'llmExposureStatus': 'manual_verification_required',
'steps': [
_step('connector', 'Home Assistant connector', 'warning', f'Missing: {", ".join(missing)}'),
],
'nextActions': [
{
'id': 'configure_connector',
'label': 'Configure Home Assistant connector',
'href': '/setup?step=connectors&connector=home_assistant',
},
],
'lastCheckedAt': utc_now().isoformat(),
}
config_payload, config_status, config_code = _get_json(url, token, '/api/config', verify_tls)
states_payload, states_status, states_code = _get_json(url, token, '/api/states', verify_tls)
api_status = config_status if config_status != 'ok' else states_status
if api_status != 'ok':
return {
'status': api_status,
'operational': False,
'apiReachable': False,
'homeAssistantUrl': url,
'missing': [],
'statusCode': config_code or states_code,
'expectedScriptCount': len(expected_scripts),
'detectedScriptCount': 0,
'detectedScripts': [],
'missingScripts': expected_scripts,
'conversationAvailable': False,
'geminiConversationAvailable': False,
'llmExposureStatus': 'manual_verification_required',
'steps': [
_step('connector', 'Home Assistant connector', 'complete', 'URL and token are stored.'),
_step('api', 'Home Assistant API', 'warning', f'Live API check returned {api_status}.'),
],
'nextActions': [
{
'id': 'test_connector',
'label': 'Review Home Assistant connector',
'href': '/setup?step=connectors&connector=home_assistant',
},
],
'lastCheckedAt': utc_now().isoformat(),
}
components = set()
if isinstance(config_payload, dict) and isinstance(config_payload.get('components'), list):
components = {str(item) for item in config_payload['components']}
entity_ids = {
str(item.get('entity_id'))
for item in states_payload
if isinstance(states_payload, list) and isinstance(item, dict) and item.get('entity_id')
}
detected_scripts = sorted(entity_id for entity_id in expected_scripts if entity_id in entity_ids)
missing_scripts = sorted(set(expected_scripts) - set(detected_scripts))
conversation_available = 'conversation' in components
gemini_available = 'google_generative_ai_conversation' in components
core_script_available = core_script in entity_ids
operational = core_script_available and conversation_available and gemini_available
coverage = int((len(detected_scripts) / len(expected_scripts)) * 100) if expected_scripts else 100
steps = [
_step('connector', 'Home Assistant connector', 'complete', 'Stored configuration is available.'),
_step('api', 'Home Assistant API', 'complete', 'Authenticated API requests succeeded.'),
_step(
'scripts',
'AppOps scripts',
'complete' if not missing_scripts else ('partial' if detected_scripts else 'warning'),
f'{len(detected_scripts)} of {len(expected_scripts)} allowlisted scripts detected.',
),
_step(
'conversation',
'Home Assistant Conversation',
'complete' if conversation_available else 'warning',
'Conversation component is loaded.' if conversation_available else 'Conversation component was not reported by Home Assistant.',
),
_step(
'gemini',
'Google Gemini Conversation',
'complete' if gemini_available else 'warning',
'Google Gemini Conversation integration is loaded.' if gemini_available else 'Google Gemini Conversation integration was not reported by Home Assistant.',
),
_step(
'exposure',
'Scripts exposed to Assist',
'manual',
'Home Assistant does not expose reliable LLM script-exposure state through this API check; verify this in Assist settings.',
),
]
next_actions = []
if missing_scripts:
next_actions.append({'id': 'install_scripts', 'label': 'Install or reload AppOps scripts', 'href': '/voice?tab=ha'})
if not gemini_available:
next_actions.append({'id': 'configure_gemini', 'label': 'Configure Gemini Conversation', 'href': f'{url}/config/integrations'})
if not conversation_available:
next_actions.append({'id': 'configure_assist', 'label': 'Configure an Assist pipeline', 'href': f'{url}/config/voice-assistants/assistants'})
next_actions.append({'id': 'verify_exposure', 'label': 'Verify script exposure to Assist', 'href': f'{url}/config/voice-assistants/expose'})
return {
'status': 'ready' if operational else 'partial',
'operational': operational,
'apiReachable': True,
'homeAssistantUrl': url,
'missing': [],
'expectedScriptCount': len(expected_scripts),
'detectedScriptCount': len(detected_scripts),
'scriptCoveragePercent': coverage,
'detectedScripts': detected_scripts,
'missingScripts': missing_scripts,
'coreScriptAvailable': core_script_available,
'conversationAvailable': conversation_available,
'geminiConversationAvailable': gemini_available,
'llmExposureStatus': 'manual_verification_required',
'steps': steps,
'nextActions': next_actions,
'lastCheckedAt': utc_now().isoformat(),
}