Browse Source

Add script automation and visibility options (#176)

Enhance Home Assistant script support with automation triggers and
printer card visibility control.

- Script automation: Run scripts automatically when main plug turns on/off
- Show/hide scripts on printer cards (configurable per script)
- Scripts appear in dedicated row on printer cards with quick-run buttons
- Toast notification when triggering scripts from settings/sidebar

Closes #176
maziggy 3 months ago
parent
commit
4c2cef64b3

+ 7 - 0
CHANGELOG.md

@@ -5,6 +5,13 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6-final] - Not released
 
 ### New Features
+- **Home Assistant Script Support** - Control multiple devices together using HA scripts (Issue #176):
+  - Add HA script entities (e.g., `script.turn_on_printer_setup`) as smart plugs
+  - Trigger scripts to control printer + enclosure fan + other devices simultaneously
+  - Scripts show "Run" button instead of On/Off (scripts execute once when triggered)
+  - Script automation: Run scripts automatically when main printer plug turns on/off
+  - Show/hide scripts on printer cards (configurable per script)
+  - Scripts appear in dedicated row on printer cards with quick-run buttons
 - **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
   - Prevents Bambuddy from checking Bambu Lab servers for firmware updates
   - Useful for users who prefer to manage firmware manually or have network restrictions

+ 3 - 0
backend/app/api/routes/settings.py

@@ -376,6 +376,7 @@ async def export_backup(
                     "schedule_on_time": plug.schedule_on_time,
                     "schedule_off_time": plug.schedule_off_time,
                     "show_in_switchbar": plug.show_in_switchbar,
+                    "show_on_printer_card": plug.show_on_printer_card,
                 }
             )
         backup["included"].append("smart_plugs")
