Browse Source

Merge pull request #294 from bambuman/feature/home-assistant-env-vars

[Feature]: Configure home assistant via environment variables
MartinNYHC 3 months ago
parent
commit
6ad02d1c59

+ 6 - 0
.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

+ 41 - 0
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),

+ 14 - 7
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
 

+ 3 - 4
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

+ 7 - 0
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(

+ 5 - 13
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_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)
+                    ha_settings = await get_homeassistant_settings(session)
+
+            homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
         except Exception as e:
             logger.warning("Failed to configure HA service: %s", e)
 

+ 248 - 0
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).

+ 229 - 0
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

+ 3 - 0
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;

+ 4 - 0
frontend/src/i18n/locales/de.ts

@@ -1093,6 +1093,10 @@ export default {
     enableRetry: 'Wiederholung aktivieren',
     // Home Assistant
     homeAssistantDescription: 'Smart Plugs über Home Assistant steuern',
+    environmentManagedLabel: '(Umgebungsvariable)',
+    autoEnabledViaEnv: 'Automatisch über Umgebungsvariablen aktiviert',
+    urlFromEnvReadOnly: 'Wert wird über HA_URL Umgebungsvariable gesetzt (schreibgeschützt)',
+    tokenFromEnvReadOnly: 'Wert wird über HA_TOKEN Umgebungsvariable gesetzt (schreibgeschützt)',
     // MQTT
     mqttConnectedTo: 'Verbunden mit',
     // Prometheus

+ 4 - 0
frontend/src/i18n/locales/en.ts

@@ -1093,6 +1093,10 @@ export default {
     enableRetry: 'Enable retry',
     // Home Assistant
     homeAssistantDescription: 'Control smart plugs via Home Assistant',
+    environmentManagedLabel: '(Environment Managed)',
+    autoEnabledViaEnv: 'Automatically enabled via environment variables',
+    urlFromEnvReadOnly: 'Value set by HA_URL environment variable (read-only)',
+    tokenFromEnvReadOnly: 'Value set by HA_TOKEN environment variable (read-only)',
     // MQTT
     mqttConnectedTo: 'Connected to',
     // Prometheus

+ 4 - 0
frontend/src/i18n/locales/ja.ts

@@ -1274,6 +1274,10 @@ export default {
     connected: '接続済み',
     disconnected: '未接続',
     homeAssistantDescription: 'Home Assistantに接続してHA REST APIでスマートプラグを制御します。switch、light、input_booleanエンティティに対応しています。',
+    environmentManagedLabel: '(環境変数で管理)',
+    autoEnabledViaEnv: '環境変数により自動的に有効化されました',
+    urlFromEnvReadOnly: 'HA_URL環境変数で設定された値(読み取り専用)',
+    tokenFromEnvReadOnly: 'HA_TOKEN環境変数で設定された値(読み取り専用)',
     enableHA: 'Home Assistantを有効化',
     enableHADescription: 'Home Assistantでスマートプラグを制御',
     haUrl: 'Home Assistant URL',

+ 67 - 19
frontend/src/pages/SettingsPage.tsx

@@ -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">
+                        {t('settings.autoEnabledViaEnv')}
+                      </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">
+                          {t('settings.environmentManagedLabel')}
+                        </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">
+                        {t('settings.urlFromEnvReadOnly')}
+                      </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">
+                          {t('settings.environmentManagedLabel')}
+                        </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">
+                        {t('settings.tokenFromEnvReadOnly')}
+                      </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 && (