From eda1f5a9e7cdb396969b63ff981e10ba1e02edd8 Mon Sep 17 00:00:00 2001
From: bambuman
Date: Sat, 7 Feb 2026 19:01:04 +0200
Subject: [PATCH] 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
---
.env.example | 6 +
backend/app/api/routes/settings.py | 41 +++
backend/app/api/routes/smart_plugs.py | 21 +-
backend/app/main.py | 7 +-
backend/app/schemas/settings.py | 7 +
backend/app/services/smart_plug_manager.py | 16 +-
.../tests/integration/test_settings_api.py | 248 ++++++++++++++++++
.../tests/unit/test_homeassistant_settings.py | 229 ++++++++++++++++
frontend/src/api/client.ts | 3 +
frontend/src/pages/SettingsPage.tsx | 86 ++++--
10 files changed, 622 insertions(+), 42 deletions(-)
create mode 100644 backend/tests/unit/test_homeassistant_settings.py
diff --git a/.env.example b/.env.example
index 3d4ed67dd..b8df7014c 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py
index 56cc5a83e..2c5fa5cfc 100644
--- a/backend/app/api/routes/settings.py
+++ b/backend/app/api/routes/settings.py
@@ -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),
diff --git a/backend/app/api/routes/smart_plugs.py b/backend/app/api/routes/smart_plugs.py
index c15352db9..096093419 100644
--- a/backend/app/api/routes/smart_plugs.py
+++ b/backend/app/api/routes/smart_plugs.py
@@ -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
diff --git a/backend/app/main.py b/backend/app/main.py
index ff50cd81e..48e001c0e 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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
diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py
index cc6a52190..0e692e4db 100644
--- a/backend/app/schemas/settings.py
+++ b/backend/app/schemas/settings.py
@@ -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(
diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py
index 592259756..7a6eb9c9c 100644
--- a/backend/app/services/smart_plug_manager.py
+++ b/backend/app/services/smart_plug_manager.py
@@ -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)
diff --git a/backend/tests/integration/test_settings_api.py b/backend/tests/integration/test_settings_api.py
index 5fe595101..c2fec9fcb 100644
--- a/backend/tests/integration/test_settings_api.py
+++ b/backend/tests/integration/test_settings_api.py
@@ -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).
diff --git a/backend/tests/unit/test_homeassistant_settings.py b/backend/tests/unit/test_homeassistant_settings.py
new file mode 100644
index 000000000..fe2916851
--- /dev/null
+++ b/backend/tests/unit/test_homeassistant_settings.py
@@ -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
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 883d8a7aa..a39f947fb 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -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;
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
index 6d7f7468b..4554277aa 100644
--- a/frontend/src/pages/SettingsPage.tsx
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -1974,18 +1974,29 @@ export function SettingsPage() {
-
+
{t('settings.enableHomeAssistant')}
{t('settings.homeAssistantDescription')}
+ {localSettings.ha_env_managed && (
+
+
+
+ Automatically enabled via environment variables
+
+
+ )}
@@ -1994,30 +2005,67 @@ export function SettingsPage() {
-
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"
- />
+
+ 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 && (
+
+ )}
+
+ {localSettings.ha_url_from_env && (
+
+ Value set by HA_URL environment variable (read-only)
+
+ )}
-
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"
- />
-
- Create a token in HA: Profile → Long-Lived Access Tokens → Create Token
-
+
+ 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 && (
+
+ )}
+
+ {localSettings.ha_token_from_env ? (
+
+ Value set by HA_TOKEN environment variable (read-only)
+
+ ) : (
+
+ Create a token in HA: Profile → Long-Lived Access Tokens → Create Token
+
+ )}
{localSettings.ha_url && localSettings.ha_token && (