@@ -1295,6 +1296,7 @@ async def import_backup(
                     existing.schedule_on_time = plug_data.get("schedule_on_time")
                     existing.schedule_off_time = plug_data.get("schedule_off_time")
                     existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
+                    existing.show_on_printer_card = plug_data.get("show_on_printer_card", True)
                     restored["smart_plugs"] += 1
                 else:
                     skipped["smart_plugs"] += 1
@@ -1324,6 +1326,7 @@ async def import_backup(
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
                     show_in_switchbar=plug_data.get("show_in_switchbar", False),
+                    show_on_printer_card=plug_data.get("show_on_printer_card", True),
                 )
                 db.add(plug)
                 restored["smart_plugs"] += 1

+ 115 - 14
backend/app/api/routes/smart_plugs.py

@@ -56,9 +56,21 @@ async def create_smart_plug(
             raise HTTPException(400, "Printer not found")
 
         # Check if printer already has a plug assigned
-        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
-        if result.scalar_one_or_none():
-            raise HTTPException(400, "This printer already has a smart plug assigned")
+        # Scripts can coexist with other plugs (they're for multi-device control, not power on/off)
+        is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.")
+        if not is_script:
+            # For non-script plugs, check there's no other non-script plug assigned
+            result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
+            existing = result.scalar_one_or_none()
+            if existing:
+                # Allow if existing plug is a script
+                existing_is_script = (
+                    existing.plug_type == "homeassistant"
+                    and existing.ha_entity_id
+                    and existing.ha_entity_id.startswith("script.")
+                )
+                if not existing_is_script:
+                    raise HTTPException(400, "This printer already has a smart plug assigned")
 
     plug = SmartPlug(**data.model_dump())
     db.add(plug)
@@ -74,12 +86,48 @@ async def create_smart_plug(
 
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
-    """Get the smart plug assigned to a printer."""
+    """Get the main smart plug assigned to a printer.
+
+    When multiple plugs are assigned (e.g., a regular plug + script),
+    returns the main (non-script) plug for power control.
+    """
     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-    plug = result.scalar_one_or_none()
-    if not plug:
+    plugs = result.scalars().all()
+
+    if not plugs:
         return None
-    return plug
+
+    # If multiple plugs, prefer the non-script one (main power plug)
+    for plug in plugs:
+        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+        if not is_script:
+            return plug
+
+    # All are scripts, return the first one
+    return plugs[0]
+
+
+@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
+async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Get all HA script plugs assigned to a printer.
+
+    Returns only script entities (script.*) for the printer that have
+    show_on_printer_card enabled.
+    Used to display "Run Script" buttons alongside the main power plug.
+    """
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+    plugs = result.scalars().all()
+
+    # Filter to only scripts with show_on_printer_card enabled
+    scripts = [
+        plug
+        for plug in plugs
+        if plug.plug_type == "homeassistant"
+        and plug.ha_entity_id
+        and plug.ha_entity_id.startswith("script.")
+        and plug.show_on_printer_card
+    ]
+    return scripts
 
 
 # Tasmota Discovery Endpoints
@@ -287,14 +335,29 @@ async def update_smart_plug(
             raise HTTPException(400, "Printer not found")
 
         # Check if that printer already has a different plug assigned
-        result = await db.execute(
-            select(SmartPlug).where(
-                SmartPlug.printer_id == new_printer_id,
-                SmartPlug.id != plug_id,
+        # Scripts can coexist with other plugs
+        # Determine if the plug being updated is/will be a script
+        new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id)
+        new_plug_type = update_data.get("plug_type", plug.plug_type)
+        is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.")
+
+        if not is_script:
+            result = await db.execute(
+                select(SmartPlug).where(
+                    SmartPlug.printer_id == new_printer_id,
+                    SmartPlug.id != plug_id,
+                )
             )
-        )
-        if result.scalar_one_or_none():
-            raise HTTPException(400, "This printer already has a smart plug assigned")
+            existing = result.scalar_one_or_none()
+            if existing:
+                # Allow if existing plug is a script
+                existing_is_script = (
+                    existing.plug_type == "homeassistant"
+                    and existing.ha_entity_id
+                    and existing.ha_entity_id.startswith("script.")
+                )
+                if not existing_is_script:
+                    raise HTTPException(400, "This printer already has a smart plug assigned")
 
     for field, value in update_data.items():
         setattr(plug, field, value)
@@ -376,6 +439,13 @@ async def control_smart_plug(
     plug.last_checked = datetime.utcnow()
     await db.commit()
 
+    # Trigger associated scripts if this is a main (non-script) plug
+    is_main_plug = not (
+        plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+    )
+    if is_main_plug and plug.printer_id and expected_state:
+        await trigger_associated_scripts(plug.printer_id, expected_state, db)
+
     # MQTT relay - publish smart plug state change
     if expected_state:
         try:
@@ -401,6 +471,37 @@ async def control_smart_plug(
     return {"success": True, "action": control.action}
 
 
+async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession):
+    """Trigger scripts linked to a printer based on main plug state change.
+
+    When the main plug turns ON, triggers scripts with auto_on=True.
+    When the main plug turns OFF, triggers scripts with auto_off=True.
+    """
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+    plugs = result.scalars().all()
+
+    # Find scripts that should be triggered
+    for plug in plugs:
+        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+        if not is_script:
+            continue
+
+        should_trigger = False
+        if plug_state == "ON" and plug.auto_on:
+            should_trigger = True
+            logger.info(f"Auto-triggering script '{plug.name}' on printer power-on")
+        elif plug_state == "OFF" and plug.auto_off:
+            should_trigger = True
+            logger.info(f"Auto-triggering script '{plug.name}' on printer power-off")
+
+        if should_trigger:
+            try:
+                service = await _get_service_for_plug(plug, db)
+                await service.turn_on(plug)  # Scripts are triggered by calling turn_on
+            except Exception as e:
+                logger.error(f"Failed to trigger script '{plug.name}': {e}")
+
+
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     """Get current plug status from device including energy data."""

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

@@ -760,6 +760,79 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Remove UNIQUE constraint from smart_plugs.printer_id
+    # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control)
+    # SQLite requires table recreation to drop constraints
+    try:
+        # Check if we need to migrate (if UNIQUE constraint exists)
+        result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
+        row = result.fetchone()
+        if row and "printer_id INTEGER UNIQUE" in (row[0] or ""):
+            # Create new table without UNIQUE constraint on printer_id
+            await conn.execute(
+                text("""
+                CREATE TABLE smart_plugs_temp (
+                    id INTEGER PRIMARY KEY,
+                    name VARCHAR(100) NOT NULL,
+                    ip_address VARCHAR(45),
+                    plug_type VARCHAR(20) DEFAULT 'tasmota',
+                    ha_entity_id VARCHAR(100),
+                    ha_power_entity VARCHAR(100),
+                    ha_energy_today_entity VARCHAR(100),
+                    ha_energy_total_entity VARCHAR(100),
+                    printer_id INTEGER 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
+                )
+            """)
+            )
+            # Copy data
+            await conn.execute(
+                text("""
+                INSERT INTO smart_plugs_temp
+                SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,
+                       ha_energy_today_entity, ha_energy_total_entity, 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,
+                       show_in_switchbar, last_state, last_checked, auto_off_executed,
+                       auto_off_pending, auto_off_pending_since, created_at, updated_at
+                FROM smart_plugs
+            """)
+            )
+            # Drop old table and rename new one
+            await conn.execute(text("DROP TABLE smart_plugs"))
+            await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs"))
+    except Exception:
+        pass
+
+    # Migration: Add show_on_printer_card column to smart_plugs
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 4 - 5
backend/app/models/smart_plug.py

@@ -24,10 +24,8 @@ class SmartPlug(Base):
     ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_today
     ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_total
 
-    # Link to printer (1:1)
-    printer_id: Mapped[int | None] = mapped_column(
-        ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True
-    )
+    # Link to printer (scripts can coexist with regular plugs for multi-device control)
+    printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
 
     # Automation settings
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
@@ -54,8 +52,9 @@ class SmartPlug(Base):
     schedule_on_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # "HH:MM" format
     schedule_off_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # "HH:MM" format
 
-    # Switchbar visibility
+    # Visibility options
     show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False)
+    show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True)  # For scripts: show on printer card
 
     # Status tracking
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"

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

@@ -14,7 +14,7 @@ class SmartPlugBase(BaseModel):
     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_]+$")
+    ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean|script)\.[a-z0-9_]+$")
     # Home Assistant energy sensor entities (optional, for separate energy sensors)
     ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
@@ -35,8 +35,9 @@ class SmartPlugBase(BaseModel):
     schedule_enabled: bool = False
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
-    # Switchbar visibility
+    # Visibility options
     show_in_switchbar: bool = False
+    show_on_printer_card: bool = True  # For scripts: show on printer card
 
     @model_validator(mode="after")
     def validate_plug_type_fields(self) -> "SmartPlugBase":
@@ -77,8 +78,9 @@ class SmartPlugUpdate(BaseModel):
     schedule_enabled: bool | None = None
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
-    # Switchbar visibility
+    # Visibility options
     show_in_switchbar: bool | None = None
+    show_on_printer_card: bool | None = None
 
 
 class SmartPlugResponse(SmartPlugBase):
@@ -147,7 +149,7 @@ class HAEntity(BaseModel):
     entity_id: str
     friendly_name: str
     state: str | None = None
-    domain: str  # "switch", "light", "input_boolean"
+    domain: str  # "switch", "light", "input_boolean", "script"
 
 
 class HASensorEntity(BaseModel):

+ 1 - 1
backend/app/services/homeassistant.py

@@ -228,7 +228,7 @@ class HomeAssistantService:
             - domain: str
         """
         # Default domains for smart plug control
-        default_domains = {"switch", "light", "input_boolean"}
+        default_domains = {"switch", "light", "input_boolean", "script"}
 
         try:
             async with httpx.AsyncClient(timeout=self.timeout) as client:

+ 5 - 0
backend/app/services/smart_plug_manager.py

@@ -195,6 +195,11 @@ class SmartPlugManager:
             logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
             return
 
+        # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
+        if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
+            logger.debug(f"Smart plug '{plug.name}' is a HA script entity, skipping auto-off")
+            return
+
         # Only auto-off on successful completion, not on failures
         # This allows the user to investigate errors before power-off
         if status != "completed":

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

@@ -573,3 +573,200 @@ class TestSmartPlugsAPI:
 
         assert response.status_code == 400
         assert "not configured" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_homeassistant_script_plug(self, async_client: AsyncClient):
+        """Verify Home Assistant script entity can be created as a plug.
+
+        Scripts allow users to trigger HA automations that control multiple devices
+        (e.g., turn on printer + fan together). Scripts can only be triggered (turn_on),
+        not turned off.
+        """
+        data = {
+            "name": "Turn On Printer Setup",
+            "plug_type": "homeassistant",
+            "ha_entity_id": "script.turn_on_printer_and_fan",
+            "enabled": True,
+            "auto_on": True,
+            "auto_off": False,  # Scripts don't support auto_off
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "Turn On Printer Setup"
+        assert result["plug_type"] == "homeassistant"
+        assert result["ha_entity_id"] == "script.turn_on_printer_and_fan"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_control_homeassistant_script(
+        self, async_client: AsyncClient, smart_plug_factory, mock_homeassistant_service, db_session
+    ):
+        """Verify HA script entity can be triggered via control endpoint."""
+        plug = await smart_plug_factory(plug_type="homeassistant", ha_entity_id="script.turn_on_printer")
+
+        # Scripts use "on" action to trigger
+        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_invalid_ha_entity_domain(self, async_client: AsyncClient):
+        """Verify invalid HA entity domains are rejected."""
+        data = {
+            "name": "Invalid Entity",
+            "plug_type": "homeassistant",
+            "ha_entity_id": "sensor.some_sensor",  # sensor domain not allowed
+            "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_script_can_coexist_with_regular_plug(
+        self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session
+    ):
+        """Verify HA scripts can be assigned to printers that already have a regular plug.
+
+        Scripts are for multi-device control (e.g., turn on printer + fan together),
+        so they should coexist with the main power plug.
+        """
+        # Create a printer
+        printer = await printer_factory(name="Test Printer")
+
+        # Create a regular Tasmota plug assigned to the printer
+        main_plug = await smart_plug_factory(
+            name="Main Power Plug",
+            plug_type="tasmota",
+            ip_address="192.168.1.100",
+            printer_id=printer.id,
+        )
+        assert main_plug.printer_id == printer.id
+
+        # Now try to create a script also assigned to the same printer
+        script_data = {
+            "name": "Turn On Everything",
+            "plug_type": "homeassistant",
+            "ha_entity_id": "script.turn_on_printer_setup",
+            "printer_id": printer.id,
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=script_data)
+
+        # Should succeed - scripts can coexist with regular plugs
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+        assert result["ha_entity_id"] == "script.turn_on_printer_setup"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_regular_plug_blocked_when_another_exists(
+        self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session
+    ):
+        """Verify regular plugs cannot be assigned if printer already has one."""
+        # Create a printer
+        printer = await printer_factory(name="Test Printer")
+
+        # Create a regular plug assigned to the printer
+        await smart_plug_factory(
+            name="Main Power Plug",
+            plug_type="tasmota",
+            ip_address="192.168.1.100",
+            printer_id=printer.id,
+        )
+
+        # Try to create another regular plug for the same printer
+        another_plug = {
+            "name": "Second Plug",
+            "plug_type": "homeassistant",
+            "ha_entity_id": "switch.another_plug",
+            "printer_id": printer.id,
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=another_plug)
+
+        # Should fail - only one regular plug per printer
+        assert response.status_code == 400
+        assert "already has a smart plug" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_scripts_by_printer_filters_by_show_on_printer_card(
+        self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session
+    ):
+        """Verify scripts endpoint only returns scripts with show_on_printer_card=True."""
+        printer = await printer_factory(name="Test Printer")
+
+        # Create a script with show_on_printer_card=True (default)
+        visible_script = await smart_plug_factory(
+            name="Visible Script",
+            plug_type="homeassistant",
+            ha_entity_id="script.visible_script",
+            printer_id=printer.id,
+            show_on_printer_card=True,
+        )
+
+        # Create a script with show_on_printer_card=False
+        await smart_plug_factory(
+            name="Hidden Script",
+            plug_type="homeassistant",
+            ha_entity_id="script.hidden_script",
+            printer_id=printer.id,
+            show_on_printer_card=False,
+        )
+
+        response = await async_client.get(f"/api/v1/smart-plugs/by-printer/{printer.id}/scripts")
+
+        assert response.status_code == 200
+        scripts = response.json()
+        # Should only return the visible script
+        assert len(scripts) == 1
+        assert scripts[0]["id"] == visible_script.id
+        assert scripts[0]["name"] == "Visible Script"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_script_auto_on_auto_off_fields(
+        self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session
+    ):
+        """Verify scripts can have auto_on and auto_off set for automation triggers."""
+        printer = await printer_factory(name="Test Printer")
+
+        # Create a script with custom auto_on/auto_off settings
+        script_data = {
+            "name": "Fan Control Script",
+            "plug_type": "homeassistant",
+            "ha_entity_id": "script.fan_control",
+            "printer_id": printer.id,
+            "auto_on": True,
+            "auto_off": False,
+            "show_on_printer_card": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=script_data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["auto_on"] is True
+        assert result["auto_off"] is False
+        assert result["show_on_printer_card"] is True
+
+        # Update the script's auto_off setting
+        update_response = await async_client.patch(f"/api/v1/smart-plugs/{result['id']}", json={"auto_off": True})
+
+        assert update_response.status_code == 200
+        updated = update_response.json()
+        assert updated["auto_off"] is True

+ 9 - 5
frontend/src/api/client.ts

@@ -842,7 +842,7 @@ export interface SmartPlug {
   name: 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")
+  ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer")
   // Home Assistant energy sensor entities (optional)
   ha_power_entity: string | null;
   ha_energy_today_entity: string | null;
@@ -865,8 +865,9 @@ export interface SmartPlug {
   schedule_enabled: boolean;
   schedule_on_time: string | null;
   schedule_off_time: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar: boolean;
+  show_on_printer_card: boolean;  // For scripts: show on printer card
   // Status
   last_state: string | null;
   last_checked: string | null;
@@ -901,8 +902,9 @@ export interface SmartPlugCreate {
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar?: boolean;
+  show_on_printer_card?: boolean;
 }
 
 export interface SmartPlugUpdate {
@@ -931,8 +933,9 @@ export interface SmartPlugUpdate {
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar?: boolean;
+  show_on_printer_card?: boolean;
 }
 
 // Home Assistant entity for smart plug selection
@@ -940,7 +943,7 @@ export interface HAEntity {
   entity_id: string;
   friendly_name: string;
   state: string | null;
-  domain: string;  // "switch", "light", "input_boolean"
+  domain: string;  // "switch", "light", "input_boolean", "script"
 }
 
 // Home Assistant sensor entity for energy monitoring
@@ -2394,6 +2397,7 @@ export const api = {
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
   getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
+  getScriptPlugsByPrinter: (printerId: number) => request<SmartPlug[]>(`/smart-plugs/by-printer/${printerId}/scripts`),
   createSmartPlug: (data: SmartPlugCreate) =>
     request<SmartPlug>('/smart-plugs/', {
       method: 'POST',

+ 194 - 98
frontend/src/components/AddSmartPlugModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home } from 'lucide-react';
+import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Play, Eye } from 'lucide-react';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import { Button } from './Button';
@@ -51,6 +51,10 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
   const [error, setError] = useState<string | null>(null);
 
+  // Automation settings
+  const [autoOn, setAutoOn] = useState(plug?.auto_on ?? true);
+  const [autoOff, setAutoOff] = useState(plug?.auto_off ?? true);
+
   // Power alert settings
   const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
   const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');
@@ -61,8 +65,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
   const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
 
-  // Switchbar visibility
+  // Visibility options
   const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
+  const [showOnPrinterCard, setShowOnPrinterCard] = useState(plug?.show_on_printer_card ?? true);
 
   // Discovery state
   const [isScanning, setIsScanning] = useState(false);
@@ -235,6 +240,10 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      // Also invalidate script plugs queries for printer cards
+      queryClient.invalidateQueries({ predicate: (query) =>
+        Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter'
+      });
       onClose();
     },
     onError: (err: Error) => {
@@ -247,6 +256,10 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      // Also invalidate script plugs queries for printer cards
+      queryClient.invalidateQueries({ predicate: (query) =>
+        Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter'
+      });
       onClose();
     },
     onError: (err: Error) => {
@@ -254,11 +267,17 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     },
   });
 
+  // Check if selected entity is a script (scripts allow multiple plugs per printer)
+  const isScript = plugType === 'homeassistant' && haEntityId?.startsWith('script.');
+
   // Filter out printers that already have a plug assigned (except current plug's printer)
-  const availablePrinters = printers?.filter(p => {
-    const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id);
-    return !hasPlug;
-  });
+  // Scripts can link to any printer (they're for multi-device control)
+  const availablePrinters = isScript
+    ? printers
+    : printers?.filter(p => {
+        const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id);
+        return !hasPlug;
+      });
 
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
@@ -291,6 +310,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
+      // Automation
+      auto_on: autoOn,
+      auto_off: autoOff,
       // Power alerts
       power_alert_enabled: powerAlertEnabled,
       power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
@@ -299,8 +321,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       schedule_enabled: scheduleEnabled,
       schedule_on_time: scheduleOnTime || null,
       schedule_off_time: scheduleOffTime || null,
-      // Switchbar
+      // Visibility options
       show_in_switchbar: showInSwitchbar,
+      show_on_printer_card: showOnPrinterCard,
     };
 
     if (isEditing) {
@@ -582,15 +605,15 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                         <p className="text-xs text-bambu-gray mt-1">
                           {debouncedSearch
                             ? `Searching all entities (${availableEntities.length} found)`
-                            : `Showing switch, light, input_boolean (${availableEntities.length} available)`}
+                            : `Showing switch, light, input_boolean, script (${availableEntities.length} available)`}
                         </p>
                       </div>
                     );
                   })()}
 
 
