198 lines
8.3 KiB
Python
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(),
|
|
}
|