Browse Source

Add Home Assistant smart plug integration
- Add plug_type field to SmartPlug model ("tasmota" or "homeassistant")
- Add ha_entity_id field for Home Assistant entity reference
- Add global HA settings (ha_url, ha_token, ha_enabled) in Settings
- Create homeassistant_service.py with REST API calls for entity control
- Update smart_plug_manager to dispatch to correct service by plug_type
- Add HA entity discovery endpoint (/ha/entities)
- Add HA connection test endpoint (/ha/test-connection)
- Add Home Assistant tab in AddSmartPlugModal with entity dropdown
- Add HA settings section in Settings → Network tab
- Filter already-configured entities from dropdown
- Update backup/restore to include plug_type and ha_entity_id
- Add frontend tests for HA plug rendering
- Add backend integration tests for HA endpoints
- Update README and CHANGELOG

Closes #91

maziggy 4 months ago
parent
commit
06d1d24b9c

+ 7 - 0
CHANGELOG.md

@@ -5,6 +5,13 @@ All notable changes to Bambuddy will be documented in this file.
 ## [Unreleased]
 ## [Unreleased]
 
 
 ### Added
 ### Added
+- **Home Assistant smart plug integration** - Control any Home Assistant switch/light entity as a smart plug:
+  - Configure HA connection (URL + Long-Lived Access Token) in Settings → Network
+  - Add HA-controlled plugs via Settings → Plugs → Add Smart Plug → Home Assistant tab
+  - Entity dropdown shows all available switch/light/input_boolean entities
+  - Full automation support: auto-on, auto-off, scheduling, power alerts
+  - Works alongside existing Tasmota plugs
+  - Closes [#91](https://github.com/maziggy/bambuddy/issues/91)
 - **Fusion 360 design file attachments** - Attach F3D files to archives for complete design tracking:
 - **Fusion 360 design file attachments** - Attach F3D files to archives for complete design tracking:
   - Upload F3D files via archive context menu ("Upload F3D" / "Replace F3D")
   - Upload F3D files via archive context menu ("Upload F3D" / "Replace F3D")
   - Cyan badge on archive card indicates attached F3D file (next to source 3MF badge)
   - Cyan badge on archive card indicates attached F3D file (next to source 3MF badge)

+ 1 - 1
README.md

@@ -71,7 +71,7 @@
 - Print queue with drag-and-drop
 - Print queue with drag-and-drop
 - Scheduled prints (date/time)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
 - Queue Only mode (stage without auto-start)
-- Smart plug integration (Tasmota)
+- Smart plug integration (Tasmota, Home Assistant)
 - Energy consumption tracking
 - Energy consumption tracking
 - Auto power-on before print
 - Auto power-on before print
 - Auto power-off after cooldown
 - Auto power-off after cooldown

+ 26 - 4
backend/app/api/routes/settings.py

@@ -76,6 +76,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
                 "ftp_retry_enabled",
                 "ftp_retry_enabled",
                 "mqtt_enabled",
                 "mqtt_enabled",
                 "mqtt_use_tls",
                 "mqtt_use_tls",
+                "ha_enabled",
             ]:
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
@@ -323,7 +324,9 @@ async def export_backup(
             backup["smart_plugs"].append(
             backup["smart_plugs"].append(
                 {
                 {
                     "name": plug.name,
                     "name": plug.name,
+                    "plug_type": plug.plug_type,
                     "ip_address": plug.ip_address,
                     "ip_address": plug.ip_address,
+                    "ha_entity_id": plug.ha_entity_id,
                     "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
                     "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
                     "enabled": plug.enabled,
                     "enabled": plug.enabled,
                     "auto_on": plug.auto_on,
                     "auto_on": plug.auto_on,
@@ -1056,11 +1059,28 @@ async def import_backup(
             printer_serial = plug_data.get("printer_serial")
             printer_serial = plug_data.get("printer_serial")
             printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else plug_data.get("printer_id")
             printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else plug_data.get("printer_id")
 
 
-            result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
-            existing = result.scalar_one_or_none()
+            # Determine plug type (default to tasmota for backwards compatibility)
+            plug_type = plug_data.get("plug_type", "tasmota")
+
+            # Find existing plug by IP (Tasmota) or entity_id (Home Assistant)
+            existing = None
+            if plug_type == "homeassistant" and plug_data.get("ha_entity_id"):
+                result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"]))
+                existing = result.scalar_one_or_none()
+                plug_identifier = plug_data["ha_entity_id"]
+            elif plug_data.get("ip_address"):
+                result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
+                existing = result.scalar_one_or_none()
+                plug_identifier = plug_data["ip_address"]
+            else:
+                # Skip invalid plug data
+                continue
+
             if existing:
             if existing:
                 if overwrite:
                 if overwrite:
                     existing.name = plug_data["name"]
                     existing.name = plug_data["name"]
+                    existing.plug_type = plug_type
+                    existing.ha_entity_id = plug_data.get("ha_entity_id")
                     existing.printer_id = printer_id
                     existing.printer_id = printer_id
                     existing.enabled = plug_data.get("enabled", True)
                     existing.enabled = plug_data.get("enabled", True)
                     existing.auto_on = plug_data.get("auto_on", True)
                     existing.auto_on = plug_data.get("auto_on", True)
@@ -1080,11 +1100,13 @@ async def import_backup(
                     restored["smart_plugs"] += 1
                     restored["smart_plugs"] += 1
                 else:
                 else:
                     skipped["smart_plugs"] += 1
                     skipped["smart_plugs"] += 1
-                    skipped_details["smart_plugs"].append(f"{plug_data['name']} ({plug_data['ip_address']})")
+                    skipped_details["smart_plugs"].append(f"{plug_data['name']} ({plug_identifier})")
             else:
             else:
                 plug = SmartPlug(
                 plug = SmartPlug(
                     name=plug_data["name"],
                     name=plug_data["name"],
-                    ip_address=plug_data["ip_address"],
+                    plug_type=plug_type,
+                    ip_address=plug_data.get("ip_address"),
+                    ha_entity_id=plug_data.get("ha_entity_id"),
                     printer_id=printer_id,
                     printer_id=printer_id,
                     enabled=plug_data.get("enabled", True),
                     enabled=plug_data.get("enabled", True),
                     auto_on=plug_data.get("auto_on", True),
                     auto_on=plug_data.get("auto_on", True),

+ 57 - 6
backend/app/api/routes/smart_plugs.py

@@ -8,10 +8,14 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.api.routes.settings import get_setting
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.schemas.smart_plug import (
 from backend.app.schemas.smart_plug import (
+    HAEntity,
+    HATestConnectionRequest,
+    HATestConnectionResponse,
     SmartPlugControl,
     SmartPlugControl,
     SmartPlugCreate,
     SmartPlugCreate,
     SmartPlugEnergy,
     SmartPlugEnergy,
@@ -21,6 +25,7 @@ from backend.app.schemas.smart_plug import (
     SmartPlugUpdate,
     SmartPlugUpdate,
 )
 )
 from backend.app.services.discovery import tasmota_scanner
 from backend.app.services.discovery import tasmota_scanner
+from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.tasmota import tasmota_service
 from backend.app.services.tasmota import tasmota_service
@@ -59,7 +64,10 @@ async def create_smart_plug(
     await db.commit()
     await db.commit()
     await db.refresh(plug)
     await db.refresh(plug)
 
 
-    logger.info(f"Created smart plug '{plug.name}' at {plug.ip_address}")
+    if plug.plug_type == "homeassistant":
+        logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})")
+    else:
+        logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}")
     return plug
     return plug
 
 
 
 
@@ -191,6 +199,32 @@ async def get_discovered_tasmota_devices():
     ]
     ]
 
 
 
 
+# Home Assistant Discovery Endpoints
+
+
+@router.post("/ha/test-connection", response_model=HATestConnectionResponse)
+async def test_ha_connection(request: HATestConnectionRequest):
+    """Test connection to Home Assistant."""
+    result = await homeassistant_service.test_connection(request.url, request.token)
+    return HATestConnectionResponse(**result)
+
+
+@router.get("/ha/entities", response_model=list[HAEntity])
+async def list_ha_entities(db: AsyncSession = Depends(get_db)):
+    """List available Home Assistant 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 ""
+
+    if not ha_url or not ha_token:
+        raise HTTPException(400, "Home Assistant not configured. Please set HA URL and token in Settings.")
+
+    entities = await homeassistant_service.list_entities(ha_url, ha_token)
+    return [HAEntity(**e) for e in entities]
+
+
 @router.get("/{plug_id}", response_model=SmartPlugResponse)
 @router.get("/{plug_id}", response_model=SmartPlugResponse)
 async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
 async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific smart plug."""
     """Get a specific smart plug."""
@@ -260,6 +294,20 @@ async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
     return {"message": "Smart plug deleted"}
     return {"message": "Smart plug deleted"}
 
 
 
 
+async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):
+    """Get the appropriate service for the plug type.
+
+    For HA plugs, configures the service with current settings from DB.
+    """
+    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)
+        return homeassistant_service
+    return tasmota_service
+
+
 @router.post("/{plug_id}/control")
 @router.post("/{plug_id}/control")
 async def control_smart_plug(
 async def control_smart_plug(
     plug_id: int,
     plug_id: int,
@@ -272,14 +320,16 @@ async def control_smart_plug(
     if not plug:
     if not plug:
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
+    service = await _get_service_for_plug(plug, db)
+
     if control.action == "on":
     if control.action == "on":
-        success = await tasmota_service.turn_on(plug)
+        success = await service.turn_on(plug)
         expected_state = "ON"
         expected_state = "ON"
     elif control.action == "off":
     elif control.action == "off":
-        success = await tasmota_service.turn_off(plug)
+        success = await service.turn_off(plug)
         expected_state = "OFF"
         expected_state = "OFF"
     elif control.action == "toggle":
     elif control.action == "toggle":
-        success = await tasmota_service.toggle(plug)
+        success = await service.toggle(plug)
         expected_state = None  # Unknown after toggle
         expected_state = None  # Unknown after toggle
     else:
     else:
         raise HTTPException(400, f"Invalid action: {control.action}")
         raise HTTPException(400, f"Invalid action: {control.action}")
@@ -331,7 +381,8 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     if not plug:
     if not plug:
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
-    status = await tasmota_service.get_status(plug)
+    service = await _get_service_for_plug(plug, db)
+    status = await service.get_status(plug)
 
 
     # Update last state in database
     # Update last state in database
     if status["reachable"]:
     if status["reachable"]:
@@ -342,7 +393,7 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     # Fetch energy data if device is reachable
     # Fetch energy data if device is reachable
     energy_data = None
     energy_data = None
     if status["reachable"]:
     if status["reachable"]:
-        energy = await tasmota_service.get_energy(plug)
+        energy = await service.get_energy(plug)
         if energy:
         if energy:
             energy_data = SmartPlugEnergy(**energy)
             energy_data = SmartPlugEnergy(**energy)
 
 

+ 72 - 0
backend/app/core/database.py

@@ -456,6 +456,78 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         pass
 
 
+    # Migration: Add plug_type column to smart_plugs for HA integration
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN plug_type VARCHAR(20) DEFAULT 'tasmota'"))
+    except Exception:
+        pass
+
+    # Migration: Add ha_entity_id column to smart_plugs for HA integration
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_entity_id VARCHAR(100)"))
+    except Exception:
+        pass
+
+    # Migration: Make ip_address nullable for HA plugs (SQLite requires table recreation)
+    try:
+        # Check if ip_address is currently NOT NULL
+        result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
+        row = result.fetchone()
+        if row and "ip_address VARCHAR(45) NOT NULL" in (row[0] or ""):
+            # Need to migrate - ip_address is currently NOT NULL
+            await conn.execute(
+                text("""
+                CREATE TABLE smart_plugs_new (
+                    id INTEGER PRIMARY KEY,
+                    name VARCHAR(100) NOT NULL,
+                    ip_address VARCHAR(45),
+                    plug_type VARCHAR(20) DEFAULT 'tasmota',
+                    ha_entity_id VARCHAR(100),
+                    printer_id INTEGER UNIQUE REFERENCES printers(id) ON DELETE SET NULL,
+                    enabled BOOLEAN NOT NULL DEFAULT 1,
+                    auto_on BOOLEAN NOT NULL DEFAULT 1,
+                    auto_off BOOLEAN NOT NULL DEFAULT 1,
+                    off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
+                    off_delay_minutes INTEGER NOT NULL DEFAULT 5,
+                    off_temp_threshold INTEGER NOT NULL DEFAULT 70,
+                    username VARCHAR(50),
+                    password VARCHAR(100),
+                    power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
+                    power_alert_high FLOAT,
+                    power_alert_low FLOAT,
+                    power_alert_last_triggered DATETIME,
+                    schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
+                    schedule_on_time VARCHAR(5),
+                    schedule_off_time VARCHAR(5),
+                    show_in_switchbar BOOLEAN DEFAULT 0,
+                    last_state VARCHAR(10),
+                    last_checked DATETIME,
+                    auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
+                    auto_off_pending BOOLEAN DEFAULT 0,
+                    auto_off_pending_since DATETIME,
+                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+                    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
+                )
+            """)
+            )
+            await conn.execute(
+                text("""
+                INSERT INTO smart_plugs_new
+                SELECT id, name, ip_address,
+                       COALESCE(plug_type, 'tasmota'), ha_entity_id, printer_id,
+                       enabled, auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold,
+                       username, password, power_alert_enabled, power_alert_high, power_alert_low,
+                       power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
+                       COALESCE(show_in_switchbar, 0), last_state, last_checked, auto_off_executed,
+                       COALESCE(auto_off_pending, 0), auto_off_pending_since, created_at, updated_at
+                FROM smart_plugs
+            """)
+            )
+            await conn.execute(text("DROP TABLE smart_plugs"))
+            await conn.execute(text("ALTER TABLE smart_plugs_new RENAME TO smart_plugs"))
+    except Exception:
+        pass
+
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 7 - 2
backend/app/models/smart_plug.py

@@ -7,13 +7,18 @@ from backend.app.core.database import Base
 
 
 
 
 class SmartPlug(Base):
 class SmartPlug(Base):
-    """Tasmota smart plug for printer power control."""
+    """Smart plug for printer power control (Tasmota or Home Assistant)."""
 
 
     __tablename__ = "smart_plugs"
     __tablename__ = "smart_plugs"
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100))
     name: Mapped[str] = mapped_column(String(100))
-    ip_address: Mapped[str] = mapped_column(String(45))  # IPv4/IPv6
+    ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)  # IPv4/IPv6 (required for Tasmota)
+
+    # Plug type: "tasmota" (default) or "homeassistant"
+    plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
+    # Home Assistant entity ID (e.g., "switch.printer_plug")
+    ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
 
 
     # Link to printer (1:1)
     # Link to printer (1:1)
     printer_id: Mapped[int | None] = mapped_column(
     printer_id: Mapped[int | None] = mapped_column(

+ 8 - 0
backend/app/schemas/settings.py

@@ -85,6 +85,11 @@ class AppSettings(BaseModel):
     mqtt_topic_prefix: str = Field(default="bambuddy", description="Topic prefix for all published messages")
     mqtt_topic_prefix: str = Field(default="bambuddy", description="Topic prefix for all published messages")
     mqtt_use_tls: bool = Field(default=False, description="Use TLS/SSL encryption for MQTT connection")
     mqtt_use_tls: bool = Field(default=False, description="Use TLS/SSL encryption for MQTT connection")
 
 
+    # Home Assistant integration for smart plug control
+    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")
+
 
 
 class AppSettingsUpdate(BaseModel):
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
     """Schema for updating settings (all fields optional)."""
@@ -129,3 +134,6 @@ class AppSettingsUpdate(BaseModel):
     mqtt_password: str | None = None
     mqtt_password: str | None = None
     mqtt_topic_prefix: str | None = None
     mqtt_topic_prefix: str | None = None
     mqtt_use_tls: bool | None = None
     mqtt_use_tls: bool | None = None
+    ha_enabled: bool | None = None
+    ha_url: str | None = None
+    ha_token: str | None = None

+ 46 - 4
backend/app/schemas/smart_plug.py

@@ -1,12 +1,21 @@
 from datetime import datetime
 from datetime import datetime
 from typing import Literal
 from typing import Literal
 
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, model_validator
 
 
 
 
 class SmartPlugBase(BaseModel):
 class SmartPlugBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     name: str = Field(..., min_length=1, max_length=100)
-    ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
+    plug_type: Literal["tasmota", "homeassistant"] = "tasmota"
+
+    # Tasmota fields (required when plug_type="tasmota")
+    ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
+    username: str | None = None
+    password: str | None = None
+
+    # Home Assistant fields (required when plug_type="homeassistant")
+    ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean)\.[a-z0-9_]+$")
+
     printer_id: int | None = None
     printer_id: int | None = None
     enabled: bool = True
     enabled: bool = True
     auto_on: bool = True
     auto_on: bool = True
@@ -14,8 +23,6 @@ class SmartPlugBase(BaseModel):
     off_delay_mode: Literal["time", "temperature"] = "time"
     off_delay_mode: Literal["time", "temperature"] = "time"
     off_delay_minutes: int = Field(default=5, ge=0, le=60)
     off_delay_minutes: int = Field(default=5, ge=0, le=60)
     off_temp_threshold: int = Field(default=70, ge=30, le=150)
     off_temp_threshold: int = Field(default=70, ge=30, le=150)
-    username: str | None = None
-    password: str | None = None
     # Power alerts
     # Power alerts
     power_alert_enabled: bool = False
     power_alert_enabled: bool = False
     power_alert_high: float | None = Field(default=None, ge=0, le=5000)  # Alert when power > this (watts)
     power_alert_high: float | None = Field(default=None, ge=0, le=5000)  # Alert when power > this (watts)
@@ -27,6 +34,14 @@ class SmartPlugBase(BaseModel):
     # Switchbar visibility
     # Switchbar visibility
     show_in_switchbar: bool = False
     show_in_switchbar: bool = False
 
 
+    @model_validator(mode="after")
+    def validate_plug_type_fields(self) -> "SmartPlugBase":
+        if self.plug_type == "tasmota" and not self.ip_address:
+            raise ValueError("ip_address is required for Tasmota plugs")
+        if self.plug_type == "homeassistant" and not self.ha_entity_id:
+            raise ValueError("ha_entity_id is required for Home Assistant plugs")
+        return self
+
 
 
 class SmartPlugCreate(SmartPlugBase):
 class SmartPlugCreate(SmartPlugBase):
     pass
     pass
@@ -34,7 +49,9 @@ class SmartPlugCreate(SmartPlugBase):
 
 
 class SmartPlugUpdate(BaseModel):
 class SmartPlugUpdate(BaseModel):
     name: str | None = None
     name: str | None = None
+    plug_type: Literal["tasmota", "homeassistant"] | None = None
     ip_address: str | None = None
     ip_address: str | None = None
+    ha_entity_id: str | None = None
     printer_id: int | None = None
     printer_id: int | None = None
     enabled: bool | None = None
     enabled: bool | None = None
     auto_on: bool | None = None
     auto_on: bool | None = None
@@ -98,3 +115,28 @@ class SmartPlugTestConnection(BaseModel):
     ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
     ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
     username: str | None = None
     username: str | None = None
     password: str | None = None
     password: str | None = None
+
+
+# Home Assistant schemas
+class HATestConnectionRequest(BaseModel):
+    """Request to test Home Assistant connection."""
+
+    url: str = Field(..., min_length=1)
+    token: str = Field(..., min_length=1)
+
+
+class HATestConnectionResponse(BaseModel):
+    """Response from HA connection test."""
+
+    success: bool
+    message: str | None = None
+    error: str | None = None
+
+
+class HAEntity(BaseModel):
+    """A Home Assistant entity that can be used as a smart plug."""
+
+    entity_id: str
+    friendly_name: str
+    state: str | None = None
+    domain: str  # "switch", "light", "input_boolean"

+ 221 - 0
backend/app/services/homeassistant.py

@@ -0,0 +1,221 @@
+"""Service for communicating with Home Assistant via REST API."""
+
+import logging
+from typing import TYPE_CHECKING
+
+import httpx
+
+if TYPE_CHECKING:
+    from backend.app.models.smart_plug import SmartPlug
+
+logger = logging.getLogger(__name__)
+
+
+class HomeAssistantService:
+    """Service for controlling Home Assistant entities via REST API."""
+
+    def __init__(self, timeout: float = 10.0):
+        self.timeout = timeout
+        self.base_url: str = ""
+        self.token: str = ""
+
+    def configure(self, url: str, token: str):
+        """Configure HA connection settings."""
+        self.base_url = url.rstrip("/") if url else ""
+        self.token = token or ""
+
+    def _headers(self) -> dict:
+        return {
+            "Authorization": f"Bearer {self.token}",
+            "Content-Type": "application/json",
+        }
+
+    async def get_status(self, plug: "SmartPlug") -> dict:
+        """Get current state of HA entity.
+
+        Returns dict with:
+            - state: "ON" or "OFF" or None if unreachable
+            - reachable: bool
+            - device_name: str or None
+        """
+        if not self.base_url or not self.token:
+            return {"state": None, "reachable": False, "device_name": None}
+
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.get(
+                    f"{self.base_url}/api/states/{plug.ha_entity_id}",
+                    headers=self._headers(),
+                )
+                response.raise_for_status()
+                data = response.json()
+
+                state_value = data.get("state", "").lower()
+                # Normalize to ON/OFF
+                if state_value == "on":
+                    state = "ON"
+                elif state_value == "off":
+                    state = "OFF"
+                else:
+                    state = None
+
+                return {
+                    "state": state,
+                    "reachable": True,
+                    "device_name": data.get("attributes", {}).get("friendly_name"),
+                }
+        except Exception as e:
+            logger.warning(f"Failed to get HA entity state for {plug.ha_entity_id}: {e}")
+            return {"state": None, "reachable": False, "device_name": None}
+
+    async def turn_on(self, plug: "SmartPlug") -> bool:
+        """Turn on HA entity. Returns True if successful."""
+        success = await self._call_service(plug, "turn_on")
+        if success:
+            logger.info(f"Turned ON HA entity '{plug.name}' ({plug.ha_entity_id})")
+        return success
+
+    async def turn_off(self, plug: "SmartPlug") -> bool:
+        """Turn off HA entity. Returns True if successful."""
+        success = await self._call_service(plug, "turn_off")
+        if success:
+            logger.info(f"Turned OFF HA entity '{plug.name}' ({plug.ha_entity_id})")
+        return success
+
+    async def toggle(self, plug: "SmartPlug") -> bool:
+        """Toggle HA entity. Returns True if successful."""
+        success = await self._call_service(plug, "toggle")
+        if success:
+            logger.info(f"Toggled HA entity '{plug.name}' ({plug.ha_entity_id})")
+        return success
+
+    async def _call_service(self, plug: "SmartPlug", action: str) -> bool:
+        """Call HA service on entity."""
+        if not self.base_url or not self.token or not plug.ha_entity_id:
+            return False
+
+        domain = plug.ha_entity_id.split(".")[0]  # "switch", "light", etc.
+
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.post(
+                    f"{self.base_url}/api/services/{domain}/{action}",
+                    headers=self._headers(),
+                    json={"entity_id": plug.ha_entity_id},
+                )
+                response.raise_for_status()
+                return True
+        except Exception as e:
+            logger.warning(f"Failed to {action} HA entity {plug.ha_entity_id}: {e}")
+            return False
+
+    async def get_energy(self, plug: "SmartPlug") -> dict | None:
+        """Get energy data from HA entity attributes.
+
+        HA entities may have power attributes - check common patterns.
+        Returns dict with energy data or None if not available.
+        """
+        if not self.base_url or not self.token:
+            return None
+
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.get(
+                    f"{self.base_url}/api/states/{plug.ha_entity_id}",
+                    headers=self._headers(),
+                )
+                response.raise_for_status()
+                attrs = response.json().get("attributes", {})
+
+                # Common HA power monitoring attributes
+                power = attrs.get("current_power_w") or attrs.get("power")
+                if power is None:
+                    return None
+
+                return {
+                    "power": power,
+                    "voltage": attrs.get("voltage"),
+                    "current": attrs.get("current"),
+                    "today": attrs.get("today_energy_kwh"),
+                    "total": attrs.get("total_energy_kwh"),
+                    "yesterday": None,
+                    "factor": None,
+                    "apparent_power": None,
+                    "reactive_power": None,
+                }
+        except Exception:
+            return None
+
+    async def test_connection(self, url: str, token: str) -> dict:
+        """Test connection to Home Assistant.
+
+        Returns dict with:
+            - success: bool
+            - message: str or None (HA message on success)
+            - error: str or None (error message on failure)
+        """
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.get(
+                    f"{url.rstrip('/')}/api/",
+                    headers={"Authorization": f"Bearer {token}"},
+                )
+                response.raise_for_status()
+                data = response.json()
+                return {
+                    "success": True,
+                    "message": data.get("message", "Connected"),
+                    "error": None,
+                }
+        except httpx.HTTPStatusError as e:
+            if e.response.status_code == 401:
+                return {"success": False, "message": None, "error": "Invalid access token"}
+            return {"success": False, "message": None, "error": f"HTTP {e.response.status_code}"}
+        except httpx.TimeoutException:
+            return {"success": False, "message": None, "error": "Connection timeout"}
+        except httpx.ConnectError:
+            return {"success": False, "message": None, "error": "Could not connect to Home Assistant"}
+        except Exception as e:
+            return {"success": False, "message": None, "error": str(e)}
+
+    async def list_entities(self, url: str, token: str) -> list[dict]:
+        """List available switch/light entities from HA.
+
+        Returns list of entity dicts with:
+            - entity_id: str
+            - friendly_name: str
+            - state: str
+            - domain: str
+        """
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.get(
+                    f"{url.rstrip('/')}/api/states",
+                    headers={"Authorization": f"Bearer {token}"},
+                )
+                response.raise_for_status()
+
+                entities = []
+                for entity in response.json():
+                    entity_id = entity.get("entity_id", "")
+                    domain = entity_id.split(".")[0] if "." in entity_id else ""
+
+                    # Filter to switch, light, input_boolean domains
+                    if domain in ["switch", "light", "input_boolean"]:
+                        entities.append(
+                            {
+                                "entity_id": entity_id,
+                                "friendly_name": entity.get("attributes", {}).get("friendly_name", entity_id),
+                                "state": entity.get("state"),
+                                "domain": domain,
+                            }
+                        )
+
+                return sorted(entities, key=lambda x: x["friendly_name"].lower())
+        except Exception as e:
+            logger.warning(f"Failed to list HA entities: {e}")
+            return []
+
+
+# Singleton instance
+homeassistant_service = HomeAssistantService()

+ 74 - 17
backend/app/services/smart_plug_manager.py

@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.tasmota import tasmota_service
 from backend.app.services.tasmota import tasmota_service
 
 
@@ -26,6 +27,44 @@ class SmartPlugManager:
         self._scheduler_task: asyncio.Task | None = None
         self._scheduler_task: asyncio.Task | None = None
         self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
         self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
 
 
+    async def _get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
+        """Get the appropriate service for the plug type.
+
+        For HA plugs, configures the service with current settings from DB.
+        """
+        if plug.plug_type == "homeassistant":
+            # Configure HA service with current settings
+            await self._configure_ha_service(db)
+            return homeassistant_service
+        return tasmota_service
+
+    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
+
+        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()
+            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)
+        except Exception as e:
+            logger.warning(f"Failed to configure HA service: {e}")
+
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
         """Set the event loop for async operations."""
         """Set the event loop for async operations."""
         self._loop = loop
         self._loop = loop
@@ -71,12 +110,14 @@ class SmartPlugManager:
             plugs = result.scalars().all()
             plugs = result.scalars().all()
 
 
             for plug in plugs:
             for plug in plugs:
+                service = await self._get_service_for_plug(plug, db)
+
                 # Check if we should turn on
                 # Check if we should turn on
                 if plug.schedule_on_time == current_time:
                 if plug.schedule_on_time == current_time:
                     last_check = self._last_schedule_check.get(plug.id)
                     last_check = self._last_schedule_check.get(plug.id)
                     if last_check != f"on:{current_time}":
                     if last_check != f"on:{current_time}":
                         logger.info(f"Schedule: Turning on plug '{plug.name}' at {current_time}")
                         logger.info(f"Schedule: Turning on plug '{plug.name}' at {current_time}")
-                        success = await tasmota_service.turn_on(plug)
+                        success = await service.turn_on(plug)
                         if success:
                         if success:
                             plug.last_state = "ON"
                             plug.last_state = "ON"
                             plug.last_checked = datetime.utcnow()
                             plug.last_checked = datetime.utcnow()
@@ -87,7 +128,7 @@ class SmartPlugManager:
                     last_check = self._last_schedule_check.get(plug.id)
                     last_check = self._last_schedule_check.get(plug.id)
                     if last_check != f"off:{current_time}":
                     if last_check != f"off:{current_time}":
                         logger.info(f"Schedule: Turning off plug '{plug.name}' at {current_time}")
                         logger.info(f"Schedule: Turning off plug '{plug.name}' at {current_time}")
-                        success = await tasmota_service.turn_off(plug)
+                        success = await service.turn_off(plug)
                         if success:
                         if success:
                             plug.last_state = "OFF"
                             plug.last_state = "OFF"
                             plug.last_checked = datetime.utcnow()
                             plug.last_checked = datetime.utcnow()
@@ -125,7 +166,8 @@ class SmartPlugManager:
 
 
         # Turn on the plug
         # Turn on the plug
         logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
         logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
-        success = await tasmota_service.turn_on(plug)
+        service = await self._get_service_for_plug(plug, db)
+        success = await service.turn_on(plug)
 
 
         if success:
         if success:
             # Update last state and reset auto_off_executed
             # Update last state and reset auto_off_executed
@@ -180,14 +222,25 @@ class SmartPlugManager:
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
 
 
         task = asyncio.create_task(
         task = asyncio.create_task(
-            self._delayed_off(plug.id, plug.ip_address, plug.username, plug.password, printer_id, delay_seconds)
+            self._delayed_off(
+                plug.id,
+                plug.plug_type,
+                plug.ip_address,
+                plug.ha_entity_id,
+                plug.username,
+                plug.password,
+                printer_id,
+                delay_seconds,
+            )
         )
         )
         self._pending_off[plug.id] = task
         self._pending_off[plug.id] = task
 
 
     async def _delayed_off(
     async def _delayed_off(
         self,
         self,
         plug_id: int,
         plug_id: int,
-        ip_address: str,
+        plug_type: str,
+        ip_address: str | None,
+        ha_entity_id: str | None,
         username: str | None,
         username: str | None,
         password: str | None,
         password: str | None,
         printer_id: int,
         printer_id: int,
@@ -197,16 +250,19 @@ class SmartPlugManager:
         try:
         try:
             await asyncio.sleep(delay_seconds)
             await asyncio.sleep(delay_seconds)
 
 
-            # Create a minimal plug-like object for the tasmota service
+            # Create a minimal plug-like object for the service
             class PlugInfo:
             class PlugInfo:
                 def __init__(self):
                 def __init__(self):
+                    self.plug_type = plug_type
                     self.ip_address = ip_address
                     self.ip_address = ip_address
+                    self.ha_entity_id = ha_entity_id
                     self.username = username
                     self.username = username
                     self.password = password
                     self.password = password
                     self.name = f"plug_{plug_id}"
                     self.name = f"plug_{plug_id}"
 
 
             plug_info = PlugInfo()
             plug_info = PlugInfo()
-            success = await tasmota_service.turn_off(plug_info)
+            service = await self._get_service_for_plug(plug_info)
+            success = await service.turn_off(plug_info)
             logger.info(f"Turned off plug {plug_id} after time delay")
             logger.info(f"Turned off plug {plug_id} after time delay")
 
 
             # Mark auto_off_executed in database and update printer status
             # Mark auto_off_executed in database and update printer status
@@ -233,7 +289,9 @@ class SmartPlugManager:
         task = asyncio.create_task(
         task = asyncio.create_task(
             self._temp_based_off(
             self._temp_based_off(
                 plug.id,
                 plug.id,
+                plug.plug_type,
                 plug.ip_address,
                 plug.ip_address,
+                plug.ha_entity_id,
                 plug.username,
                 plug.username,
                 plug.password,
                 plug.password,
                 printer_id,
                 printer_id,
@@ -245,7 +303,9 @@ class SmartPlugManager:
     async def _temp_based_off(
     async def _temp_based_off(
         self,
         self,
         plug_id: int,
         plug_id: int,
-        ip_address: str,
+        plug_type: str,
+        ip_address: str | None,
+        ha_entity_id: str | None,
         username: str | None,
         username: str | None,
         password: str | None,
         password: str | None,
         printer_id: int,
         printer_id: int,
@@ -285,13 +345,16 @@ class SmartPlugManager:
                         # All nozzles are below threshold, turn off
                         # All nozzles are below threshold, turn off
                         class PlugInfo:
                         class PlugInfo:
                             def __init__(self):
                             def __init__(self):
+                                self.plug_type = plug_type
                                 self.ip_address = ip_address
                                 self.ip_address = ip_address
+                                self.ha_entity_id = ha_entity_id
                                 self.username = username
                                 self.username = username
                                 self.password = password
                                 self.password = password
                                 self.name = f"plug_{plug_id}"
                                 self.name = f"plug_{plug_id}"
 
 
                         plug_info = PlugInfo()
                         plug_info = PlugInfo()
-                        success = await tasmota_service.turn_off(plug_info)
+                        service = await self._get_service_for_plug(plug_info)
+                        success = await service.turn_off(plug_info)
                         logger.info(
                         logger.info(
                             f"Turned off plug {plug_id} after nozzle temp dropped to "
                             f"Turned off plug {plug_id} after nozzle temp dropped to "
                             f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)"
                             f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)"
@@ -411,14 +474,8 @@ class SmartPlugManager:
                         # For time mode, just turn off immediately since delay already passed
                         # For time mode, just turn off immediately since delay already passed
                         logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
                         logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
 
 
-                        class PlugInfo:
-                            def __init__(self, p):
-                                self.ip_address = p.ip_address
-                                self.username = p.username
-                                self.password = p.password
-                                self.name = p.name
-
-                        success = await tasmota_service.turn_off(PlugInfo(plug))
+                        service = await self._get_service_for_plug(plug, db)
+                        success = await service.turn_off(plug)
                         if success:
                         if success:
                             await self._mark_auto_off_executed(plug.id)
                             await self._mark_auto_off_executed(plug.id)
                             printer_manager.mark_printer_offline(plug.printer_id)
                             printer_manager.mark_printer_offline(plug.printer_id)

+ 51 - 1
backend/tests/conftest.py

@@ -147,6 +147,44 @@ def mock_tasmota_service():
         yield mock
         yield mock
 
 
 
 
+@pytest.fixture
+def mock_homeassistant_service():
+    """Mock the Home Assistant service for smart plug tests."""
+    # Patch both the module where it's defined and where it's imported
+    with (
+        patch("backend.app.services.homeassistant.homeassistant_service") as mock,
+        patch("backend.app.api.routes.smart_plugs.homeassistant_service") as mock2,
+    ):
+        mock.turn_on = AsyncMock(return_value=True)
+        mock.turn_off = AsyncMock(return_value=True)
+        mock.toggle = AsyncMock(return_value=True)
+        mock.get_status = AsyncMock(return_value={"state": "ON", "reachable": True, "device_name": "Test HA Entity"})
+        mock.get_energy = AsyncMock(return_value=None)  # Most HA entities don't have power monitoring
+        mock.test_connection = AsyncMock(return_value={"success": True, "message": "API running", "error": None})
+        mock.list_entities = AsyncMock(
+            return_value=[
+                {
+                    "entity_id": "switch.printer_plug",
+                    "friendly_name": "Printer Plug",
+                    "state": "on",
+                    "domain": "switch",
+                },
+                {"entity_id": "switch.test", "friendly_name": "Test Switch", "state": "off", "domain": "switch"},
+            ]
+        )
+        mock.configure = MagicMock()
+        # Copy mocks to second patch target
+        mock2.turn_on = mock.turn_on
+        mock2.turn_off = mock.turn_off
+        mock2.toggle = mock.toggle
+        mock2.get_status = mock.get_status
+        mock2.get_energy = mock.get_energy
+        mock2.test_connection = mock.test_connection
+        mock2.list_entities = mock.list_entities
+        mock2.configure = mock.configure
+        yield mock
+
+
 @pytest.fixture
 @pytest.fixture
 def mock_mqtt_client():
 def mock_mqtt_client():
     """Mock the MQTT client for printer communication tests."""
     """Mock the MQTT client for printer communication tests."""
@@ -219,9 +257,12 @@ def smart_plug_factory(db_session):
     async def _create_plug(**kwargs):
     async def _create_plug(**kwargs):
         from backend.app.models.smart_plug import SmartPlug
         from backend.app.models.smart_plug import SmartPlug
 
 
+        # Determine defaults based on plug_type
+        plug_type = kwargs.get("plug_type", "tasmota")
+
         defaults = {
         defaults = {
             "name": "Test Plug",
             "name": "Test Plug",
-            "ip_address": "192.168.1.100",
+            "plug_type": plug_type,
             "enabled": True,
             "enabled": True,
             "auto_on": True,
             "auto_on": True,
             "auto_off": True,
             "auto_off": True,
@@ -231,6 +272,15 @@ def smart_plug_factory(db_session):
             "schedule_enabled": False,
             "schedule_enabled": False,
             "power_alert_enabled": False,
             "power_alert_enabled": False,
         }
         }
+
+        # Set required fields based on plug_type
+        if plug_type == "homeassistant":
+            defaults["ha_entity_id"] = "switch.test"
+            defaults["ip_address"] = None
+        else:
+            defaults["ip_address"] = "192.168.1.100"
+            defaults["ha_entity_id"] = None
+
         defaults.update(kwargs)
         defaults.update(kwargs)
 
 
         plug = SmartPlug(**defaults)
         plug = SmartPlug(**defaults)

+ 114 - 0
backend/tests/integration/test_smart_plugs_api.py

@@ -407,3 +407,117 @@ class TestSmartPlugsAPI:
         assert response.status_code == 200
         assert response.status_code == 200
         data = response.json()
         data = response.json()
         assert "running" in data
         assert "running" in data
+
+    # ========================================================================
+    # Home Assistant Integration tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_homeassistant_plug(self, async_client: AsyncClient):
+        """Verify Home Assistant plug can be created."""
+        data = {
+            "name": "HA Plug",
+            "plug_type": "homeassistant",
+            "ha_entity_id": "switch.printer_plug",
+            "enabled": True,
+            "auto_on": True,
+            "auto_off": False,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "HA Plug"
+        assert result["plug_type"] == "homeassistant"
+        assert result["ha_entity_id"] == "switch.printer_plug"
+        assert result["ip_address"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_homeassistant_plug_missing_entity_id(self, async_client: AsyncClient):
+        """Verify creating HA plug without entity_id fails."""
+        data = {
+            "name": "HA Plug",
+            "plug_type": "homeassistant",
+            # Missing ha_entity_id
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 422  # Validation error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_tasmota_plug_missing_ip(self, async_client: AsyncClient):
+        """Verify creating Tasmota plug without IP fails."""
+        data = {
+            "name": "Tasmota Plug",
+            "plug_type": "tasmota",
+            # Missing ip_address
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 422  # Validation error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ha_entities_endpoint_not_configured(self, async_client: AsyncClient):
+        """Verify HA entities endpoint returns error when not configured."""
+        response = await async_client.get("/api/v1/smart-plugs/ha/entities")
+
+        assert response.status_code == 400
+        assert "not configured" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_plug_type(self, async_client: AsyncClient, smart_plug_factory, db_session):
+        """Verify plug_type can be updated."""
+        plug = await smart_plug_factory(plug_type="tasmota", ip_address="192.168.1.100")
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={
+                "plug_type": "homeassistant",
+                "ha_entity_id": "switch.test",
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["plug_type"] == "homeassistant"
+        assert result["ha_entity_id"] == "switch.test"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_control_homeassistant_plug(
+        self, async_client: AsyncClient, smart_plug_factory, mock_homeassistant_service, db_session
+    ):
+        """Verify HA smart plug can be controlled."""
+        plug = await smart_plug_factory(plug_type="homeassistant", ha_entity_id="switch.test")
+
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["success"] is True
+        assert result["action"] == "on"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_homeassistant_plug_status(
+        self, async_client: AsyncClient, smart_plug_factory, mock_homeassistant_service, db_session
+    ):
+        """Verify HA smart plug status can be retrieved."""
+        plug = await smart_plug_factory(plug_type="homeassistant", ha_entity_id="switch.test")
+
+        response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}/status")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["state"] == "ON"
+        assert result["reachable"] is True

+ 43 - 0
frontend/src/__tests__/components/SmartPlugCard.test.tsx

@@ -18,7 +18,9 @@ import type { SmartPlug } from '../../api/client';
 const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
 const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
   id: 1,
   id: 1,
   name: 'Test Plug',
   name: 'Test Plug',
+  plug_type: 'tasmota',
   ip_address: '192.168.1.100',
   ip_address: '192.168.1.100',
+  ha_entity_id: null,
   printer_id: 1,
   printer_id: 1,
   enabled: true,
   enabled: true,
   auto_on: true,
   auto_on: true,
@@ -38,6 +40,7 @@ const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
   last_state: 'ON',
   last_state: 'ON',
   last_checked: null,
   last_checked: null,
   auto_off_executed: false,
   auto_off_executed: false,
+  show_in_switchbar: false,
   created_at: '2024-01-01T00:00:00Z',
   created_at: '2024-01-01T00:00:00Z',
   updated_at: '2024-01-01T00:00:00Z',
   updated_at: '2024-01-01T00:00:00Z',
   ...overrides,
   ...overrides,
@@ -229,4 +232,44 @@ describe('SmartPlugCard', () => {
       expect(screen.getByText('Test Plug')).toBeInTheDocument();
       expect(screen.getByText('Test Plug')).toBeInTheDocument();
     });
     });
   });
   });
+
+  describe('Home Assistant plugs', () => {
+    it('renders HA plug with entity_id instead of IP', () => {
+      const plug = createMockPlug({
+        plug_type: 'homeassistant',
+        ip_address: null,
+        ha_entity_id: 'switch.printer_plug',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      // Should show entity_id, not IP
+      expect(screen.getByText('switch.printer_plug')).toBeInTheDocument();
+      expect(screen.queryByText('192.168.1.100')).not.toBeInTheDocument();
+    });
+
+    it('renders HA plug name correctly', () => {
+      const plug = createMockPlug({
+        name: 'HA Printer Plug',
+        plug_type: 'homeassistant',
+        ip_address: null,
+        ha_entity_id: 'switch.printer_plug',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      expect(screen.getByText('HA Printer Plug')).toBeInTheDocument();
+    });
+
+    it('shows power controls for HA plug', () => {
+      const plug = createMockPlug({
+        plug_type: 'homeassistant',
+        ip_address: null,
+        ha_entity_id: 'switch.printer_plug',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      // Power control buttons should still be present
+      const buttons = screen.getAllByRole('button');
+      expect(buttons.length).toBeGreaterThan(0);
+    });
+  });
 });
 });

+ 36 - 3
frontend/src/api/client.ts

@@ -586,6 +586,10 @@ export interface AppSettings {
   mqtt_password: string;
   mqtt_password: string;
   mqtt_topic_prefix: string;
   mqtt_topic_prefix: string;
   mqtt_use_tls: boolean;
   mqtt_use_tls: boolean;
+  // Home Assistant integration
+  ha_enabled: boolean;
+  ha_url: string;
+  ha_token: string;
 }
 }
 
 
 export type AppSettingsUpdate = Partial<AppSettings>;
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -695,7 +699,9 @@ export interface CloudDevice {
 export interface SmartPlug {
 export interface SmartPlug {
   id: number;
   id: number;
   name: string;
   name: string;
-  ip_address: string;
+  plug_type: 'tasmota' | 'homeassistant';
+  ip_address: string | null;  // Required for Tasmota
+  ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug")
   printer_id: number | null;
   printer_id: number | null;
   enabled: boolean;
   enabled: boolean;
   auto_on: boolean;
   auto_on: boolean;
@@ -726,7 +732,9 @@ export interface SmartPlug {
 
 
 export interface SmartPlugCreate {
 export interface SmartPlugCreate {
   name: string;
   name: string;
-  ip_address: string;
+  plug_type?: 'tasmota' | 'homeassistant';
+  ip_address?: string | null;  // Required for Tasmota
+  ha_entity_id?: string | null;  // Required for Home Assistant
   printer_id?: number | null;
   printer_id?: number | null;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
@@ -750,7 +758,9 @@ export interface SmartPlugCreate {
 
 
 export interface SmartPlugUpdate {
 export interface SmartPlugUpdate {
   name?: string;
   name?: string;
-  ip_address?: string;
+  plug_type?: 'tasmota' | 'homeassistant';
+  ip_address?: string | null;
+  ha_entity_id?: string | null;
   printer_id?: number | null;
   printer_id?: number | null;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
@@ -772,6 +782,20 @@ export interface SmartPlugUpdate {
   show_in_switchbar?: boolean;
   show_in_switchbar?: boolean;
 }
 }
 
 
+// Home Assistant entity for smart plug selection
+export interface HAEntity {
+  entity_id: string;
+  friendly_name: string;
+  state: string | null;
+  domain: string;  // "switch", "light", "input_boolean"
+}
+
+export interface HATestConnectionResult {
+  success: boolean;
+  message: string | null;
+  error: string | null;
+}
+
 export interface SmartPlugEnergy {
 export interface SmartPlugEnergy {
   power: number | null;  // Current watts
   power: number | null;  // Current watts
   voltage: number | null;  // Volts
   voltage: number | null;  // Volts
@@ -1979,6 +2003,15 @@ export const api = {
   getDiscoveredTasmotaDevices: () =>
   getDiscoveredTasmotaDevices: () =>
     request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
     request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
 
 
+  // Home Assistant Integration
+  testHAConnection: (url: string, token: string) =>
+    request<HATestConnectionResult>('/smart-plugs/ha/test-connection', {
+      method: 'POST',
+      body: JSON.stringify({ url, token }),
+    }),
+  getHAEntities: () =>
+    request<HAEntity[]>('/smart-plugs/ha/entities'),
+
   // Print Queue
   // Print Queue
   getQueue: (printerId?: number, status?: string) => {
   getQueue: (printerId?: number, status?: string) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();

+ 204 - 63
frontend/src/components/AddSmartPlugModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power } from 'lucide-react';
+import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -14,10 +14,17 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const isEditing = !!plug;
   const isEditing = !!plug;
 
 
+  // Plug type selection
+  const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant'>(plug?.plug_type || 'tasmota');
+
   const [name, setName] = useState(plug?.name || '');
   const [name, setName] = useState(plug?.name || '');
+  // Tasmota fields
   const [ipAddress, setIpAddress] = useState(plug?.ip_address || '');
   const [ipAddress, setIpAddress] = useState(plug?.ip_address || '');
   const [username, setUsername] = useState(plug?.username || '');
   const [username, setUsername] = useState(plug?.username || '');
   const [password, setPassword] = useState(plug?.password || '');
   const [password, setPassword] = useState(plug?.password || '');
+  // Home Assistant fields
+  const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || '');
+
   const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
   const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
   const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
   const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
@@ -53,6 +60,14 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     queryFn: api.getSmartPlugs,
     queryFn: api.getSmartPlugs,
   });
   });
 
 
+  // Fetch Home Assistant entities when in HA mode
+  const { data: haEntities, isLoading: haEntitiesLoading, error: haEntitiesError } = useQuery({
+    queryKey: ['ha-entities'],
+    queryFn: api.getHAEntities,
+    enabled: plugType === 'homeassistant',
+    retry: false,
+  });
+
   // Close on Escape key and cleanup scan polling
   // Close on Escape key and cleanup scan polling
   useEffect(() => {
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -184,16 +199,24 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       setError('Name is required');
       setError('Name is required');
       return;
       return;
     }
     }
-    if (!ipAddress.trim()) {
-      setError('IP address is required');
+
+    if (plugType === 'tasmota' && !ipAddress.trim()) {
+      setError('IP address is required for Tasmota plugs');
+      return;
+    }
+
+    if (plugType === 'homeassistant' && !haEntityId) {
+      setError('Entity is required for Home Assistant plugs');
       return;
       return;
     }
     }
 
 
     const data = {
     const data = {
       name: name.trim(),
       name: name.trim(),
-      ip_address: ipAddress.trim(),
-      username: username.trim() || null,
-      password: password.trim() || null,
+      plug_type: plugType,
+      ip_address: plugType === 'tasmota' ? ipAddress.trim() : null,
+      ha_entity_id: plugType === 'homeassistant' ? haEntityId : null,
+      username: plugType === 'tasmota' ? (username.trim() || null) : null,
+      password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
       printer_id: printerId,
       // Power alerts
       // Power alerts
       power_alert_enabled: powerAlertEnabled,
       power_alert_enabled: powerAlertEnabled,
@@ -246,8 +269,46 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </div>
             </div>
           )}
           )}
 
 
-          {/* Discovery Section - only show when not editing */}
+          {/* Plug Type Selector - only show when not editing */}
           {!isEditing && (
           {!isEditing && (
+            <div className="flex gap-2 mb-2">
+              <button
+                type="button"
+                onClick={() => {
+                  setPlugType('tasmota');
+                  setTestResult(null);
+                  setError(null);
+                }}
+                className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
+                  plugType === 'tasmota'
+                    ? 'bg-bambu-green text-white'
+                    : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
+                }`}
+              >
+                <Plug className="w-4 h-4" />
+                Tasmota
+              </button>
+              <button
+                type="button"
+                onClick={() => {
+                  setPlugType('homeassistant');
+                  setTestResult(null);
+                  setError(null);
+                }}
+                className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
+                  plugType === 'homeassistant'
+                    ? 'bg-bambu-green text-white'
+                    : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
+                }`}
+              >
+                <Home className="w-4 h-4" />
+                Home Assistant
+              </button>
+            </div>
+          )}
+
+          {/* Discovery Section - only show when not editing and Tasmota is selected */}
+          {!isEditing && plugType === 'tasmota' && (
             <div className="space-y-3">
             <div className="space-y-3">
               {/* Scan button - auto-detects network */}
               {/* Scan button - auto-detects network */}
               {isScanning ? (
               {isScanning ? (
@@ -319,38 +380,114 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </div>
             </div>
           )}
           )}
 
 
-          {/* IP Address */}
-          <div>
-            <label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
-            <div className="flex gap-2">
-              <input
-                type="text"
-                value={ipAddress}
-                onChange={(e) => {
-                  setIpAddress(e.target.value);
-                  setTestResult(null);
-                }}
-                placeholder="192.168.1.100"
-                className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-              />
-              <Button
-                type="button"
-                variant="secondary"
-                onClick={() => testMutation.mutate()}
-                disabled={!ipAddress.trim() || testMutation.isPending}
-              >
-                {testMutation.isPending ? (
-                  <Loader2 className="w-4 h-4 animate-spin" />
-                ) : (
-                  <Wifi className="w-4 h-4" />
-                )}
-                Test
-              </Button>
+          {/* Home Assistant Entity Selector - only show when HA is selected */}
+          {plugType === 'homeassistant' && (
+            <div className="space-y-3">
+              {haEntitiesLoading && (
+                <div className="flex items-center justify-center py-4 text-bambu-gray">
+                  <Loader2 className="w-5 h-5 animate-spin mr-2" />
+                  Loading entities...
+                </div>
+              )}
+
+              {haEntitiesError && (
+                <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+                  {haEntitiesError instanceof Error ? haEntitiesError.message : 'Failed to load Home Assistant entities. Check Settings → Network → Home Assistant.'}
+                </div>
+              )}
+
+              {haEntities && haEntities.length === 0 && (
+                <div className="p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400">
+                  No switch/light entities found in Home Assistant
+                </div>
+              )}
+
+              {haEntities && haEntities.length > 0 && (() => {
+                // Filter out entities already configured (except current plug when editing)
+                const configuredEntityIds = existingPlugs
+                  ?.filter(p => p.ha_entity_id && p.id !== plug?.id)
+                  .map(p => p.ha_entity_id) || [];
+                const availableEntities = haEntities.filter(e => !configuredEntityIds.includes(e.entity_id));
+
+                return (
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">Select Entity *</label>
+                    <select
+                      value={haEntityId}
+                      onChange={(e) => {
+                        setHaEntityId(e.target.value);
+                        // Auto-fill name from entity friendly name
+                        const entity = haEntities?.find(ent => ent.entity_id === e.target.value);
+                        if (entity && !name) {
+                          setName(entity.friendly_name);
+                        }
+                      }}
+                      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"
+                    >
+                      <option value="">Choose an entity...</option>
+                      {availableEntities.map((entity) => (
+                        <option key={entity.entity_id} value={entity.entity_id}>
+                          {entity.friendly_name} ({entity.entity_id}) - {entity.state}
+                        </option>
+                      ))}
+                    </select>
+                    {configuredEntityIds.length > 0 && (
+                      <p className="text-xs text-bambu-gray mt-1">
+                        {configuredEntityIds.length} entity(s) already configured
+                      </p>
+                    )}
+                  </div>
+                );
+              })()}
+
+              {haEntityId && haEntities && (
+                <div className="p-3 bg-bambu-green/20 border border-bambu-green/50 rounded-lg text-sm text-bambu-green flex items-center gap-2">
+                  <CheckCircle className="w-5 h-5" />
+                  <div>
+                    <p className="font-medium">Entity selected</p>
+                    <p className="text-xs opacity-80">
+                      {haEntities.find(e => e.entity_id === haEntityId)?.friendly_name} - {haEntities.find(e => e.entity_id === haEntityId)?.state}
+                    </p>
+                  </div>
+                </div>
+              )}
             </div>
             </div>
-          </div>
+          )}
 
 
-          {/* Test Result */}
-          {testResult && (
+          {/* IP Address - only show for Tasmota */}
+          {plugType === 'tasmota' && (
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
+              <div className="flex gap-2">
+                <input
+                  type="text"
+                  value={ipAddress}
+                  onChange={(e) => {
+                    setIpAddress(e.target.value);
+                    setTestResult(null);
+                  }}
+                  placeholder="192.168.1.100"
+                  className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                />
+                <Button
+                  type="button"
+                  variant="secondary"
+                  onClick={() => testMutation.mutate()}
+                  disabled={!ipAddress.trim() || testMutation.isPending}
+                >
+                  {testMutation.isPending ? (
+                    <Loader2 className="w-4 h-4 animate-spin" />
+                  ) : (
+                    <Wifi className="w-4 h-4" />
+                  )}
+                  Test
+                </Button>
+              </div>
+            </div>
+          )}
+
+          {/* Test Result - only show for Tasmota */}
+          {plugType === 'tasmota' && testResult && (
             <div className={`p-3 rounded-lg flex items-center gap-2 ${
             <div className={`p-3 rounded-lg flex items-center gap-2 ${
               testResult.success
               testResult.success
                 ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
                 ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
@@ -388,32 +525,36 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             />
             />
           </div>
           </div>
 
 
-          {/* Authentication (optional) */}
-          <div className="grid grid-cols-2 gap-3">
-            <div>
-              <label className="block text-sm text-bambu-gray mb-1">Username</label>
-              <input
-                type="text"
-                value={username}
-                onChange={(e) => setUsername(e.target.value)}
-                placeholder="admin"
-                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>
-            <div>
-              <label className="block text-sm text-bambu-gray mb-1">Password</label>
-              <input
-                type="password"
-                value={password}
-                onChange={(e) => setPassword(e.target.value)}
-                placeholder="********"
-                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>
-          </div>
-          <p className="text-xs text-bambu-gray -mt-2">
-            Leave empty if your Tasmota device doesn't require authentication
-          </p>
+          {/* Authentication (optional) - only show for Tasmota */}
+          {plugType === 'tasmota' && (
+            <>
+              <div className="grid grid-cols-2 gap-3">
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">Username</label>
+                  <input
+                    type="text"
+                    value={username}
+                    onChange={(e) => setUsername(e.target.value)}
+                    placeholder="admin"
+                    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>
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">Password</label>
+                  <input
+                    type="password"
+                    value={password}
+                    onChange={(e) => setPassword(e.target.value)}
+                    placeholder="********"
+                    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>
+              </div>
+              <p className="text-xs text-bambu-gray -mt-2">
+                Leave empty if your Tasmota device doesn't require authentication
+              </p>
+            </>
+          )}
 
 
           {/* Link to Printer */}
           {/* Link to Printer */}
           <div>
           <div>

+ 59 - 19
frontend/src/components/SmartPlugCard.tsx

@@ -1,11 +1,12 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink } from 'lucide-react';
+import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
 
 
 interface SmartPlugCardProps {
 interface SmartPlugCardProps {
   plug: SmartPlug;
   plug: SmartPlug;
@@ -14,13 +15,14 @@ interface SmartPlugCardProps {
 
 
 export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
 export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
+  const { showToast } = useToast();
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
   const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
   const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
   const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
   const [isExpanded, setIsExpanded] = useState(false);
   const [isExpanded, setIsExpanded] = useState(false);
 
 
   // Fetch current status
   // Fetch current status
-  const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
+  const { data: status, isLoading: statusLoading } = useQuery({
     queryKey: ['smart-plug-status', plug.id],
     queryKey: ['smart-plug-status', plug.id],
     queryFn: () => api.getSmartPlugStatus(plug.id),
     queryFn: () => api.getSmartPlugStatus(plug.id),
     refetchInterval: 30000, // Refresh every 30 seconds
     refetchInterval: 30000, // Refresh every 30 seconds
@@ -34,11 +36,38 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
 
 
   const linkedPrinter = printers?.find(p => p.id === plug.printer_id);
   const linkedPrinter = printers?.find(p => p.id === plug.printer_id);
 
 
-  // Control mutation
+  // Control mutation with optimistic updates
   const controlMutation = useMutation({
   const controlMutation = useMutation({
     mutationFn: (action: 'on' | 'off' | 'toggle') => api.controlSmartPlug(plug.id, action),
     mutationFn: (action: 'on' | 'off' | 'toggle') => api.controlSmartPlug(plug.id, action),
-    onSuccess: () => {
-      refetchStatus();
+    onMutate: async (action) => {
+      // Cancel any outgoing refetches
+      await queryClient.cancelQueries({ queryKey: ['smart-plug-status', plug.id] });
+
+      // Snapshot the previous value
+      const previousStatus = queryClient.getQueryData(['smart-plug-status', plug.id]);
+
+      // Optimistically update to the new value
+      const newState = action === 'on' ? 'ON' : action === 'off' ? 'OFF' : (status?.state === 'ON' ? 'OFF' : 'ON');
+      queryClient.setQueryData(['smart-plug-status', plug.id], (old: typeof status) => ({
+        ...old,
+        state: newState,
+      }));
+
+      return { previousStatus };
+    },
+    onError: (_err, action, context) => {
+      // Rollback on error
+      if (context?.previousStatus) {
+        queryClient.setQueryData(['smart-plug-status', plug.id], context.previousStatus);
+      }
+      showToast(`Failed to turn ${action} "${plug.name}"`, 'error');
+    },
+    onSettled: () => {
+      // Refetch after a short delay to get actual state
+      setTimeout(() => {
+        queryClient.invalidateQueries({ queryKey: ['smart-plug-status', plug.id] });
+        queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      }, 1000);
     },
     },
   });
   });
 
 
@@ -66,8 +95,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
   const isReachable = status?.reachable ?? false;
   const isReachable = status?.reachable ?? false;
   const isPending = controlMutation.isPending;
   const isPending = controlMutation.isPending;
 
 
-  // Generate admin URL with auto-login credentials
+  // Generate admin URL with auto-login credentials (Tasmota only)
   const getAdminUrl = () => {
   const getAdminUrl = () => {
+    if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null;
     const ip = plug.ip_address;
     const ip = plug.ip_address;
     if (plug.username && plug.password) {
     if (plug.username && plug.password) {
       // Use HTTP Basic Auth in URL for auto-login
       // Use HTTP Basic Auth in URL for auto-login
@@ -76,6 +106,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
     return `http://${ip}/`;
     return `http://${ip}/`;
   };
   };
 
 
+  const adminUrl = getAdminUrl();
+
   return (
   return (
     <>
     <>
       <Card className="relative">
       <Card className="relative">
@@ -84,11 +116,17 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           <div className="flex items-start justify-between mb-3">
           <div className="flex items-start justify-between mb-3">
             <div className="flex items-center gap-3">
             <div className="flex items-center gap-3">
               <div className={`p-2 rounded-lg ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
               <div className={`p-2 rounded-lg ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
-                <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+                {plug.plug_type === 'homeassistant' ? (
+                  <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+                ) : (
+                  <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+                )}
               </div>
               </div>
               <div>
               <div>
                 <h3 className="font-medium text-white">{plug.name}</h3>
                 <h3 className="font-medium text-white">{plug.name}</h3>
-                <p className="text-sm text-bambu-gray">{plug.ip_address}</p>
+                <p className="text-sm text-bambu-gray">
+                  {plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
+                </p>
               </div>
               </div>
             </div>
             </div>
 
 
@@ -107,17 +145,19 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                   <span>Offline</span>
                   <span>Offline</span>
                 </div>
                 </div>
               )}
               )}
-              {/* Admin page link */}
-              <a
-                href={getAdminUrl()}
-                target="_blank"
-                rel="noopener noreferrer"
-                className="flex items-center gap-1 px-2 py-0.5 bg-bambu-dark hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white text-xs rounded-full transition-colors"
-                title="Open plug admin page"
-              >
-                <ExternalLink className="w-3 h-3" />
-                Admin
-              </a>
+              {/* Admin page link - only for Tasmota */}
+              {adminUrl && (
+                <a
+                  href={adminUrl}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="flex items-center gap-1 px-2 py-0.5 bg-bambu-dark hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white text-xs rounded-full transition-colors"
+                  title="Open plug admin page"
+                >
+                  <ExternalLink className="w-3 h-3" />
+                  Admin
+                </a>
+              )}
             </div>
             </div>
           </div>
           </div>
 
 

+ 383 - 236
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { formatDateOnly } from '../utils/date';
 import { formatDateOnly } from '../utils/date';
@@ -67,6 +67,10 @@ export function SettingsPage() {
   const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
   const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
   const [showReleaseNotes, setShowReleaseNotes] = useState(false);
   const [showReleaseNotes, setShowReleaseNotes] = useState(false);
 
 
+  // Home Assistant test connection state
+  const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null);
+  const [haTestLoading, setHaTestLoading] = useState(false);
+
   const handleDefaultViewChange = (path: string) => {
   const handleDefaultViewChange = (path: string) => {
     setDefaultViewState(path);
     setDefaultViewState(path);
     setDefaultView(path);
     setDefaultView(path);
@@ -368,7 +372,10 @@ export function SettingsPage() {
       settings.mqtt_username !== localSettings.mqtt_username ||
       settings.mqtt_username !== localSettings.mqtt_username ||
       settings.mqtt_password !== localSettings.mqtt_password ||
       settings.mqtt_password !== localSettings.mqtt_password ||
       settings.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
       settings.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
-      settings.mqtt_use_tls !== localSettings.mqtt_use_tls;
+      settings.mqtt_use_tls !== localSettings.mqtt_use_tls ||
+      settings.ha_enabled !== localSettings.ha_enabled ||
+      settings.ha_url !== localSettings.ha_url ||
+      settings.ha_token !== localSettings.ha_token;
 
 
     if (!hasChanges) {
     if (!hasChanges) {
       return;
       return;
@@ -422,6 +429,9 @@ export function SettingsPage() {
         mqtt_password: localSettings.mqtt_password,
         mqtt_password: localSettings.mqtt_password,
         mqtt_topic_prefix: localSettings.mqtt_topic_prefix,
         mqtt_topic_prefix: localSettings.mqtt_topic_prefix,
         mqtt_use_tls: localSettings.mqtt_use_tls,
         mqtt_use_tls: localSettings.mqtt_use_tls,
+        ha_enabled: localSettings.ha_enabled,
+        ha_url: localSettings.ha_url,
+        ha_token: localSettings.ha_token,
       };
       };
       updateMutation.mutate(settingsToSave);
       updateMutation.mutate(settingsToSave);
     }, 500);
     }, 500);
@@ -933,141 +943,12 @@ export function SettingsPage() {
             </CardContent>
             </CardContent>
           </Card>
           </Card>
 
 
-          <Card>
-            <CardHeader>
-              <h2 className="text-lg font-semibold text-white">AMS Display Thresholds</h2>
-            </CardHeader>
-            <CardContent className="space-y-4">
-              <p className="text-sm text-bambu-gray">
-                Configure color thresholds for AMS humidity and temperature indicators.
-              </p>
-
-              {/* Humidity Thresholds */}
-              <div className="space-y-3">
-                <div className="flex items-center gap-2 text-white">
-                  <Droplets className="w-4 h-4 text-blue-400" />
-                  <span className="font-medium">Humidity</span>
-                </div>
-                <div className="grid grid-cols-2 gap-3">
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">
-                      Good (green) ≤
-                    </label>
-                    <div className="flex items-center gap-2">
-                      <input
-                        type="number"
-                        min="0"
-                        max="100"
-                        value={localSettings.ams_humidity_good ?? 40}
-                        onChange={(e) => updateSetting('ams_humidity_good', parseInt(e.target.value) || 40)}
-                        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"
-                      />
-                      <span className="text-bambu-gray">%</span>
-                    </div>
-                  </div>
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">
-                      Fair (orange) ≤
-                    </label>
-                    <div className="flex items-center gap-2">
-                      <input
-                        type="number"
-                        min="0"
-                        max="100"
-                        value={localSettings.ams_humidity_fair ?? 60}
-                        onChange={(e) => updateSetting('ams_humidity_fair', parseInt(e.target.value) || 60)}
-                        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"
-                      />
-                      <span className="text-bambu-gray">%</span>
-                    </div>
-                  </div>
-                </div>
-                <p className="text-xs text-bambu-gray">
-                  Above fair threshold shows as red (bad)
-                </p>
-              </div>
-
-              {/* Temperature Thresholds */}
-              <div className="space-y-3 pt-2 border-t border-bambu-dark-tertiary">
-                <div className="flex items-center gap-2 text-white">
-                  <Thermometer className="w-4 h-4 text-orange-400" />
-                  <span className="font-medium">Temperature</span>
-                </div>
-                <div className="grid grid-cols-2 gap-3">
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">
-                      Good (blue) ≤
-                    </label>
-                    <div className="flex items-center gap-2">
-                      <input
-                        type="number"
-                        step="0.5"
-                        min="0"
-                        max="60"
-                        value={localSettings.ams_temp_good ?? 28}
-                        onChange={(e) => updateSetting('ams_temp_good', parseFloat(e.target.value) || 28)}
-                        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"
-                      />
-                      <span className="text-bambu-gray">°C</span>
-                    </div>
-                  </div>
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">
-                      Fair (orange) ≤
-                    </label>
-                    <div className="flex items-center gap-2">
-                      <input
-                        type="number"
-                        step="0.5"
-                        min="0"
-                        max="60"
-                        value={localSettings.ams_temp_fair ?? 35}
-                        onChange={(e) => updateSetting('ams_temp_fair', parseFloat(e.target.value) || 35)}
-                        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"
-                      />
-                      <span className="text-bambu-gray">°C</span>
-                    </div>
-                  </div>
-                </div>
-                <p className="text-xs text-bambu-gray">
-                  Above fair threshold shows as red (hot)
-                </p>
-              </div>
-
-              {/* History Retention */}
-              <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
-                <div className="flex items-center gap-2 text-white">
-                  <Database className="w-4 h-4 text-purple-400" />
-                  <span className="font-medium">History Retention</span>
-                </div>
-                <div>
-                  <label className="block text-sm text-bambu-gray mb-1">
-                    Keep sensor history for
-                  </label>
-                  <div className="flex items-center gap-2">
-                    <input
-                      type="number"
-                      min="1"
-                      max="365"
-                      value={localSettings.ams_history_retention_days ?? 30}
-                      onChange={(e) => updateSetting('ams_history_retention_days', parseInt(e.target.value) || 30)}
-                      className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                    />
-                    <span className="text-bambu-gray">days</span>
-                  </div>
-                </div>
-                <p className="text-xs text-bambu-gray">
-                  Older humidity and temperature data will be automatically deleted
-                </p>
-              </div>
-            </CardContent>
-          </Card>
-
+          {/* Sidebar Links */}
+          <ExternalLinksSettings />
         </div>
         </div>
 
 
-        {/* Third Column - Updates */}
+        {/* Right Column - Updates */}
         <div className="space-y-6 flex-1 lg:max-w-sm">
         <div className="space-y-6 flex-1 lg:max-w-sm">
-          <ExternalLinksSettings />
 
 
           <Card>
           <Card>
             <CardHeader>
             <CardHeader>
@@ -1314,7 +1195,205 @@ export function SettingsPage() {
       {/* Network Tab */}
       {/* Network Tab */}
       {activeTab === 'network' && localSettings && (
       {activeTab === 'network' && localSettings && (
       <div className="flex flex-col lg:flex-row gap-6">
       <div className="flex flex-col lg:flex-row gap-6">
-        {/* Left Column - MQTT */}
+        {/* Left Column - FTP Retry & Home Assistant */}
+        <div className="flex-1 lg:max-w-xl space-y-4">
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <RefreshCw className="w-5 h-5 text-blue-400" />
+                FTP Retry
+              </h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-sm text-bambu-gray">
+                Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.
+              </p>
+
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Enable retry</p>
+                  <p className="text-sm text-bambu-gray">
+                    Automatically retry failed FTP operations
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.ftp_retry_enabled ?? true}
+                    onChange={(e) => updateSetting('ftp_retry_enabled', e.target.checked)}
+                    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>
+                </label>
+              </div>
+
+              {localSettings.ftp_retry_enabled && (
+                <div className="space-y-4 pt-2 border-t border-bambu-dark-tertiary">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Retry attempts
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min="1"
+                        max="10"
+                        value={localSettings.ftp_retry_count ?? 3}
+                        onChange={(e) => updateSetting('ftp_retry_count', Math.min(10, Math.max(1, parseInt(e.target.value) || 3)))}
+                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                      <span className="text-bambu-gray">times</span>
+                    </div>
+                    <p className="text-xs text-bambu-gray mt-1">
+                      Number of retry attempts before giving up (1-10)
+                    </p>
+                  </div>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Retry delay
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min="1"
+                        max="30"
+                        value={localSettings.ftp_retry_delay ?? 2}
+                        onChange={(e) => updateSetting('ftp_retry_delay', Math.min(30, Math.max(1, parseInt(e.target.value) || 2)))}
+                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                      <span className="text-bambu-gray">seconds</span>
+                    </div>
+                    <p className="text-xs text-bambu-gray mt-1">
+                      Wait time between retries (1-30)
+                    </p>
+                  </div>
+                </div>
+              )}
+
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Connection timeout
+                </label>
+                <div className="flex items-center gap-2">
+                  <input
+                    type="number"
+                    min="10"
+                    max="120"
+                    value={localSettings.ftp_timeout ?? 30}
+                    onChange={(e) => updateSetting('ftp_timeout', Math.min(120, Math.max(10, parseInt(e.target.value) || 30)))}
+                    className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  />
+                  <span className="text-bambu-gray">seconds</span>
+                </div>
+                <p className="text-xs text-bambu-gray mt-1">
+                  Socket timeout for slow connections. Increase for A1/A1 Mini printers with weak WiFi (10-120)
+                </p>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Home Assistant Integration */}
+          <Card>
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                  <Home className="w-5 h-5 text-bambu-green" />
+                  Home Assistant
+                </h2>
+                {localSettings.ha_enabled && haTestResult && (
+                  <div className="flex items-center gap-2">
+                    <span className={`w-2.5 h-2.5 rounded-full ${haTestResult.success ? 'bg-green-400' : 'bg-red-400'}`} />
+                    <span className={`text-sm ${haTestResult.success ? 'text-green-400' : 'text-red-400'}`}>
+                      {haTestResult.success ? 'Connected' : 'Disconnected'}
+                    </span>
+                  </div>
+                )}
+              </div>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-sm text-bambu-gray">
+                Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, and input_boolean entities.
+              </p>
+
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Enable Home Assistant</p>
+                  <p className="text-xs text-bambu-gray">Control smart plugs via Home Assistant</p>
+                </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)}
+                    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>
+                </label>
+              </div>
+
+              {localSettings.ha_enabled && (
+                <>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Home Assistant URL
+                    </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>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Long-Lived Access Token
+                    </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>
+
+                  {localSettings.ha_url && localSettings.ha_token && (
+                    <div className="pt-2 border-t border-bambu-dark-tertiary">
+                      <Button
+                        variant="secondary"
+                        size="sm"
+                        disabled={haTestLoading}
+                        onClick={async () => {
+                          setHaTestLoading(true);
+                          setHaTestResult(null);
+                          try {
+                            const result = await api.testHAConnection(localSettings.ha_url!, localSettings.ha_token!);
+                            setHaTestResult(result);
+                          } catch (e) {
+                            setHaTestResult({ success: false, message: null, error: e instanceof Error ? e.message : 'Unknown error' });
+                          } finally {
+                            setHaTestLoading(false);
+                          }
+                        }}
+                      >
+                        {haTestLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wifi className="w-4 h-4" />}
+                        Test Connection
+                      </Button>
+                    </div>
+                  )}
+                </>
+              )}
+            </CardContent>
+          </Card>
+        </div>
+
+        {/* Right Column - MQTT Publishing */}
         <div className="flex-1 lg:max-w-xl space-y-4">
         <div className="flex-1 lg:max-w-xl space-y-4">
           <Card>
           <Card>
             <CardHeader>
             <CardHeader>
@@ -1471,106 +1550,38 @@ export function SettingsPage() {
             </CardContent>
             </CardContent>
           </Card>
           </Card>
         </div>
         </div>
+      </div>
+      )}
 
 
-        {/* Right Column - FTP Retry */}
-        <div className="flex-1 lg:max-w-xl space-y-4">
-          <Card>
-            <CardHeader>
-              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
-                <RefreshCw className="w-5 h-5 text-blue-400" />
-                FTP Retry
-              </h2>
-            </CardHeader>
-            <CardContent className="space-y-4">
-              <p className="text-sm text-bambu-gray">
-                Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.
-              </p>
-
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-white">Enable retry</p>
-                  <p className="text-sm text-bambu-gray">
-                    Automatically retry failed FTP operations
-                  </p>
-                </div>
-                <label className="relative inline-flex items-center cursor-pointer">
-                  <input
-                    type="checkbox"
-                    checked={localSettings.ftp_retry_enabled ?? true}
-                    onChange={(e) => updateSetting('ftp_retry_enabled', e.target.checked)}
-                    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>
-                </label>
-              </div>
-
-              {localSettings.ftp_retry_enabled && (
-                <div className="space-y-4 pt-2 border-t border-bambu-dark-tertiary">
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">
-                      Retry attempts
-                    </label>
-                    <div className="flex items-center gap-2">
-                      <input
-                        type="number"
-                        min="1"
-                        max="10"
-                        value={localSettings.ftp_retry_count ?? 3}
-                        onChange={(e) => updateSetting('ftp_retry_count', Math.min(10, Math.max(1, parseInt(e.target.value) || 3)))}
-                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                      />
-                      <span className="text-bambu-gray">times</span>
-                    </div>
-                    <p className="text-xs text-bambu-gray mt-1">
-                      Number of retry attempts before giving up (1-10)
-                    </p>
-                  </div>
-
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">
-                      Retry delay
-                    </label>
-                    <div className="flex items-center gap-2">
-                      <input
-                        type="number"
-                        min="1"
-                        max="30"
-                        value={localSettings.ftp_retry_delay ?? 2}
-                        onChange={(e) => updateSetting('ftp_retry_delay', Math.min(30, Math.max(1, parseInt(e.target.value) || 2)))}
-                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                      />
-                      <span className="text-bambu-gray">seconds</span>
-                    </div>
-                    <p className="text-xs text-bambu-gray mt-1">
-                      Wait time between retries (1-30)
-                    </p>
-                  </div>
-                </div>
+      {/* Home Assistant Test Connection Modal */}
+      {haTestResult && (
+        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+          <div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-md w-full mx-4">
+            <div className="flex items-center gap-3 mb-4">
+              {haTestResult.success ? (
+                <CheckCircle className="w-8 h-8 text-green-400" />
+              ) : (
+                <XCircle className="w-8 h-8 text-red-400" />
               )}
               )}
-
-              <div className="pt-2 border-t border-bambu-dark-tertiary">
-                <label className="block text-sm text-bambu-gray mb-1">
-                  Connection timeout
-                </label>
-                <div className="flex items-center gap-2">
-                  <input
-                    type="number"
-                    min="10"
-                    max="120"
-                    value={localSettings.ftp_timeout ?? 30}
-                    onChange={(e) => updateSetting('ftp_timeout', Math.min(120, Math.max(10, parseInt(e.target.value) || 30)))}
-                    className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  />
-                  <span className="text-bambu-gray">seconds</span>
-                </div>
-                <p className="text-xs text-bambu-gray mt-1">
-                  Socket timeout for slow connections. Increase for A1/A1 Mini printers with weak WiFi (10-120)
-                </p>
-              </div>
-            </CardContent>
-          </Card>
+              <h3 className="text-lg font-medium text-white">
+                {haTestResult.success ? 'Connection Successful' : 'Connection Failed'}
+              </h3>
+            </div>
+            <p className="text-bambu-gray mb-6">
+              {haTestResult.success
+                ? haTestResult.message || 'Successfully connected to Home Assistant.'
+                : haTestResult.error || 'Failed to connect to Home Assistant.'}
+            </p>
+            <div className="flex justify-end">
+              <Button
+                variant="primary"
+                onClick={() => setHaTestResult(null)}
+              >
+                OK
+              </Button>
+            </div>
+          </div>
         </div>
         </div>
-      </div>
       )}
       )}
 
 
       {/* Smart Plugs Tab */}
       {/* Smart Plugs Tab */}
@@ -1583,7 +1594,7 @@ export function SettingsPage() {
                 Smart Plugs
                 Smart Plugs
               </h2>
               </h2>
               <p className="text-sm text-bambu-gray mt-1">
               <p className="text-sm text-bambu-gray mt-1">
-                Connect Tasmota-based smart plugs to automate power control and track energy usage for your printers.
+                Connect smart plugs (Tasmota or Home Assistant) to automate power control and track energy usage for your printers.
               </p>
               </p>
             </div>
             </div>
             <div className="flex items-center gap-2 pt-1 shrink-0">
             <div className="flex items-center gap-2 pt-1 shrink-0">
@@ -2288,9 +2299,145 @@ export function SettingsPage() {
       )}
       )}
 
 
       {/* Filament Tab */}
       {/* Filament Tab */}
-      {activeTab === 'filament' && (
-        <div className="max-w-2xl">
-          <SpoolmanSettings />
+      {activeTab === 'filament' && localSettings && (
+        <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
+          {/* Left Column - AMS Display Thresholds */}
+          <div className="flex-1 lg:max-w-xl">
+            <Card>
+              <CardHeader>
+                <h2 className="text-lg font-semibold text-white">AMS Display Thresholds</h2>
+              </CardHeader>
+              <CardContent className="space-y-4">
+                <p className="text-sm text-bambu-gray">
+                  Configure color thresholds for AMS humidity and temperature indicators.
+                </p>
+
+                {/* Humidity Thresholds */}
+                <div className="space-y-3">
+                  <div className="flex items-center gap-2 text-white">
+                    <Droplets className="w-4 h-4 text-blue-400" />
+                    <span className="font-medium">Humidity</span>
+                  </div>
+                  <div className="grid grid-cols-2 gap-3">
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">
+                        Good (green) ≤
+                      </label>
+                      <div className="flex items-center gap-2">
+                        <input
+                          type="number"
+                          min="0"
+                          max="100"
+                          value={localSettings.ams_humidity_good ?? 40}
+                          onChange={(e) => updateSetting('ams_humidity_good', parseInt(e.target.value) || 40)}
+                          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"
+                        />
+                        <span className="text-bambu-gray">%</span>
+                      </div>
+                    </div>
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">
+                        Fair (orange) ≤
+                      </label>
+                      <div className="flex items-center gap-2">
+                        <input
+                          type="number"
+                          min="0"
+                          max="100"
+                          value={localSettings.ams_humidity_fair ?? 60}
+                          onChange={(e) => updateSetting('ams_humidity_fair', parseInt(e.target.value) || 60)}
+                          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"
+                        />
+                        <span className="text-bambu-gray">%</span>
+                      </div>
+                    </div>
+                  </div>
+                  <p className="text-xs text-bambu-gray">
+                    Above fair threshold shows as red (bad)
+                  </p>
+                </div>
+
+                {/* Temperature Thresholds */}
+                <div className="space-y-3 pt-2 border-t border-bambu-dark-tertiary">
+                  <div className="flex items-center gap-2 text-white">
+                    <Thermometer className="w-4 h-4 text-orange-400" />
+                    <span className="font-medium">Temperature</span>
+                  </div>
+                  <div className="grid grid-cols-2 gap-3">
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">
+                        Good (blue) ≤
+                      </label>
+                      <div className="flex items-center gap-2">
+                        <input
+                          type="number"
+                          step="0.5"
+                          min="0"
+                          max="60"
+                          value={localSettings.ams_temp_good ?? 28}
+                          onChange={(e) => updateSetting('ams_temp_good', parseFloat(e.target.value) || 28)}
+                          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"
+                        />
+                        <span className="text-bambu-gray">°C</span>
+                      </div>
+                    </div>
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">
+                        Fair (orange) ≤
+                      </label>
+                      <div className="flex items-center gap-2">
+                        <input
+                          type="number"
+                          step="0.5"
+                          min="0"
+                          max="60"
+                          value={localSettings.ams_temp_fair ?? 35}
+                          onChange={(e) => updateSetting('ams_temp_fair', parseFloat(e.target.value) || 35)}
+                          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"
+                        />
+                        <span className="text-bambu-gray">°C</span>
+                      </div>
+                    </div>
+                  </div>
+                  <p className="text-xs text-bambu-gray">
+                    Above fair threshold shows as red (hot)
+                  </p>
+                </div>
+
+                {/* History Retention */}
+                <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
+                  <div className="flex items-center gap-2 text-white">
+                    <Database className="w-4 h-4 text-purple-400" />
+                    <span className="font-medium">History Retention</span>
+                  </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Keep sensor history for
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min="1"
+                        max="365"
+                        value={localSettings.ams_history_retention_days ?? 30}
+                        onChange={(e) => updateSetting('ams_history_retention_days', parseInt(e.target.value) || 30)}
+                        className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                      <span className="text-bambu-gray">days</span>
+                    </div>
+                  </div>
+                  <p className="text-xs text-bambu-gray">
+                    Older humidity and temperature data will be automatically deleted
+                  </p>
+                </div>
+              </CardContent>
+            </Card>
+          </div>
+
+          {/* Right Column - Spoolman Integration */}
+          <div className="flex-1 lg:max-w-xl">
+            <SpoolmanSettings />
+          </div>
         </div>
         </div>
       )}
       )}
 
 

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CDWq7hKx.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BlbHG-iv.js"></script>
+    <script type="module" crossorigin src="/assets/index-CDWq7hKx.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Dzh7xD3q.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Dzh7xD3q.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff