mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 21:21:54 +02:00
Home Assistant: add environment variable configuration support
- Add HA_URL and HA_TOKEN environment variables for automatic HA integration configuration in HA add-on deployments - Environment variables always override database settings with non-negotiable precedence; database values preserved for fallback - Auto-enable integration when both env vars are set; partial config (one env var) uses database enable state without auto-enabling - Add centralized get_homeassistant_settings() function following Spoolman pattern; replace direct database queries across codebase - Add ha_url_from_env, ha_token_from_env, ha_env_managed fields to AppSettings schema to inform frontend about configuration source - UI shows read-only fields with lock icons and "(Environment Managed)" labels when env-controlled; toggle shows auto-enable badge - Add comprehensive test coverage: 9 integration + 8 unit tests Closes #283
This commit is contained in:
@@ -10,3 +10,9 @@ LOG_LEVEL=INFO
|
||||
|
||||
# Enable file logging (logs written to logs/bambutrack.log)
|
||||
LOG_TO_FILE=true
|
||||
|
||||
# Home Assistant Integration (for HA Add-on deployments)
|
||||
# When both HA_URL and HA_TOKEN are set, Home Assistant integration is automatically enabled
|
||||
# and these values override any database settings (read-only in UI)
|
||||
# HA_URL=http://supervisor/core
|
||||
# HA_TOKEN=your-long-lived-access-token
|
||||
|
||||
@@ -101,6 +101,10 @@ async def get_settings(
|
||||
else:
|
||||
settings_dict[setting.key] = setting.value
|
||||
|
||||
# Get Home Assistant settings (with environment variable overrides)
|
||||
ha_settings = await get_homeassistant_settings(db)
|
||||
settings_dict.update(ha_settings)
|
||||
|
||||
return AppSettings(**settings_dict)
|
||||
|
||||
|
||||
@@ -247,6 +251,43 @@ async def update_spoolman_settings(
|
||||
return await get_spoolman_settings(db)
|
||||
|
||||
|
||||
async def get_homeassistant_settings(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Get Home Assistant integration settings.
|
||||
Environment variables (HA_URL, HA_TOKEN) take precedence over database settings.
|
||||
"""
|
||||
import os
|
||||
|
||||
# Check environment variables first
|
||||
ha_url_env = os.environ.get("HA_URL")
|
||||
ha_token_env = os.environ.get("HA_TOKEN")
|
||||
|
||||
# Fall back to database values
|
||||
ha_url = ha_url_env or await get_setting(db, "ha_url") or ""
|
||||
ha_token = ha_token_env or await get_setting(db, "ha_token") or ""
|
||||
ha_enabled_db = await get_setting(db, "ha_enabled") or "false"
|
||||
|
||||
# Track which settings come from environment
|
||||
ha_url_from_env = bool(ha_url_env)
|
||||
ha_token_from_env = bool(ha_token_env)
|
||||
ha_env_managed = ha_url_from_env and ha_token_from_env
|
||||
|
||||
# Auto-enable when both env vars are set, otherwise use database value
|
||||
if ha_url_env and ha_token_env:
|
||||
ha_enabled = True
|
||||
else:
|
||||
ha_enabled = ha_enabled_db.lower() == "true"
|
||||
|
||||
return {
|
||||
"ha_enabled": ha_enabled,
|
||||
"ha_url": ha_url,
|
||||
"ha_token": ha_token,
|
||||
"ha_url_from_env": ha_url_from_env,
|
||||
"ha_token_from_env": ha_token_from_env,
|
||||
"ha_env_managed": ha_env_managed,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/backup")
|
||||
async def create_backup(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -354,8 +354,11 @@ async def list_ha_entities(
|
||||
|
||||
Requires HA connection settings to be configured in Settings.
|
||||
"""
|
||||
ha_url = await get_setting(db, "ha_url") or ""
|
||||
ha_token = await get_setting(db, "ha_token") or ""
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
|
||||
ha_settings = await get_homeassistant_settings(db)
|
||||
ha_url = ha_settings["ha_url"]
|
||||
ha_token = ha_settings["ha_token"]
|
||||
|
||||
if not ha_url or not ha_token:
|
||||
raise HTTPException(
|
||||
@@ -376,8 +379,11 @@ async def list_ha_sensor_entities(
|
||||
Returns sensors with power/energy units (W, kW, kWh, Wh).
|
||||
Requires HA connection settings to be configured in Settings.
|
||||
"""
|
||||
ha_url = await get_setting(db, "ha_url") or ""
|
||||
ha_token = await get_setting(db, "ha_token") or ""
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
|
||||
ha_settings = await get_homeassistant_settings(db)
|
||||
ha_url = ha_settings["ha_url"]
|
||||
ha_token = ha_settings["ha_token"]
|
||||
|
||||
if not ha_url or not ha_token:
|
||||
raise HTTPException(
|
||||
@@ -546,9 +552,10 @@ async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):
|
||||
"""
|
||||
if plug.plug_type == "homeassistant":
|
||||
# Configure HA service with current settings
|
||||
ha_url = await get_setting(db, "ha_url") or ""
|
||||
ha_token = await get_setting(db, "ha_token") or ""
|
||||
homeassistant_service.configure(ha_url, ha_token)
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
|
||||
ha_settings = await get_homeassistant_settings(db)
|
||||
homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
|
||||
return homeassistant_service
|
||||
return tasmota_service
|
||||
|
||||
|
||||
+3
-4
@@ -260,11 +260,10 @@ async def _get_plug_energy(plug, db) -> dict | None:
|
||||
For MQTT plugs, returns data from the subscription service.
|
||||
"""
|
||||
if plug.plug_type == "homeassistant":
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
|
||||
ha_url = await get_setting(db, "ha_url") or ""
|
||||
ha_token = await get_setting(db, "ha_token") or ""
|
||||
homeassistant_service.configure(ha_url, ha_token)
|
||||
ha_settings = await get_homeassistant_settings(db)
|
||||
homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
|
||||
return await homeassistant_service.get_energy(plug)
|
||||
elif plug.plug_type == "mqtt":
|
||||
# MQTT plugs report "today" energy, not lifetime total
|
||||
|
||||
@@ -106,6 +106,13 @@ class AppSettings(BaseModel):
|
||||
ha_enabled: bool = Field(default=False, description="Enable Home Assistant integration for smart plug control")
|
||||
ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)")
|
||||
ha_token: str = Field(default="", description="Home Assistant Long-Lived Access Token")
|
||||
ha_url_from_env: bool = Field(default=False, description="Whether HA URL is set via HA_URL environment variable")
|
||||
ha_token_from_env: bool = Field(
|
||||
default=False, description="Whether HA token is set via HA_TOKEN environment variable"
|
||||
)
|
||||
ha_env_managed: bool = Field(
|
||||
default=False, description="Whether HA integration is fully managed by environment variables"
|
||||
)
|
||||
|
||||
# File Manager / Library settings
|
||||
library_archive_mode: str = Field(
|
||||
|
||||
@@ -40,28 +40,20 @@ class SmartPlugManager:
|
||||
|
||||
async def _configure_ha_service(self, db: AsyncSession | None = None):
|
||||
"""Configure the HA service with URL and token from settings."""
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
|
||||
try:
|
||||
if db:
|
||||
# Use provided session
|
||||
result = await db.execute(select(Settings).where(Settings.key == "ha_url"))
|
||||
ha_url_setting = result.scalar_one_or_none()
|
||||
result = await db.execute(select(Settings).where(Settings.key == "ha_token"))
|
||||
ha_token_setting = result.scalar_one_or_none()
|
||||
ha_settings = await get_homeassistant_settings(db)
|
||||
else:
|
||||
# Create new session
|
||||
from backend.app.core.database import async_session
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(Settings).where(Settings.key == "ha_url"))
|
||||
ha_url_setting = result.scalar_one_or_none()
|
||||
result = await session.execute(select(Settings).where(Settings.key == "ha_token"))
|
||||
ha_token_setting = result.scalar_one_or_none()
|
||||
ha_settings = await get_homeassistant_settings(session)
|
||||
|
||||
ha_url = ha_url_setting.value if ha_url_setting else ""
|
||||
ha_token = ha_token_setting.value if ha_token_setting else ""
|
||||
homeassistant_service.configure(ha_url, ha_token)
|
||||
homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
|
||||
except Exception as e:
|
||||
logger.warning("Failed to configure HA service: %s", e)
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
Tests the full request/response cycle for /api/v1/settings/ endpoints.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
@@ -393,6 +395,252 @@ class TestSettingsAPI:
|
||||
# Default is False as defined in schema
|
||||
assert isinstance(result["per_printer_mapping_expanded"], bool)
|
||||
|
||||
# ========================================================================
|
||||
# Home Assistant environment variable tests
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_ha_settings_default_no_env_vars(self, async_client: AsyncClient):
|
||||
"""Verify HA settings work without environment variables (default behavior)."""
|
||||
# Ensure no env vars are set
|
||||
os.environ.pop("HA_URL", None)
|
||||
os.environ.pop("HA_TOKEN", None)
|
||||
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
result = response.json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "ha_enabled" in result
|
||||
assert "ha_url" in result
|
||||
assert "ha_token" in result
|
||||
assert "ha_url_from_env" in result
|
||||
assert "ha_token_from_env" in result
|
||||
assert "ha_env_managed" in result
|
||||
|
||||
# Default values without env vars
|
||||
assert result["ha_url_from_env"] is False
|
||||
assert result["ha_token_from_env"] is False
|
||||
assert result["ha_env_managed"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_ha_settings_with_both_env_vars(self, async_client: AsyncClient):
|
||||
"""Verify HA settings are overridden when both env vars are set."""
|
||||
# Set environment variables
|
||||
os.environ["HA_URL"] = "http://supervisor/core"
|
||||
os.environ["HA_TOKEN"] = "test-token-12345"
|
||||
|
||||
try:
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
result = response.json()
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify env var values are used
|
||||
assert result["ha_url"] == "http://supervisor/core"
|
||||
assert result["ha_token"] == "test-token-12345"
|
||||
|
||||
# Verify metadata fields
|
||||
assert result["ha_url_from_env"] is True
|
||||
assert result["ha_token_from_env"] is True
|
||||
assert result["ha_env_managed"] is True
|
||||
|
||||
# Verify auto-enable behavior
|
||||
assert result["ha_enabled"] is True
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
os.environ.pop("HA_URL", None)
|
||||
os.environ.pop("HA_TOKEN", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_ha_settings_with_only_url_env_var(self, async_client: AsyncClient):
|
||||
"""Verify partial configuration when only HA_URL is set."""
|
||||
# Set only URL env var
|
||||
os.environ["HA_URL"] = "http://supervisor/core"
|
||||
os.environ.pop("HA_TOKEN", None)
|
||||
|
||||
try:
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
result = response.json()
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify URL is from env, token is from database
|
||||
assert result["ha_url"] == "http://supervisor/core"
|
||||
assert result["ha_url_from_env"] is True
|
||||
assert result["ha_token_from_env"] is False
|
||||
assert result["ha_env_managed"] is False
|
||||
|
||||
# No auto-enable with partial config
|
||||
assert result["ha_enabled"] is False # Database default
|
||||
|
||||
finally:
|
||||
os.environ.pop("HA_URL", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_ha_settings_with_only_token_env_var(self, async_client: AsyncClient):
|
||||
"""Verify partial configuration when only HA_TOKEN is set."""
|
||||
# Set only token env var
|
||||
os.environ.pop("HA_URL", None)
|
||||
os.environ["HA_TOKEN"] = "test-token-12345"
|
||||
|
||||
try:
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
result = response.json()
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify token is from env, URL is from database
|
||||
assert result["ha_token"] == "test-token-12345"
|
||||
assert result["ha_url_from_env"] is False
|
||||
assert result["ha_token_from_env"] is True
|
||||
assert result["ha_env_managed"] is False
|
||||
|
||||
# No auto-enable with partial config
|
||||
assert result["ha_enabled"] is False # Database default
|
||||
|
||||
finally:
|
||||
os.environ.pop("HA_TOKEN", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_ha_settings_env_vars_override_database(self, async_client: AsyncClient):
|
||||
"""Verify environment variables take precedence over database values."""
|
||||
# First, set database values
|
||||
await async_client.put(
|
||||
"/api/v1/settings/",
|
||||
json={
|
||||
"ha_enabled": True,
|
||||
"ha_url": "http://database-url:8123",
|
||||
"ha_token": "database-token",
|
||||
},
|
||||
)
|
||||
|
||||
# Verify database values are set
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
result = response.json()
|
||||
assert result["ha_url"] == "http://database-url:8123"
|
||||
assert result["ha_token"] == "database-token"
|
||||
|
||||
# Now set environment variables
|
||||
os.environ["HA_URL"] = "http://env-url/core"
|
||||
os.environ["HA_TOKEN"] = "env-token-xyz"
|
||||
|
||||
try:
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
result = response.json()
|
||||
|
||||
# Verify env vars override database
|
||||
assert result["ha_url"] == "http://env-url/core"
|
||||
assert result["ha_token"] == "env-token-xyz"
|
||||
assert result["ha_url_from_env"] is True
|
||||
assert result["ha_token_from_env"] is True
|
||||
assert result["ha_env_managed"] is True
|
||||
assert result["ha_enabled"] is True
|
||||
|
||||
finally:
|
||||
os.environ.pop("HA_URL", None)
|
||||
os.environ.pop("HA_TOKEN", None)
|
||||
|
||||
# Verify database values are still there after removing env vars
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
result = response.json()
|
||||
assert result["ha_url"] == "http://database-url:8123"
|
||||
assert result["ha_token"] == "database-token"
|
||||
assert result["ha_url_from_env"] is False
|
||||
assert result["ha_token_from_env"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_ha_settings_database_updates_accepted_but_ignored(self, async_client: AsyncClient):
|
||||
"""Verify database updates are accepted but have no effect when env vars are set."""
|
||||
# Set environment variables
|
||||
os.environ["HA_URL"] = "http://supervisor/core"
|
||||
os.environ["HA_TOKEN"] = "env-token"
|
||||
|
||||
try:
|
||||
# Attempt to update via API
|
||||
response = await async_client.put(
|
||||
"/api/v1/settings/",
|
||||
json={
|
||||
"ha_url": "http://different-url:8123",
|
||||
"ha_token": "different-token",
|
||||
},
|
||||
)
|
||||
|
||||
# Update should succeed
|
||||
assert response.status_code == 200
|
||||
|
||||
# But values should still be from env vars
|
||||
result = response.json()
|
||||
assert result["ha_url"] == "http://supervisor/core"
|
||||
assert result["ha_token"] == "env-token"
|
||||
assert result["ha_url_from_env"] is True
|
||||
assert result["ha_token_from_env"] is True
|
||||
|
||||
finally:
|
||||
os.environ.pop("HA_URL", None)
|
||||
os.environ.pop("HA_TOKEN", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_ha_settings_empty_env_vars_treated_as_not_set(self, async_client: AsyncClient):
|
||||
"""Verify empty environment variables are treated as not set."""
|
||||
# Set empty env vars
|
||||
os.environ["HA_URL"] = ""
|
||||
os.environ["HA_TOKEN"] = ""
|
||||
|
||||
try:
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
result = response.json()
|
||||
|
||||
# Empty env vars should be treated as not set
|
||||
assert result["ha_url_from_env"] is False
|
||||
assert result["ha_token_from_env"] is False
|
||||
assert result["ha_env_managed"] is False
|
||||
|
||||
finally:
|
||||
os.environ.pop("HA_URL", None)
|
||||
os.environ.pop("HA_TOKEN", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_ha_settings_can_be_updated_normally_without_env_vars(self, async_client: AsyncClient):
|
||||
"""Verify HA settings can be updated normally when env vars are not set."""
|
||||
# Ensure no env vars
|
||||
os.environ.pop("HA_URL", None)
|
||||
os.environ.pop("HA_TOKEN", None)
|
||||
|
||||
# Update HA settings
|
||||
response = await async_client.put(
|
||||
"/api/v1/settings/",
|
||||
json={
|
||||
"ha_enabled": True,
|
||||
"ha_url": "http://192.168.1.100:8123",
|
||||
"ha_token": "my-long-lived-token",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["ha_enabled"] is True
|
||||
assert result["ha_url"] == "http://192.168.1.100:8123"
|
||||
assert result["ha_token"] == "my-long-lived-token"
|
||||
assert result["ha_url_from_env"] is False
|
||||
assert result["ha_token_from_env"] is False
|
||||
assert result["ha_env_managed"] is False
|
||||
|
||||
# Verify persistence
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
result = response.json()
|
||||
assert result["ha_enabled"] is True
|
||||
assert result["ha_url"] == "http://192.168.1.100:8123"
|
||||
assert result["ha_token"] == "my-long-lived-token"
|
||||
|
||||
|
||||
class TestSimplifiedBackupRestore:
|
||||
"""Integration tests for the simplified backup/restore endpoints (ZIP-based).
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Unit tests for Home Assistant settings with environment variable support.
|
||||
|
||||
Tests the get_homeassistant_settings() function in isolation.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_get_homeassistant_settings_no_env_vars():
|
||||
"""Test get_homeassistant_settings with no environment variables."""
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
|
||||
# Mock database session
|
||||
db = AsyncMock(spec=AsyncSession)
|
||||
|
||||
# Mock get_setting to return database values
|
||||
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
|
||||
mock_get_setting.side_effect = lambda db, key: {
|
||||
"ha_url": "http://db-url:8123",
|
||||
"ha_token": "db-token",
|
||||
"ha_enabled": "true",
|
||||
}.get(key, "")
|
||||
|
||||
# Ensure no env vars
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HA_URL", None)
|
||||
os.environ.pop("HA_TOKEN", None)
|
||||
|
||||
result = await get_homeassistant_settings(db)
|
||||
|
||||
# Should use database values
|
||||
assert result["ha_url"] == "http://db-url:8123"
|
||||
assert result["ha_token"] == "db-token"
|
||||
assert result["ha_enabled"] is True
|
||||
assert result["ha_url_from_env"] is False
|
||||
assert result["ha_token_from_env"] is False
|
||||
assert result["ha_env_managed"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_get_homeassistant_settings_with_env_vars():
|
||||
"""Test get_homeassistant_settings with environment variables set."""
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
|
||||
db = AsyncMock(spec=AsyncSession)
|
||||
|
||||
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
|
||||
mock_get_setting.side_effect = lambda db, key: {
|
||||
"ha_url": "http://db-url:8123",
|
||||
"ha_token": "db-token",
|
||||
"ha_enabled": "false",
|
||||
}.get(key, "")
|
||||
|
||||
# Set environment variables
|
||||
with patch.dict(os.environ, {"HA_URL": "http://supervisor/core", "HA_TOKEN": "env-token"}, clear=False):
|
||||
result = await get_homeassistant_settings(db)
|
||||
|
||||
# Should use environment values
|
||||
assert result["ha_url"] == "http://supervisor/core"
|
||||
assert result["ha_token"] == "env-token"
|
||||
assert result["ha_enabled"] is True # Auto-enabled
|
||||
assert result["ha_url_from_env"] is True
|
||||
assert result["ha_token_from_env"] is True
|
||||
assert result["ha_env_managed"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_get_homeassistant_settings_partial_env_url_only():
|
||||
"""Test get_homeassistant_settings with only HA_URL set."""
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
|
||||
db = AsyncMock(spec=AsyncSession)
|
||||
|
||||
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
|
||||
mock_get_setting.side_effect = lambda db, key: {
|
||||
"ha_url": "http://db-url:8123",
|
||||
"ha_token": "db-token",
|
||||
"ha_enabled": "false",
|
||||
}.get(key, "")
|
||||
|
||||
# Set only URL env var
|
||||
with patch.dict(os.environ, {"HA_URL": "http://supervisor/core"}, clear=False):
|
||||
os.environ.pop("HA_TOKEN", None)
|
||||
|
||||
result = await get_homeassistant_settings(db)
|
||||
|
||||
# URL from env, token from database
|
||||
assert result["ha_url"] == "http://supervisor/core"
|
||||
assert result["ha_token"] == "db-token"
|
||||
assert result["ha_enabled"] is False # Not auto-enabled
|
||||
assert result["ha_url_from_env"] is True
|
||||
assert result["ha_token_from_env"] is False
|
||||
assert result["ha_env_managed"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_get_homeassistant_settings_partial_env_token_only():
|
||||
"""Test get_homeassistant_settings with only HA_TOKEN set."""
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
|
||||
db = AsyncMock(spec=AsyncSession)
|
||||
|
||||
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
|
||||
mock_get_setting.side_effect = lambda db, key: {
|
||||
"ha_url": "http://db-url:8123",
|
||||
"ha_token": "db-token",
|
||||
"ha_enabled": "false",
|
||||
}.get(key, "")
|
||||
|
||||
# Set only token env var
|
||||
with patch.dict(os.environ, {"HA_TOKEN": "env-token"}, clear=False):
|
||||
os.environ.pop("HA_URL", None)
|
||||
|
||||
result = await get_homeassistant_settings(db)
|
||||
|
||||
# URL from database, token from env
|
||||
assert result["ha_url"] == "http://db-url:8123"
|
||||
assert result["ha_token"] == "env-token"
|
||||
assert result["ha_enabled"] is False # Not auto-enabled
|
||||
assert result["ha_url_from_env"] is False
|
||||
assert result["ha_token_from_env"] is True
|
||||
assert result["ha_env_managed"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_get_homeassistant_settings_empty_env_vars():
|
||||
"""Test get_homeassistant_settings with empty environment variables."""
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
|
||||
db = AsyncMock(spec=AsyncSession)
|
||||
|
||||
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
|
||||
mock_get_setting.side_effect = lambda db, key: {
|
||||
"ha_url": "http://db-url:8123",
|
||||
"ha_token": "db-token",
|
||||
"ha_enabled": "false",
|
||||
}.get(key, "")
|
||||
|
||||
# Set empty env vars
|
||||
with patch.dict(os.environ, {"HA_URL": "", "HA_TOKEN": ""}, clear=False):
|
||||
result = await get_homeassistant_settings(db)
|
||||
|
||||
# Empty env vars treated as not set, should use database values
|
||||
assert result["ha_url"] == "http://db-url:8123"
|
||||
assert result["ha_token"] == "db-token"
|
||||
assert result["ha_enabled"] is False
|
||||
assert result["ha_url_from_env"] is False
|
||||
assert result["ha_token_from_env"] is False
|
||||
assert result["ha_env_managed"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_get_homeassistant_settings_auto_enable_logic():
|
||||
"""Test auto-enable behavior with various configurations."""
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
|
||||
db = AsyncMock(spec=AsyncSession)
|
||||
|
||||
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
|
||||
# Database has ha_enabled=false
|
||||
mock_get_setting.side_effect = lambda db, key: {
|
||||
"ha_url": "",
|
||||
"ha_token": "",
|
||||
"ha_enabled": "false",
|
||||
}.get(key, "")
|
||||
|
||||
# Test 1: No env vars - use database enabled state
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HA_URL", None)
|
||||
os.environ.pop("HA_TOKEN", None)
|
||||
|
||||
result = await get_homeassistant_settings(db)
|
||||
assert result["ha_enabled"] is False
|
||||
|
||||
# Test 2: Both env vars set - auto-enable
|
||||
with patch.dict(os.environ, {"HA_URL": "http://test", "HA_TOKEN": "token"}, clear=False):
|
||||
result = await get_homeassistant_settings(db)
|
||||
assert result["ha_enabled"] is True
|
||||
|
||||
# Test 3: Only URL - use database enabled state
|
||||
with patch.dict(os.environ, {"HA_URL": "http://test"}, clear=False):
|
||||
os.environ.pop("HA_TOKEN", None)
|
||||
|
||||
result = await get_homeassistant_settings(db)
|
||||
assert result["ha_enabled"] is False
|
||||
|
||||
# Test 4: Only token - use database enabled state
|
||||
with patch.dict(os.environ, {"HA_TOKEN": "token"}, clear=False):
|
||||
os.environ.pop("HA_URL", None)
|
||||
|
||||
result = await get_homeassistant_settings(db)
|
||||
assert result["ha_enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_get_homeassistant_settings_env_vars_override_enabled_true():
|
||||
"""Test that env vars auto-enable even when database has ha_enabled=true."""
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
|
||||
db = AsyncMock(spec=AsyncSession)
|
||||
|
||||
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
|
||||
# Database has ha_enabled=true
|
||||
mock_get_setting.side_effect = lambda db, key: {
|
||||
"ha_url": "http://db-url:8123",
|
||||
"ha_token": "db-token",
|
||||
"ha_enabled": "true",
|
||||
}.get(key, "")
|
||||
|
||||
# Both env vars set - should still be enabled
|
||||
with patch.dict(os.environ, {"HA_URL": "http://supervisor/core", "HA_TOKEN": "env-token"}, clear=False):
|
||||
result = await get_homeassistant_settings(db)
|
||||
|
||||
assert result["ha_enabled"] is True # Auto-enabled by env vars
|
||||
assert result["ha_url"] == "http://supervisor/core"
|
||||
assert result["ha_token"] == "env-token"
|
||||
assert result["ha_env_managed"] is True
|
||||
@@ -751,6 +751,9 @@ export interface AppSettings {
|
||||
ha_enabled: boolean;
|
||||
ha_url: string;
|
||||
ha_token: string;
|
||||
ha_url_from_env: boolean;
|
||||
ha_token_from_env: boolean;
|
||||
ha_env_managed: boolean;
|
||||
// File Manager / Library settings
|
||||
library_archive_mode: 'always' | 'never' | 'ask';
|
||||
library_disk_warning_gb: number;
|
||||
|
||||
@@ -1974,18 +1974,29 @@ export function SettingsPage() {
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white">{t('settings.enableHomeAssistant')}</p>
|
||||
<p className="text-xs text-bambu-gray">{t('settings.homeAssistantDescription')}</p>
|
||||
{localSettings.ha_env_managed && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<Lock className="w-3 h-3 text-bambu-green" />
|
||||
<span className="text-xs text-bambu-green">
|
||||
Automatically enabled via environment variables
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.ha_enabled ?? false}
|
||||
onChange={(e) => updateSetting('ha_enabled', e.target.checked)}
|
||||
disabled={localSettings.ha_env_managed}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
<div className={`w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green ${
|
||||
localSettings.ha_env_managed ? 'opacity-60 cursor-not-allowed' : ''
|
||||
}`}></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1994,30 +2005,67 @@ export function SettingsPage() {
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Home Assistant URL
|
||||
{localSettings.ha_url_from_env && (
|
||||
<span className="ml-2 text-xs text-bambu-green">
|
||||
(Environment Managed)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localSettings.ha_url ?? ''}
|
||||
onChange={(e) => updateSetting('ha_url', e.target.value)}
|
||||
placeholder="http://192.168.1.100:8123"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={localSettings.ha_url ?? ''}
|
||||
onChange={(e) => updateSetting('ha_url', e.target.value)}
|
||||
placeholder="http://192.168.1.100:8123"
|
||||
disabled={localSettings.ha_url_from_env}
|
||||
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${
|
||||
localSettings.ha_url_from_env ? 'opacity-60 cursor-not-allowed' : ''
|
||||
}`}
|
||||
/>
|
||||
{localSettings.ha_url_from_env && (
|
||||
<Lock className="absolute right-3 top-2.5 w-4 h-4 text-bambu-gray" />
|
||||
)}
|
||||
</div>
|
||||
{localSettings.ha_url_from_env && (
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Value set by HA_URL environment variable (read-only)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Long-Lived Access Token
|
||||
{localSettings.ha_token_from_env && (
|
||||
<span className="ml-2 text-xs text-bambu-green">
|
||||
(Environment Managed)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={localSettings.ha_token ?? ''}
|
||||
onChange={(e) => updateSetting('ha_token', e.target.value)}
|
||||
placeholder="eyJ0eXAiOiJKV1QiLC..."
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Create a token in HA: Profile → Long-Lived Access Tokens → Create Token
|
||||
</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
value={localSettings.ha_token ?? ''}
|
||||
onChange={(e) => updateSetting('ha_token', e.target.value)}
|
||||
placeholder="eyJ0eXAiOiJKV1QiLC..."
|
||||
disabled={localSettings.ha_token_from_env}
|
||||
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${
|
||||
localSettings.ha_token_from_env ? 'opacity-60 cursor-not-allowed' : ''
|
||||
}`}
|
||||
/>
|
||||
{localSettings.ha_token_from_env && (
|
||||
<Lock className="absolute right-3 top-2.5 w-4 h-4 text-bambu-gray" />
|
||||
)}
|
||||
</div>
|
||||
{localSettings.ha_token_from_env ? (
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Value set by HA_TOKEN environment variable (read-only)
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Create a token in HA: Profile → Long-Lived Access Tokens → Create Token
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{localSettings.ha_url && localSettings.ha_token && (
|
||||
|
||||
Reference in New Issue
Block a user