-                  {/* Energy Monitoring Section (Optional) */}
-                  {haEntityId && haSensorEntities && haSensorEntities.length > 0 && (
+                  {/* Energy Monitoring Section (Optional) - hidden for scripts */}
+                  {haEntityId && !isScript && haSensorEntities && haSensorEntities.length > 0 && (
                     <div className="border-t border-bambu-dark-tertiary pt-4 mt-4 space-y-3">
                       <div>
                         <p className="text-white font-medium mb-1">Energy Monitoring (Optional)</p>
@@ -975,110 +998,183 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
               ))}
             </select>
             <p className="text-xs text-bambu-gray mt-1">
-              Linking enables automatic on/off when prints start/complete
+              {isScript
+                ? 'Link to a printer to enable auto-run triggers'
+                : 'Linking enables automatic on/off when prints start/complete'}
             </p>
           </div>
 
-          {/* Power Alerts */}
-          <div className="border-t border-bambu-dark-tertiary pt-4">
-            <div className="flex items-center justify-between mb-3">
-              <div className="flex items-center gap-2">
-                <Bell className="w-4 h-4 text-bambu-green" />
-                <span className="text-white font-medium">Power Alerts</span>
+          {/* Script Options - only show for HA scripts */}
+          {isScript && (
+            <div className="border-t border-bambu-dark-tertiary pt-4 space-y-4">
+              <div className="flex items-center gap-2 mb-2">
+                <Play className="w-4 h-4 text-bambu-green" />
+                <span className="text-white font-medium">Script Automation</span>
               </div>
-              <label className="relative inline-flex items-center cursor-pointer">
-                <input
-                  type="checkbox"
-                  checked={powerAlertEnabled}
-                  onChange={(e) => setPowerAlertEnabled(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>
-            {powerAlertEnabled && (
-              <div className="space-y-3">
-                <div className="grid grid-cols-2 gap-3">
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">Alert if above (W)</label>
-                    <input
-                      type="number"
-                      value={powerAlertHigh}
-                      onChange={(e) => setPowerAlertHigh(e.target.value)}
-                      placeholder="e.g. 200"
-                      min="0"
-                      max="5000"
-                      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">Alert if below (W)</label>
-                    <input
-                      type="number"
-                      value={powerAlertLow}
-                      onChange={(e) => setPowerAlertLow(e.target.value)}
-                      placeholder="e.g. 10"
-                      min="0"
-                      max="5000"
-                      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>
+
+              {/* Auto-run when printer turns on */}
+              <div className="flex items-center justify-between">
+                <div>
+                  <span className="text-white">Run when printer turns on</span>
+                  <p className="text-xs text-bambu-gray">Execute script when main plug is switched on</p>
                 </div>
-                <p className="text-xs text-bambu-gray">
-                  Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.
-                </p>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={autoOn}
+                    onChange={(e) => setAutoOn(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>
-            )}
-          </div>
 
-          {/* Schedule */}
-          <div className="border-t border-bambu-dark-tertiary pt-4">
-            <div className="flex items-center justify-between mb-3">
-              <div className="flex items-center gap-2">
-                <Clock className="w-4 h-4 text-bambu-green" />
-                <span className="text-white font-medium">Daily Schedule</span>
+              {/* Auto-run when printer turns off */}
+              <div className="flex items-center justify-between">
+                <div>
+                  <span className="text-white">Run when printer turns off</span>
+                  <p className="text-xs text-bambu-gray">Execute script when main plug is switched off</p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={autoOff}
+                    onChange={(e) => setAutoOff(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>
-              <label className="relative inline-flex items-center cursor-pointer">
-                <input
-                  type="checkbox"
-                  checked={scheduleEnabled}
-                  onChange={(e) => setScheduleEnabled(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>
-            {scheduleEnabled && (
-              <div className="space-y-3">
-                <div className="grid grid-cols-2 gap-3">
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
-                    <input
-                      type="time"
-                      value={scheduleOnTime}
-                      onChange={(e) => setScheduleOnTime(e.target.value)}
-                      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"
-                    />
+          )}
+
+          {/* Power Alerts - hidden for scripts (no power monitoring) */}
+          {!isScript && (
+            <div className="border-t border-bambu-dark-tertiary pt-4">
+              <div className="flex items-center justify-between mb-3">
+                <div className="flex items-center gap-2">
+                  <Bell className="w-4 h-4 text-bambu-green" />
+                  <span className="text-white font-medium">Power Alerts</span>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={powerAlertEnabled}
+                    onChange={(e) => setPowerAlertEnabled(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>
+              {powerAlertEnabled && (
+                <div className="space-y-3">
+                  <div className="grid grid-cols-2 gap-3">
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Alert if above (W)</label>
+                      <input
+                        type="number"
+                        value={powerAlertHigh}
+                        onChange={(e) => setPowerAlertHigh(e.target.value)}
+                        placeholder="e.g. 200"
+                        min="0"
+                        max="5000"
+                        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">Alert if below (W)</label>
+                      <input
+                        type="number"
+                        value={powerAlertLow}
+                        onChange={(e) => setPowerAlertLow(e.target.value)}
+                        placeholder="e.g. 10"
+                        min="0"
+                        max="5000"
+                        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">
+                    Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.
+                  </p>
+                </div>
+              )}
+            </div>
+          )}
+
+          {/* Schedule - hidden for scripts */}
+          {!isScript && (
+            <div className="border-t border-bambu-dark-tertiary pt-4">
+              <div className="flex items-center justify-between mb-3">
+                <div className="flex items-center gap-2">
+                  <Clock className="w-4 h-4 text-bambu-green" />
+                  <span className="text-white font-medium">Daily Schedule</span>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={scheduleEnabled}
+                    onChange={(e) => setScheduleEnabled(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>
+              {scheduleEnabled && (
+                <div className="space-y-3">
+                  <div className="grid grid-cols-2 gap-3">
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
+                      <input
+                        type="time"
+                        value={scheduleOnTime}
+                        onChange={(e) => setScheduleOnTime(e.target.value)}
+                        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">Turn Off at</label>
+                      <input
+                        type="time"
+                        value={scheduleOffTime}
+                        onChange={(e) => setScheduleOffTime(e.target.value)}
+                        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">
+                    Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
+                  </p>
+                </div>
+              )}
+            </div>
+          )}
+
+          {/* Visibility Options */}
+          <div className="border-t border-bambu-dark-tertiary pt-4 space-y-4">
+            {/* Show on printer card - only for scripts */}
+            {isScript && (
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Eye className="w-4 h-4 text-bambu-green" />
                   <div>
-                    <label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
-                    <input
-                      type="time"
-                      value={scheduleOffTime}
-                      onChange={(e) => setScheduleOffTime(e.target.value)}
-                      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-white font-medium">Show on Printer Card</span>
+                    <p className="text-xs text-bambu-gray">Display script button on printer card</p>
                   </div>
                 </div>
-                <p className="text-xs text-bambu-gray">
-                  Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
-                </p>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={showOnPrinterCard}
+                    onChange={(e) => setShowOnPrinterCard(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>
             )}
-          </div>
 
-          {/* Switchbar Visibility */}
-          <div className="border-t border-bambu-dark-tertiary pt-4">
+            {/* Show in Switchbar */}
             <div className="flex items-center justify-between">
               <div className="flex items-center gap-2">
                 <LayoutGrid className="w-4 h-4 text-bambu-green" />

+ 126 - 43
frontend/src/components/SmartPlugCard.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home } from 'lucide-react';
+import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Play, Eye } from 'lucide-react';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
@@ -55,12 +55,24 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
 
       return { previousStatus };
     },
+    onSuccess: (_data, action) => {
+      // Show toast for script triggers
+      const isScriptPlug = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.');
+      if (isScriptPlug && action === 'on') {
+        showToast(`Script "${plug.name}" triggered`, 'success');
+      }
+    },
     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');
+      const isScriptPlug = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.');
+      if (isScriptPlug) {
+        showToast(`Failed to trigger script "${plug.name}"`, 'error');
+      } else {
+        showToast(`Failed to turn ${action} "${plug.name}"`, 'error');
+      }
     },
     onSettled: () => {
       // Refetch after a short delay to get actual state
@@ -80,6 +92,10 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       if (plug.printer_id) {
         queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });
       }
+      // Invalidate script plugs queries for printer cards
+      queryClient.invalidateQueries({ predicate: (query) =>
+        Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter'
+      });
     },
   });
 
@@ -88,6 +104,10 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
     mutationFn: () => api.deleteSmartPlug(plug.id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      // Also invalidate script plugs queries for printer cards
+      queryClient.invalidateQueries({ predicate: (query) =>
+        Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter'
+      });
     },
   });
 
@@ -95,6 +115,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
   const isReachable = status?.reachable ?? false;
   const isPending = controlMutation.isPending;
 
+  // Check if this is a HA script entity (scripts can only be triggered, not toggled)
+  const isScript = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.');
+
   // Generate admin URL with auto-login credentials (Tasmota only)
   const getAdminUrl = () => {
     if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null;
@@ -113,31 +136,46 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       <Card className="relative">
         <CardContent className="p-4">
           {/* Header Row */}
-          <div className="flex items-start justify-between mb-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'}`}>
-                {plug.plug_type === 'homeassistant' ? (
+          <div className="flex items-start justify-between gap-2 mb-3">
+            <div className="flex items-center gap-3 min-w-0 flex-1">
+              <div className={`p-2 rounded-lg flex-shrink-0 ${isReachable ? ((isOn || isScript) ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
+                {isScript ? (
+                  <Play className={`w-5 h-5 ${isReachable ? 'text-bambu-green' : '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>
-                <h3 className="font-medium text-white">{plug.name}</h3>
-                <p className="text-sm text-bambu-gray">
+              <div className="min-w-0">
+                <h3 className="font-medium text-white truncate">{plug.name}</h3>
+                <p className="text-sm text-bambu-gray truncate" title={plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.ip_address ?? undefined}>
                   {plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
                 </p>
               </div>
             </div>
 
             {/* Status indicator */}
-            <div className="flex flex-col items-end gap-1">
+            <div className="flex flex-col items-end gap-1 flex-shrink-0">
               {statusLoading ? (
                 <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
+              ) : isScript ? (
+                /* Script entities: show badge and Ready status stacked */
+                <div className="flex flex-col items-end gap-1">
+                  <span className="flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded-full">
+                    <Play className="w-3 h-3" />
+                    Script
+                  </span>
+                  <span className={`text-sm ${isReachable ? 'text-status-ok' : 'text-status-error'}`}>
+                    {isReachable ? 'Ready' : 'Offline'}
+                  </span>
+                </div>
               ) : isReachable ? (
                 <div className="flex items-center gap-1 text-sm">
                   <Wifi className="w-4 h-4 text-status-ok" />
-                  <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
+                  <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>
+                    {status?.state || 'Unknown'}
+                  </span>
                 </div>
               ) : (
                 <div className="flex items-center gap-1 text-sm text-status-error">
@@ -193,26 +231,43 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
 
           {/* Quick Controls */}
           <div className="flex gap-2 mb-3">
-            <Button
-              size="sm"
-              variant={isOn ? 'primary' : 'secondary'}
-              disabled={!isReachable || isPending}
-              onClick={() => setShowPowerOnConfirm(true)}
-              className="flex-1"
-            >
-              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
-              On
-            </Button>
-            <Button
-              size="sm"
-              variant={!isOn ? 'primary' : 'secondary'}
-              disabled={!isReachable || isPending}
-              onClick={() => setShowPowerOffConfirm(true)}
-              className="flex-1"
-            >
-              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
-              Off
-            </Button>
+            {isScript ? (
+              /* Script entities: single "Run" button */
+              <Button
+                size="sm"
+                variant="primary"
+                disabled={!isReachable || isPending}
+                onClick={() => setShowPowerOnConfirm(true)}
+                className="flex-1"
+              >
+                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
+                Run Script
+              </Button>
+            ) : (
+              /* Regular entities: On/Off buttons */
+              <>
+                <Button
+                  size="sm"
+                  variant={isOn ? 'primary' : 'secondary'}
+                  disabled={!isReachable || isPending}
+                  onClick={() => setShowPowerOnConfirm(true)}
+                  className="flex-1"
+                >
+                  {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
+                  On
+                </Button>
+                <Button
+                  size="sm"
+                  variant={!isOn ? 'primary' : 'secondary'}
+                  disabled={!isReachable || isPending}
+                  onClick={() => setShowPowerOffConfirm(true)}
+                  className="flex-1"
+                >
+                  {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
+                  Off
+                </Button>
+              </>
+            )}
           </div>
 
           {/* Toggle Settings Panel */}
@@ -230,6 +285,28 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           {/* Expanded Settings */}
           {isExpanded && (
             <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
+              {/* Show on Printer Card Toggle - only for scripts */}
+              {isScript && (
+                <div className="flex items-center justify-between">
+                  <div className="flex items-center gap-2">
+                    <Eye className="w-4 h-4 text-bambu-green" />
+                    <div>
+                      <p className="text-sm text-white">Show on Printer Card</p>
+                      <p className="text-xs text-bambu-gray">Display script button on printer card</p>
+                    </div>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={plug.show_on_printer_card}
+                      onChange={(e) => updateMutation.mutate({ show_on_printer_card: e.target.checked })}
+                      className="sr-only peer"
+                    />
+                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                  </label>
+                </div>
+              )}
+
               {/* Show in Switchbar Toggle */}
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-2">
@@ -267,11 +344,13 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
               </div>
 
-              {/* Auto On */}
+              {/* Auto On / Run when printer turns on */}
               <div className="flex items-center justify-between">
                 <div>
-                  <p className="text-sm text-white">Auto On</p>
-                  <p className="text-xs text-bambu-gray">Turn on when print starts</p>
+                  <p className="text-sm text-white">{isScript ? 'Run when printer turns on' : 'Auto On'}</p>
+                  <p className="text-xs text-bambu-gray">
+                    {isScript ? 'Execute script when main plug is switched on' : 'Turn on when print starts'}
+                  </p>
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input
@@ -284,11 +363,13 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
               </div>
 
-              {/* Auto Off */}
+              {/* Auto Off / Run when printer turns off */}
               <div className="flex items-center justify-between">
                 <div>
-                  <p className="text-sm text-white">Auto Off</p>
-                  <p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
+                  <p className="text-sm text-white">{isScript ? 'Run when printer turns off' : 'Auto Off'}</p>
+                  <p className="text-xs text-bambu-gray">
+                    {isScript ? 'Execute script when main plug is switched off' : 'Turn off when print completes (one-shot)'}
+                  </p>
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input
@@ -301,8 +382,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
               </div>
 
-              {/* Delay Mode */}
-              {plug.auto_off && (
+              {/* Delay Mode - hidden for script entities */}
+              {plug.auto_off && !isScript && (
                 <div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
                   <div>
                     <p className="text-sm text-white mb-2">Turn Off Delay Mode</p>
@@ -401,12 +482,14 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
         />
       )}
 
-      {/* Power On Confirmation */}
+      {/* Power On / Run Script Confirmation */}
       {showPowerOnConfirm && (
         <ConfirmModal
-          title="Turn On Smart Plug"
-          message={`Are you sure you want to turn on "${plug.name}"?`}
-          confirmText="Turn On"
+          title={isScript ? "Run Script" : "Turn On Smart Plug"}
+          message={isScript
+            ? `Are you sure you want to run the script "${plug.name}"?`
+            : `Are you sure you want to turn on "${plug.name}"?`}
+          confirmText={isScript ? "Run" : "Turn On"}
           variant="default"
           onConfirm={() => {
             controlMutation.mutate('on');

+ 69 - 32
frontend/src/components/SwitchbarPopover.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap } from 'lucide-react';
+import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap, Play } from 'lucide-react';
 import { api } from '../api/client';
 import type { SmartPlug } from '../api/client';
 import { ConfirmModal } from './ConfirmModal';
@@ -32,6 +32,9 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
   const isReachable = status?.reachable ?? false;
   const isPending = controlMutation.isPending;
 
+  // Check if this is a HA script entity
+  const isScript = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.');
+
   const handleConfirm = () => {
     if (confirmAction) {
       controlMutation.mutate(confirmAction);
@@ -43,18 +46,33 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
     <>
       <div className="flex items-center justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
         <div className="flex items-center gap-2">
-          <div className={`p-1.5 rounded ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
-            <Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+          <div className={`p-1.5 rounded ${isReachable ? ((isOn || isScript) ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
+            {isScript ? (
+              <Play className={`w-4 h-4 ${isReachable ? 'text-bambu-green' : 'text-red-400'}`} />
+            ) : (
+              <Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+            )}
           </div>
           <div>
-            <p className="text-sm text-white font-medium">{plug.name}</p>
+            <div className="flex items-center gap-2">
+              <p className="text-sm text-white font-medium">{plug.name}</p>
+              {isScript && (
+                <span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] rounded-full">Script</span>
+              )}
+            </div>
             <div className="flex items-center gap-1 text-xs">
               {statusLoading ? (
                 <Loader2 className="w-3 h-3 text-bambu-gray animate-spin" />
+              ) : isScript ? (
+                <span className={isReachable ? 'text-status-ok' : 'text-status-error'}>
+                  {isReachable ? 'Ready' : 'Offline'}
+                </span>
               ) : isReachable ? (
                 <>
                   <Wifi className="w-3 h-3 text-status-ok" />
-                  <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
+                  <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>
+                    {status?.state || 'Unknown'}
+                  </span>
                   {status?.energy?.power !== null && status?.energy?.power !== undefined && (
                     <>
                       <span className="text-bambu-gray mx-1">|</span>
@@ -74,38 +92,57 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
         </div>
 
         <div className="flex gap-1">
-          <button
-            onClick={() => setConfirmAction('on')}
-            disabled={!isReachable || isPending}
-            className={`p-1.5 rounded transition-colors ${
-              isOn
-                ? 'bg-bambu-green text-white'
-                : 'bg-bambu-dark text-bambu-gray hover:text-white'
-            } disabled:opacity-50 disabled:cursor-not-allowed`}
-            title="Turn On"
-          >
-            {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
-          </button>
-          <button
-            onClick={() => setConfirmAction('off')}
-            disabled={!isReachable || isPending}
-            className={`p-1.5 rounded transition-colors ${
-              !isOn && isReachable
-                ? 'bg-bambu-dark-tertiary text-white'
-                : 'bg-bambu-dark text-bambu-gray hover:text-white'
-            } disabled:opacity-50 disabled:cursor-not-allowed`}
-            title="Turn Off"
-          >
-            {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
-          </button>
+          {isScript ? (
+            /* Script: single Run button */
+            <button
+              onClick={() => setConfirmAction('on')}
+              disabled={!isReachable || isPending}
+              className="p-1.5 rounded transition-colors bg-bambu-green text-white disabled:opacity-50 disabled:cursor-not-allowed"
+              title="Run Script"
+            >
+              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
+            </button>
+          ) : (
+            /* Regular: On/Off buttons */
+            <>
+              <button
+                onClick={() => setConfirmAction('on')}
+                disabled={!isReachable || isPending}
+                className={`p-1.5 rounded transition-colors ${
+                  isOn
+                    ? 'bg-bambu-green text-white'
+                    : 'bg-bambu-dark text-bambu-gray hover:text-white'
+                } disabled:opacity-50 disabled:cursor-not-allowed`}
+                title="Turn On"
+              >
+                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
+              </button>
+              <button
+                onClick={() => setConfirmAction('off')}
+                disabled={!isReachable || isPending}
+                className={`p-1.5 rounded transition-colors ${
+                  !isOn && isReachable
+                    ? 'bg-bambu-dark-tertiary text-white'
+                    : 'bg-bambu-dark text-bambu-gray hover:text-white'
+                } disabled:opacity-50 disabled:cursor-not-allowed`}
+                title="Turn Off"
+              >
+                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
+              </button>
+            </>
+          )}
         </div>
       </div>
 
       {confirmAction && (
         <ConfirmModal
-          title={`Turn ${confirmAction === 'on' ? 'On' : 'Off'} Smart Plug`}
-          message={`Are you sure you want to turn ${confirmAction === 'on' ? 'on' : 'off'} "${plug.name}"?`}
-          confirmText={confirmAction === 'on' ? 'Turn On' : 'Turn Off'}
+          title={isScript && confirmAction === 'on'
+            ? 'Run Script'
+            : `Turn ${confirmAction === 'on' ? 'On' : 'Off'} Smart Plug`}
+          message={isScript && confirmAction === 'on'
+            ? `Are you sure you want to run the script "${plug.name}"?`
+            : `Are you sure you want to turn ${confirmAction === 'on' ? 'on' : 'off'} "${plug.name}"?`}
+          confirmText={isScript && confirmAction === 'on' ? 'Run' : (confirmAction === 'on' ? 'Turn On' : 'Turn Off')}
           variant={confirmAction === 'off' ? 'warning' : 'default'}
           onConfirm={handleConfirm}
           onCancel={() => setConfirmAction(null)}

+ 37 - 0
frontend/src/pages/PrintersPage.tsx

@@ -1093,6 +1093,12 @@ function PrinterCard({
     queryFn: () => api.getSmartPlugByPrinter(printer.id),
   });
 
+  // Fetch script plugs for this printer (for multi-device control)
+  const { data: scriptPlugs } = useQuery({
+    queryKey: ['scriptPlugsByPrinter', printer.id],
+    queryFn: () => api.getScriptPlugsByPrinter(printer.id),
+  });
+
   // Fetch smart plug status if plug exists (faster refresh for energy monitoring)
   const { data: plugStatus } = useQuery({
     queryKey: ['smartPlugStatus', smartPlug?.id],
@@ -1155,6 +1161,15 @@ function PrinterCard({
     },
   });
 
+  // Run script mutation
+  const runScriptMutation = useMutation({
+    mutationFn: (scriptId: number) => api.controlSmartPlug(scriptId, 'on'),
+    onSuccess: () => {
+      showToast('Script triggered');
+    },
+    onError: (error: Error) => showToast(error.message || 'Failed to run script', 'error'),
+  });
+
   // Print control mutations
   const stopPrintMutation = useMutation({
     mutationFn: () => api.stopPrint(printer.id),
@@ -2683,6 +2698,28 @@ function PrinterCard({
                 </button>
               </div>
             </div>
+
+            {/* Script buttons row */}
+            {scriptPlugs && scriptPlugs.length > 0 && (
+              <div className="flex items-center gap-2 mt-2 pt-2 border-t border-bambu-dark-tertiary/50">
+                <Play className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />
+                <span className="text-xs text-bambu-gray">Scripts:</span>
+                <div className="flex flex-wrap gap-1">
+                  {scriptPlugs.map(script => (
+                    <button
+                      key={script.id}
+                      onClick={() => runScriptMutation.mutate(script.id)}
+                      disabled={runScriptMutation.isPending}
+                      title={`Run ${script.ha_entity_id}`}
+                      className="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 rounded transition-colors flex items-center gap-1"
+                    >
+                      <Play className="w-2.5 h-2.5" />
+                      {script.name}
+                    </button>
+                  ))}
+                </div>
+              </div>
+            )}
           </div>
         )}
 

+ 1 - 1
frontend/src/pages/SettingsPage.tsx

@@ -1643,7 +1643,7 @@ export function SettingsPage() {
             </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.
+                Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, input_boolean, and script entities.
               </p>
 
               <div className="flex items-center justify-between">

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


+ 1 - 1
static/index.html

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

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