fix: Comprehensive v2.4.0 security, stability, and quality improvements

Security:
- Add SSRF protection via HTTPS-only endpoint validation with private IP blocking
- Mask API keys in all log output via safe_log_data()
- Sanitize error responses to prevent internal detail leakage
- Remove API key pre-population in config flow forms
- Harden service schemas with input length limits and type coercion

Bug fixes:
- Fix temperature=0 rejected due to Python falsy value bug (0 or default)
- Fix fire-and-forget race condition in coordinator init (5 async_create_task → awaited async_initialize)
- Fix rate limit state not resetting after successful API calls
- Fix ConnectionError incorrectly setting is_rate_limited=True
- Fix _rotate_history calling async method via sync executor
- Fix shutil.move blocking event loop in history migration
- Fix async_shutdown removing wrong key from hass.data
- Replace os.rename with shutil.move for cross-device safety

Improvements:
- Add asyncio.Lock for request serialization
- Replace async_timeout with stdlib asyncio.timeout (Python 3.12+)
- Use SupportsResponse.OPTIONAL for ask_question and get_history services
- Use Platform.SENSOR enum instead of string literal
- Add strings.json for HA translation framework
- Update DEFAULT_ANTHROPIC_MODEL to claude-sonnet-4-6
- Retry only transient errors (429, timeout) in API client

Cleanup:
- Remove dead code: unused schemas, imports, sync_write_history, check_memory, check_connection
- Remove icon copying code from async_setup
- Remove unused dependencies from manifest (anthropic, openai, certifi, async-timeout)
- Remove empty manifest arrays (bluetooth, mqtt, ssdp, usb, zeroconf)
- Fix duplicate JSON keys in 5 translation files
- Fix Serbian translation: "Прекључено" → "Искључено" for disconnected state

Bump version to 2.4.0
This commit is contained in:
SMKRV
2026-03-12 01:24:01 +03:00
parent ce0a75f219
commit 998103ce73
15 changed files with 564 additions and 460 deletions
+42 -34
View File
@@ -10,10 +10,7 @@ import logging
import asyncio
from typing import Any, Dict, List, Optional
from aiohttp import ClientSession, ClientTimeout
from async_timeout import timeout
from datetime import datetime, timedelta
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .const import (
DEFAULT_API_TIMEOUT,
@@ -88,38 +85,58 @@ class APIClient:
url: str,
payload: Dict[str, Any],
) -> Dict[str, Any]:
"""Make API request with retry logic."""
# Log request without sensitive data
"""Make API request with retry logic for transient errors only."""
safe_payload = {k: v for k, v in payload.items() if k not in ['messages', 'system']}
_LOGGER.debug(f"API Request: URL={url}, Safe payload: {safe_payload}")
_LOGGER.debug("API Request: URL=%s, Safe payload: %s", url, safe_payload)
for attempt in range(API_RETRY_COUNT):
try:
async with timeout(self.api_timeout):
async with self.session.post(
url,
json=payload,
headers=self.headers,
timeout=self.timeout,
) as response:
_LOGGER.debug(f"Response status: {response.status}")
if response.status != 200:
error_data = await response.json()
# Log error without sensitive data
safe_error = {k: v for k, v in error_data.items() if k not in ['message', 'details']}
_LOGGER.error(f"API error (status {response.status}): {safe_error}")
raise HomeAssistantError(f"API error: status {response.status}")
async with self.session.post(
url,
json=payload,
headers=self.headers,
timeout=self.timeout,
) as response:
_LOGGER.debug("Response status: %s", response.status)
if response.status == 200:
return await response.json()
# Try to get error details
error_data = {}
try:
error_data = await response.json()
except Exception:
error_data = {"raw": await response.text()}
# Rate limit — retry with backoff
if response.status == 429:
_LOGGER.warning(
"Rate limit on attempt %d/%d", attempt + 1, API_RETRY_COUNT
)
if attempt < API_RETRY_COUNT - 1:
await asyncio.sleep(2 ** attempt)
continue
raise HomeAssistantError("API rate limit exceeded")
# Client/server errors — don't retry
_LOGGER.error("API error (status %d): %s", response.status, error_data)
raise HomeAssistantError(f"API error: status {response.status}")
except asyncio.TimeoutError:
_LOGGER.warning(f"Timeout on attempt {attempt + 1}/{API_RETRY_COUNT}")
_LOGGER.warning("Timeout on attempt %d/%d", attempt + 1, API_RETRY_COUNT)
if attempt == API_RETRY_COUNT - 1:
raise HomeAssistantError("API request timed out")
await asyncio.sleep(1 * (attempt + 1))
await asyncio.sleep(2 ** attempt)
except HomeAssistantError:
raise
except Exception as e:
_LOGGER.warning(f"API request failed on attempt {attempt + 1}/{API_RETRY_COUNT}: {type(e).__name__}")
_LOGGER.warning(
"API request failed on attempt %d/%d: %s",
attempt + 1, API_RETRY_COUNT, type(e).__name__,
)
if attempt == API_RETRY_COUNT - 1:
raise
await asyncio.sleep(1 * (attempt + 1))
await asyncio.sleep(2 ** attempt)
async def create(
self,
@@ -320,15 +337,6 @@ class APIClient:
},
}
async def check_connection(self) -> bool:
"""Check API connection."""
try:
await self._make_request(self.endpoint, {"test": "connection"})
return True
except Exception as e:
_LOGGER.error(f"Connection check failed: {str(e)}")
return False
async def _create_gemini_completion(
self,
model: str,