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(), }