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:
bambuman
2026-02-07 19:01:04 +02:00
parent 833a0c62d2
commit eda1f5a9e7
10 changed files with 622 additions and 42 deletions
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+4 -12
View File
@@ -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
+3
View File
@@ -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;
+67 -19
View File
@@ -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 && (