Browse Source

Merge branch '0.1.6-final' into feature/183

MartinNYHC 3 months ago
parent
commit
402f448d83

+ 20 - 0
CHANGELOG.md

@@ -5,6 +5,13 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6-final] - Not released
 ## [0.1.6-final] - Not released
 
 
 ### New Features
 ### 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
 - **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156):
 - **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156):
   - Checkbox option when uploading STL files to generate thumbnails automatically
   - Checkbox option when uploading STL files to generate thumbnails automatically
   - Batch generate thumbnails for existing STL files via "Generate Thumbnails" button
   - Batch generate thumbnails for existing STL files via "Generate Thumbnails" button
@@ -123,6 +130,15 @@ All notable changes to Bambuddy will be documented in this file.
   - Job Failed: When a job fails to start (enabled by default)
   - Job Failed: When a job fails to start (enabled by default)
   - Queue Complete: When all queued jobs finish
   - Queue Complete: When all queued jobs finish
   - New "Print Queue" section in notification provider settings
   - New "Print Queue" section in notification provider settings
+- **Installation Scripts** - Interactive install scripts for Linux and macOS:
+  - Native install (`install.sh`): Python venv, systemd/launchd service, Node.js 22
+  - Docker install (`docker-install.sh`): Docker Compose setup with health checks
+  - Interactive prompts for: install path, port, bind address, timezone, data/log directories
+  - Unattended mode with `--yes` flag for automation
+  - Auto-detects OS and package manager (apt, dnf, pacman, brew)
+  - Option to set system timezone during installation
+  - Shows IP address for network access when binding to 0.0.0.0
+  - Supports updating existing installations
 
 
 ### Fixes
 ### Fixes
 - **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
 - **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
@@ -164,6 +180,10 @@ All notable changes to Bambuddy will be documented in this file.
   - Tooltip shows full name on hover
   - Tooltip shows full name on hover
 - **K-Profiles Backup Status** - Fixed GitHub backup settings showing incorrect printer connection count (e.g., "1/2 connected" when both printers are connected); now fetches status from API instead of relying on WebSocket cache
 - **K-Profiles Backup Status** - Fixed GitHub backup settings showing incorrect printer connection count (e.g., "1/2 connected" when both printers are connected); now fetches status from API instead of relying on WebSocket cache
 - **GitHub Backup Timestamps** - Removed volatile timestamps from GitHub backup files so git diffs only show actual data changes
 - **GitHub Backup Timestamps** - Removed volatile timestamps from GitHub backup files so git diffs only show actual data changes
+- **Home Assistant Smart Plug Auto-On** - Fixed print scheduler only checking Tasmota plugs when powering on printers for queued prints (Issue #200):
+  - Home Assistant smart plugs now work with automatic printer power-on for scheduled queue jobs
+  - Previously only Tasmota plugs were checked, causing HA plugs to fail with "Tasmota device not found" error
+  - Both `_power_on_and_wait` and `_power_off_if_needed` now use the correct service based on plug type
 
 
 ### Maintenance
 ### Maintenance
 - Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies
 - Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies

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

@@ -383,6 +383,7 @@ async def export_backup(
                     "schedule_on_time": plug.schedule_on_time,
                     "schedule_on_time": plug.schedule_on_time,
                     "schedule_off_time": plug.schedule_off_time,
                     "schedule_off_time": plug.schedule_off_time,
                     "show_in_switchbar": plug.show_in_switchbar,
                     "show_in_switchbar": plug.show_in_switchbar,
+                    "show_on_printer_card": plug.show_on_printer_card,
                 }
                 }
             )
             )
         backup["included"].append("smart_plugs")
         backup["included"].append("smart_plugs")
@@ -1316,6 +1317,7 @@ async def import_backup(
                     existing.schedule_on_time = plug_data.get("schedule_on_time")
                     existing.schedule_on_time = plug_data.get("schedule_on_time")
                     existing.schedule_off_time = plug_data.get("schedule_off_time")
                     existing.schedule_off_time = plug_data.get("schedule_off_time")
                     existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
                     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
                     restored["smart_plugs"] += 1
                 else:
                 else:
                     skipped["smart_plugs"] += 1
                     skipped["smart_plugs"] += 1
@@ -1345,6 +1347,7 @@ async def import_backup(
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
                     show_in_switchbar=plug_data.get("show_in_switchbar", False),
                     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)
                 db.add(plug)
                 restored["smart_plugs"] += 1
                 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")
             raise HTTPException(400, "Printer not found")
 
 
         # Check if printer already has a plug assigned
         # 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())
     plug = SmartPlug(**data.model_dump())
     db.add(plug)
     db.add(plug)
@@ -74,12 +86,48 @@ async def create_smart_plug(
 
 
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 @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)):
 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))
     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 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
 # Tasmota Discovery Endpoints
@@ -287,14 +335,29 @@ async def update_smart_plug(
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
 
 
         # Check if that printer already has a different plug assigned
         # 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():
     for field, value in update_data.items():
         setattr(plug, field, value)
         setattr(plug, field, value)
@@ -376,6 +439,13 @@ async def control_smart_plug(
     plug.last_checked = datetime.utcnow()
     plug.last_checked = datetime.utcnow()
     await db.commit()
     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
     # MQTT relay - publish smart plug state change
     if expected_state:
     if expected_state:
         try:
         try:
@@ -401,6 +471,37 @@ async def control_smart_plug(
     return {"success": True, "action": control.action}
     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)
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
 async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     """Get current plug status from device including energy data."""
     """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:
     except Exception:
         pass
         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
+
     # Migration: Add sliced_for_model column to print_archives for printer model from 3MF
     # Migration: Add sliced_for_model column to print_archives for printer model from 3MF
     try:
     try:
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)"))
         await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)"))

+ 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_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
     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
     # Automation settings
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
     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_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
     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_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
     # Status tracking
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
     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
     password: str | None = None
 
 
     # Home Assistant fields (required when plug_type="homeassistant")
     # 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)
     # 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_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_]+$")
     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_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_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
     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_in_switchbar: bool = False
+    show_on_printer_card: bool = True  # For scripts: show on printer card
 
 
     @model_validator(mode="after")
     @model_validator(mode="after")
     def validate_plug_type_fields(self) -> "SmartPlugBase":
     def validate_plug_type_fields(self) -> "SmartPlugBase":
@@ -77,8 +78,9 @@ class SmartPlugUpdate(BaseModel):
     schedule_enabled: bool | None = None
     schedule_enabled: bool | None = None
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     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$")
     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_in_switchbar: bool | None = None
+    show_on_printer_card: bool | None = None
 
 
 
 
 class SmartPlugResponse(SmartPlugBase):
 class SmartPlugResponse(SmartPlugBase):
@@ -147,7 +149,7 @@ class HAEntity(BaseModel):
     entity_id: str
     entity_id: str
     friendly_name: str
     friendly_name: str
     state: str | None = None
     state: str | None = None
-    domain: str  # "switch", "light", "input_boolean"
+    domain: str  # "switch", "light", "input_boolean", "script"
 
 
 
 
 class HASensorEntity(BaseModel):
 class HASensorEntity(BaseModel):

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

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

+ 8 - 4
backend/app/services/print_scheduler.py

@@ -17,7 +17,7 @@ from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
 from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
 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.smart_plug_manager import smart_plug_manager
 from backend.app.utils.printer_models import normalize_printer_model
 from backend.app.utils.printer_models import normalize_printer_model
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -348,15 +348,18 @@ class PrintScheduler:
 
 
         Returns True if printer connected successfully within timeout.
         Returns True if printer connected successfully within timeout.
         """
         """
+        # Get the appropriate service for the plug type (Tasmota or Home Assistant)
+        service = await smart_plug_manager.get_service_for_plug(plug, db)
+
         # Check current plug state
         # Check current plug state
-        status = await tasmota_service.get_status(plug)
+        status = await service.get_status(plug)
         if not status.get("reachable"):
         if not status.get("reachable"):
             logger.warning(f"Smart plug '{plug.name}' is not reachable")
             logger.warning(f"Smart plug '{plug.name}' is not reachable")
             return False
             return False
 
 
         # Turn on if not already on
         # Turn on if not already on
         if status.get("state") != "ON":
         if status.get("state") != "ON":
-            success = await tasmota_service.turn_on(plug)
+            success = await service.turn_on(plug)
             if not success:
             if not success:
                 logger.warning(f"Failed to turn on smart plug '{plug.name}'")
                 logger.warning(f"Failed to turn on smart plug '{plug.name}'")
                 return False
                 return False
@@ -425,7 +428,8 @@ class PrintScheduler:
             # Wait for cooldown (up to 10 minutes)
             # Wait for cooldown (up to 10 minutes)
             await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
             await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
             logger.info(f"Auto-off: Powering off printer {item.printer_id}")
             logger.info(f"Auto-off: Powering off printer {item.printer_id}")
-            await tasmota_service.turn_off(plug)
+            service = await smart_plug_manager.get_service_for_plug(plug, db)
+            await service.turn_off(plug)
 
 
     async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:
     async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:
         """Get a human-readable name for a queue item."""
         """Get a human-readable name for a queue item."""

+ 11 - 6
backend/app/services/smart_plug_manager.py

@@ -27,7 +27,7 @@ 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):
+    async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
         """Get the appropriate service for the plug type.
         """Get the appropriate service for the plug type.
 
 
         For HA plugs, configures the service with current settings from DB.
         For HA plugs, configures the service with current settings from DB.
@@ -110,7 +110,7 @@ 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)
+                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:
@@ -166,7 +166,7 @@ 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}'")
-        service = await self._get_service_for_plug(plug, db)
+        service = await self.get_service_for_plug(plug, db)
         success = await service.turn_on(plug)
         success = await service.turn_on(plug)
 
 
         if success:
         if success:
@@ -195,6 +195,11 @@ class SmartPlugManager:
             logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
             logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
             return
             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
         # Only auto-off on successful completion, not on failures
         # This allows the user to investigate errors before power-off
         # This allows the user to investigate errors before power-off
         if status != "completed":
         if status != "completed":
@@ -261,7 +266,7 @@ class SmartPlugManager:
                     self.name = f"plug_{plug_id}"
                     self.name = f"plug_{plug_id}"
 
 
             plug_info = PlugInfo()
             plug_info = PlugInfo()
-            service = await self._get_service_for_plug(plug_info)
+            service = await self.get_service_for_plug(plug_info)
             success = await service.turn_off(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")
 
 
@@ -353,7 +358,7 @@ class SmartPlugManager:
                                 self.name = f"plug_{plug_id}"
                                 self.name = f"plug_{plug_id}"
 
 
                         plug_info = PlugInfo()
                         plug_info = PlugInfo()
-                        service = await self._get_service_for_plug(plug_info)
+                        service = await self.get_service_for_plug(plug_info)
                         success = await service.turn_off(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 "
@@ -474,7 +479,7 @@ 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")
 
 
-                        service = await self._get_service_for_plug(plug, db)
+                        service = await self.get_service_for_plug(plug, db)
                         success = await service.turn_off(plug)
                         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)

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

@@ -573,3 +573,200 @@ class TestSmartPlugsAPI:
 
 
         assert response.status_code == 400
         assert response.status_code == 400
         assert "not configured" in response.json()["detail"].lower()
         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

+ 48 - 18
deploy/bambuddy.service

@@ -1,31 +1,61 @@
+# BamBuddy Systemd Service Template
+#
+# INSTALLATION:
+# 1. Copy this file to /etc/systemd/system/bambuddy.service
+# 2. Replace placeholders:
+#    - INSTALL_PATH: Where BamBuddy is installed (e.g., /opt/bambuddy)
+#    - SERVICE_USER: User to run as (e.g., bambuddy)
+#    - DATA_DIR: Data directory (e.g., /opt/bambuddy/data)
+#    - LOG_DIR: Log directory (e.g., /opt/bambuddy/logs)
+# 3. Run: sudo systemctl daemon-reload
+# 4. Run: sudo systemctl enable bambuddy
+# 5. Run: sudo systemctl start bambuddy
+#
+# Or use the install script: ./install/install.sh
+#
+
 [Unit]
 [Unit]
-Description=BamBuddy Print Archive
+Description=BamBuddy - Bambu Lab Print Management
+Documentation=https://github.com/maziggy/bambuddy
 After=network.target
 After=network.target
 
 
 [Service]
 [Service]
 Type=simple
 Type=simple
-User=claude
-Group=claude
-WorkingDirectory=<dir>/bambuddy
-Environment="PATH=<dir/bambuddy/venv/bin"
+User=SERVICE_USER
+Group=SERVICE_USER
+WorkingDirectory=INSTALL_PATH
+
+# Environment file (optional - created by install script)
+EnvironmentFile=-INSTALL_PATH/.env
+
+# Use virtual environment
+Environment="PATH=INSTALL_PATH/venv/bin:/usr/local/bin:/usr/bin:/bin"
+
+# Server configuration
+ExecStart=INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000}
+
+# Restart policy
+Restart=on-failure
+RestartSec=5
 
 
-# Force kill after 10 seconds if graceful shutdown fails
+# Graceful shutdown
 TimeoutStopSec=10
 TimeoutStopSec=10
 
 
-# Kill any zombie ffmpeg processes before starting/after stopping
-ExecStartPre=-/usr/bin/pkill -9 ffmpeg
-ExecStopPost=-/usr/bin/pkill -9 ffmpeg
+# Kill zombie ffmpeg processes (timelapse processing)
+ExecStartPre=-/usr/bin/pkill -9 -f "ffmpeg.*bambuddy"
+ExecStopPost=-/usr/bin/pkill -9 -f "ffmpeg.*bambuddy"
 
 
-# Ensure directories exist and have correct permissions before starting
-# The + prefix runs the command as root even though User=claude
-ExecStartPre=+/bin/mkdir -p <dir>/bambuddy/logs
-ExecStartPre=+/bin/mkdir -p <dir>/bambuddy/archive
-ExecStartPre=+/bin/chown -R <user>:<user> <dir>/bambuddy/logs
-ExecStartPre=+/bin/chown -R <user>:<user> <dir>/bambuddy/archive
+# Logging
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=bambuddy
 
 
-ExecStart=<dir>/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
-Restart=always
-RestartSec=10
+# Security hardening
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=DATA_DIR LOG_DIR INSTALL_PATH
 
 
 [Install]
 [Install]
 WantedBy=multi-user.target
 WantedBy=multi-user.target

+ 2 - 2
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -347,8 +347,8 @@ describe('FileManagerPage', () => {
       render(<FileManagerPage />);
       render(<FileManagerPage />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        // Sort dropdown should show Date as default option
-        expect(screen.getByDisplayValue('Date')).toBeInTheDocument();
+        // Sort dropdown should show Name as default option (persisted to localStorage)
+        expect(screen.getByDisplayValue('Name')).toBeInTheDocument();
       });
       });
     });
     });
   });
   });

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

@@ -848,7 +848,7 @@ export interface SmartPlug {
   name: string;
   name: string;
   plug_type: 'tasmota' | 'homeassistant';
   plug_type: 'tasmota' | 'homeassistant';
   ip_address: string | null;  // Required for Tasmota
   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)
   // Home Assistant energy sensor entities (optional)
   ha_power_entity: string | null;
   ha_power_entity: string | null;
   ha_energy_today_entity: string | null;
   ha_energy_today_entity: string | null;
@@ -871,8 +871,9 @@ export interface SmartPlug {
   schedule_enabled: boolean;
   schedule_enabled: boolean;
   schedule_on_time: string | null;
   schedule_on_time: string | null;
   schedule_off_time: string | null;
   schedule_off_time: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar: boolean;
   show_in_switchbar: boolean;
+  show_on_printer_card: boolean;  // For scripts: show on printer card
   // Status
   // Status
   last_state: string | null;
   last_state: string | null;
   last_checked: string | null;
   last_checked: string | null;
@@ -907,8 +908,9 @@ export interface SmartPlugCreate {
   schedule_enabled?: boolean;
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
   schedule_off_time?: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar?: boolean;
   show_in_switchbar?: boolean;
+  show_on_printer_card?: boolean;
 }
 }
 
 
 export interface SmartPlugUpdate {
 export interface SmartPlugUpdate {
@@ -937,8 +939,9 @@ export interface SmartPlugUpdate {
   schedule_enabled?: boolean;
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
   schedule_off_time?: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar?: boolean;
   show_in_switchbar?: boolean;
+  show_on_printer_card?: boolean;
 }
 }
 
 
 // Home Assistant entity for smart plug selection
 // Home Assistant entity for smart plug selection
@@ -946,7 +949,7 @@ export interface HAEntity {
   entity_id: string;
   entity_id: string;
   friendly_name: string;
   friendly_name: string;
   state: string | null;
   state: string | null;
-  domain: string;  // "switch", "light", "input_boolean"
+  domain: string;  // "switch", "light", "input_boolean", "script"
 }
 }
 
 
 // Home Assistant sensor entity for energy monitoring
 // Home Assistant sensor entity for energy monitoring
@@ -2440,6 +2443,7 @@ export const api = {
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
   getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
   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) =>
   createSmartPlug: (data: SmartPlugCreate) =>
     request<SmartPlug>('/smart-plugs/', {
     request<SmartPlug>('/smart-plugs/', {
       method: 'POST',
       method: 'POST',

+ 194 - 98
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, 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 { 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';
@@ -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 [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);
 
 
+  // Automation settings
+  const [autoOn, setAutoOn] = useState(plug?.auto_on ?? true);
+  const [autoOff, setAutoOff] = useState(plug?.auto_off ?? true);
+
   // Power alert settings
   // Power alert settings
   const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
   const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
   const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');
   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 [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
   const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
   const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
 
 
-  // Switchbar visibility
+  // Visibility options
   const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
   const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
+  const [showOnPrinterCard, setShowOnPrinterCard] = useState(plug?.show_on_printer_card ?? true);
 
 
   // Discovery state
   // Discovery state
   const [isScanning, setIsScanning] = useState(false);
   const [isScanning, setIsScanning] = useState(false);
@@ -235,6 +240,10 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
     mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
       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();
       onClose();
     },
     },
     onError: (err: Error) => {
     onError: (err: Error) => {
@@ -247,6 +256,10 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
     mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
       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();
       onClose();
     },
     },
     onError: (err: Error) => {
     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)
   // 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) => {
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
     e.preventDefault();
@@ -291,6 +310,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
       printer_id: printerId,
+      // Automation
+      auto_on: autoOn,
+      auto_off: autoOff,
       // Power alerts
       // Power alerts
       power_alert_enabled: powerAlertEnabled,
       power_alert_enabled: powerAlertEnabled,
       power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
       power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
@@ -299,8 +321,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       schedule_enabled: scheduleEnabled,
       schedule_enabled: scheduleEnabled,
       schedule_on_time: scheduleOnTime || null,
       schedule_on_time: scheduleOnTime || null,
       schedule_off_time: scheduleOffTime || null,
       schedule_off_time: scheduleOffTime || null,
-      // Switchbar
+      // Visibility options
       show_in_switchbar: showInSwitchbar,
       show_in_switchbar: showInSwitchbar,
+      show_on_printer_card: showOnPrinterCard,
     };
     };
 
 
     if (isEditing) {
     if (isEditing) {
@@ -582,15 +605,15 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                         <p className="text-xs text-bambu-gray mt-1">
                         <p className="text-xs text-bambu-gray mt-1">
                           {debouncedSearch
                           {debouncedSearch
                             ? `Searching all entities (${availableEntities.length} found)`
                             ? `Searching all entities (${availableEntities.length} found)`
-                            : `Showing switch, light, input_boolean (${availableEntities.length} available)`}
+                            : `Showing switch, light, input_boolean, script (${availableEntities.length} available)`}
                         </p>
                         </p>
                       </div>
                       </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 className="border-t border-bambu-dark-tertiary pt-4 mt-4 space-y-3">
                       <div>
                       <div>
                         <p className="text-white font-medium mb-1">Energy Monitoring (Optional)</p>
                         <p className="text-white font-medium mb-1">Energy Monitoring (Optional)</p>
@@ -975,110 +998,183 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
               ))}
               ))}
             </select>
             </select>
             <p className="text-xs text-bambu-gray mt-1">
             <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>
             </p>
           </div>
           </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>
               </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>
                 </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>
-            )}
-          </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>
               </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>
             </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>
                   </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>
                   <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>
                 </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>
             )}
             )}
-          </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 justify-between">
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 <LayoutGrid className="w-4 h-4 text-bambu-green" />
                 <LayoutGrid className="w-4 h-4 text-bambu-green" />

+ 123 - 40
frontend/src/components/SmartPlugCard.tsx

@@ -1,6 +1,6 @@
 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, 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 { 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';
@@ -55,12 +55,24 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
 
 
       return { previousStatus };
       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) => {
     onError: (_err, action, context) => {
       // Rollback on error
       // Rollback on error
       if (context?.previousStatus) {
       if (context?.previousStatus) {
         queryClient.setQueryData(['smart-plug-status', plug.id], 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: () => {
     onSettled: () => {
       // Refetch after a short delay to get actual state
       // Refetch after a short delay to get actual state
@@ -80,6 +92,10 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       if (plug.printer_id) {
       if (plug.printer_id) {
         queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', 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),
     mutationFn: () => api.deleteSmartPlug(plug.id),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
       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 isReachable = status?.reachable ?? false;
   const isPending = controlMutation.isPending;
   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)
   // Generate admin URL with auto-login credentials (Tasmota only)
   const getAdminUrl = () => {
   const getAdminUrl = () => {
     if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null;
     if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null;
@@ -113,10 +136,12 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       <Card className="relative">
       <Card className="relative">
         <CardContent className="p-4">
         <CardContent className="p-4">
           {/* Header Row */}
           {/* Header Row */}
-          <div className="flex items-start justify-between mb-3">
+          <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="flex items-center gap-3 min-w-0 flex-1">
-              <div className={`p-2 rounded-lg flex-shrink-0 ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
-                {plug.plug_type === 'homeassistant' ? (
+              <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'}`} />
                   <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'}`} />
                   <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
@@ -124,20 +149,33 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
               </div>
               </div>
               <div className="min-w-0">
               <div className="min-w-0">
                 <h3 className="font-medium text-white truncate" title={plug.name}>{plug.name}</h3>
                 <h3 className="font-medium text-white truncate" title={plug.name}>{plug.name}</h3>
-                <p className="text-sm text-bambu-gray truncate">
+                <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}
                   {plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
                 </p>
                 </p>
               </div>
               </div>
             </div>
             </div>
 
 
             {/* Status indicator */}
             {/* Status indicator */}
-            <div className="flex flex-col items-end gap-1">
+            <div className="flex flex-col items-end gap-1 flex-shrink-0">
               {statusLoading ? (
               {statusLoading ? (
                 <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
                 <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 ? (
               ) : isReachable ? (
                 <div className="flex items-center gap-1 text-sm">
                 <div className="flex items-center gap-1 text-sm">
                   <Wifi className="w-4 h-4 text-status-ok" />
                   <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>
               ) : (
               ) : (
                 <div className="flex items-center gap-1 text-sm text-status-error">
                 <div className="flex items-center gap-1 text-sm text-status-error">
@@ -193,26 +231,43 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
 
 
           {/* Quick Controls */}
           {/* Quick Controls */}
           <div className="flex gap-2 mb-3">
           <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>
           </div>
 
 
           {/* Toggle Settings Panel */}
           {/* Toggle Settings Panel */}
@@ -230,6 +285,28 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           {/* Expanded Settings */}
           {/* Expanded Settings */}
           {isExpanded && (
           {isExpanded && (
             <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
             <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 */}
               {/* Show in Switchbar Toggle */}
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-2">
                 <div className="flex items-center gap-2">
@@ -267,11 +344,13 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
                 </label>
               </div>
               </div>
 
 
-              {/* Auto On */}
+              {/* Auto On / Run when printer turns on */}
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div>
                 <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>
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input
                   <input
@@ -284,11 +363,13 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
                 </label>
               </div>
               </div>
 
 
-              {/* Auto Off */}
+              {/* Auto Off / Run when printer turns off */}
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div>
                 <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>
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input
                   <input
@@ -301,8 +382,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
                 </label>
               </div>
               </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 className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
                   <div>
                   <div>
                     <p className="text-sm text-white mb-2">Turn Off Delay Mode</p>
                     <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 && (
       {showPowerOnConfirm && (
         <ConfirmModal
         <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"
           variant="default"
           onConfirm={() => {
           onConfirm={() => {
             controlMutation.mutate('on');
             controlMutation.mutate('on');

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

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 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 { api } from '../api/client';
 import type { SmartPlug } from '../api/client';
 import type { SmartPlug } from '../api/client';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
@@ -32,6 +32,9 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
   const isReachable = status?.reachable ?? false;
   const isReachable = status?.reachable ?? false;
   const isPending = controlMutation.isPending;
   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 = () => {
   const handleConfirm = () => {
     if (confirmAction) {
     if (confirmAction) {
       controlMutation.mutate(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 justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
         <div className="flex items-center gap-2">
         <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>
           <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">
             <div className="flex items-center gap-1 text-xs">
               {statusLoading ? (
               {statusLoading ? (
                 <Loader2 className="w-3 h-3 text-bambu-gray animate-spin" />
                 <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 ? (
               ) : isReachable ? (
                 <>
                 <>
                   <Wifi className="w-3 h-3 text-status-ok" />
                   <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 && (
                   {status?.energy?.power !== null && status?.energy?.power !== undefined && (
                     <>
                     <>
                       <span className="text-bambu-gray mx-1">|</span>
                       <span className="text-bambu-gray mx-1">|</span>
@@ -74,38 +92,57 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
         </div>
         </div>
 
 
         <div className="flex gap-1">
         <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>
       </div>
       </div>
 
 
       {confirmAction && (
       {confirmAction && (
         <ConfirmModal
         <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'}
           variant={confirmAction === 'off' ? 'warning' : 'default'}
           onConfirm={handleConfirm}
           onConfirm={handleConfirm}
           onCancel={() => setConfirmAction(null)}
           onCancel={() => setConfirmAction(null)}

+ 38 - 18
frontend/src/pages/FileManagerPage.tsx

@@ -50,6 +50,7 @@ import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useIsMobile } from '../hooks/useIsMobile';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 type SortDirection = 'asc' | 'desc';
@@ -855,6 +856,7 @@ function isSlicedFilename(filename: string): boolean {
 interface FileCardProps {
 interface FileCardProps {
   file: LibraryFileListItem;
   file: LibraryFileListItem;
   isSelected: boolean;
   isSelected: boolean;
+  isMobile: boolean;
   onSelect: (id: number) => void;
   onSelect: (id: number) => void;
   onDelete: (id: number) => void;
   onDelete: (id: number) => void;
   onDownload: (id: number) => void;
   onDownload: (id: number) => void;
@@ -865,7 +867,7 @@ interface FileCardProps {
   thumbnailVersion?: number;
   thumbnailVersion?: number;
 }
 }
 
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
 
 
   return (
   return (
@@ -921,7 +923,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
       </div>
       </div>
 
 
       {/* Actions - always visible on mobile, hover on desktop */}
       {/* Actions - always visible on mobile, hover on desktop */}
-      <div className="absolute bottom-2 right-2 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
+      <div className={`absolute bottom-2 right-2 transition-opacity ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
         <button
         <button
           onClick={() => setShowActions(!showActions)}
           onClick={() => setShowActions(!showActions)}
           className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
           className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
@@ -991,7 +993,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
       <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
       <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
         isSelected
         isSelected
           ? 'bg-bambu-green border-bambu-green'
           ? 'bg-bambu-green border-bambu-green'
-          : 'border-white/30 bg-black/30 opacity-100 md:opacity-0 md:group-hover:opacity-100'
+          : `border-white/30 bg-black/30 ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`
       }`}>
       }`}>
         {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
         {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
       </div>
       </div>
@@ -1073,11 +1075,20 @@ export function FileManagerPage() {
     };
     };
   }, [isResizing, sidebarWidth]);
   }, [isResizing, sidebarWidth]);
 
 
-  // Filter and sort state
+  // Filter and sort state (persist sort preferences to localStorage)
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
   const [filterType, setFilterType] = useState<string>('all');
   const [filterType, setFilterType] = useState<string>('all');
-  const [sortField, setSortField] = useState<SortField>('date');
-  const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
+  const [sortField, setSortField] = useState<SortField>(() => {
+    const saved = localStorage.getItem('library-sort-field');
+    return (saved as SortField) || 'name';
+  });
+  const [sortDirection, setSortDirection] = useState<SortDirection>(() => {
+    const saved = localStorage.getItem('library-sort-direction');
+    return (saved as SortDirection) || 'asc';
+  });
+
+  // Mobile detection for touch-friendly UI
+  const isMobile = useIsMobile();
 
 
   // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
   // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
   useEffect(() => {
   useEffect(() => {
@@ -1420,7 +1431,7 @@ export function FileManagerPage() {
   const isLoading = foldersLoading || filesLoading;
   const isLoading = foldersLoading || filesLoading;
 
 
   return (
   return (
-    <div className="p-4 md:p-8 h-[calc(100vh-64px)] flex flex-col">
+    <div className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col">
       {/* Header */}
       {/* Header */}
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
         <div>
         <div>
@@ -1630,9 +1641,9 @@ export function FileManagerPage() {
 
 
         {/* Files area */}
         {/* Files area */}
         <div className="flex-1 flex flex-col min-w-0 min-h-0">
         <div className="flex-1 flex flex-col min-w-0 min-h-0">
-          {/* Search, Filter, Sort toolbar */}
+          {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
           {files && files.length > 0 && (
           {files && files.length > 0 && (
-            <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
+            <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static">
               {/* Search */}
               {/* Search */}
               <div className="relative w-full sm:w-auto sm:flex-1 sm:max-w-xs">
               <div className="relative w-full sm:w-auto sm:flex-1 sm:max-w-xs">
                 <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
                 <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
@@ -1666,17 +1677,25 @@ export function FileManagerPage() {
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 <select
                 <select
                   value={sortField}
                   value={sortField}
-                  onChange={(e) => setSortField(e.target.value as SortField)}
+                  onChange={(e) => {
+                    const newField = e.target.value as SortField;
+                    setSortField(newField);
+                    localStorage.setItem('library-sort-field', newField);
+                  }}
                   className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green"
                   className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green"
                 >
                 >
-                  <option value="date">Date</option>
                   <option value="name">Name</option>
                   <option value="name">Name</option>
+                  <option value="date">Date</option>
                   <option value="size">Size</option>
                   <option value="size">Size</option>
                   <option value="type">Type</option>
                   <option value="type">Type</option>
                   <option value="prints">Prints</option>
                   <option value="prints">Prints</option>
                 </select>
                 </select>
                 <button
                 <button
-                  onClick={() => setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'))}
+                  onClick={() => setSortDirection((d) => {
+                    const newDir = d === 'asc' ? 'desc' : 'asc';
+                    localStorage.setItem('library-sort-direction', newDir);
+                    return newDir;
+                  })}
                   className="p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
                   className="p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
                   title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
                   title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
                 >
                 >
@@ -1697,9 +1716,9 @@ export function FileManagerPage() {
             </div>
             </div>
           )}
           )}
 
 
-          {/* Selection toolbar */}
+          {/* Selection toolbar - sticky on mobile below search bar */}
           {filteredAndSortedFiles.length > 0 && (
           {filteredAndSortedFiles.length > 0 && (
-            <div className="flex flex-wrap items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
+            <div className="flex flex-wrap items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary sticky top-[52px] z-10 lg:static">
               {/* Select all / Deselect all */}
               {/* Select all / Deselect all */}
               {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
               {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
                 <Button
                 <Button
@@ -1825,13 +1844,14 @@ export function FileManagerPage() {
               </Button>
               </Button>
             </div>
             </div>
           ) : viewMode === 'grid' ? (
           ) : viewMode === 'grid' ? (
-            <div className="flex-1 overflow-y-auto">
+            <div className="flex-1 lg:overflow-y-auto">
               <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
               <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
                 {filteredAndSortedFiles.map((file) => (
                 {filteredAndSortedFiles.map((file) => (
                   <FileCard
                   <FileCard
                     key={file.id}
                     key={file.id}
                     file={file}
                     file={file}
                     isSelected={selectedFiles.includes(file.id)}
                     isSelected={selectedFiles.includes(file.id)}
+                    isMobile={isMobile}
                     onSelect={handleFileSelect}
                     onSelect={handleFileSelect}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDownload={handleDownload}
                     onDownload={handleDownload}
@@ -1845,10 +1865,10 @@ export function FileManagerPage() {
               </div>
               </div>
             </div>
             </div>
           ) : (
           ) : (
-            <div className="flex-1 overflow-y-auto">
+            <div className="flex-1 lg:overflow-y-auto">
               <div className="bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden">
               <div className="bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden">
-                {/* List header */}
-                <div className="grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium">
+                {/* List header - hidden on mobile, show simplified on small screens */}
+                <div className="hidden sm:grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium">
                   <div className="w-6" />
                   <div className="w-6" />
                   <div>Name</div>
                   <div>Name</div>
                   <div>Type</div>
                   <div>Type</div>

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

@@ -1093,6 +1093,12 @@ function PrinterCard({
     queryFn: () => api.getSmartPlugByPrinter(printer.id),
     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)
   // Fetch smart plug status if plug exists (faster refresh for energy monitoring)
   const { data: plugStatus } = useQuery({
   const { data: plugStatus } = useQuery({
     queryKey: ['smartPlugStatus', smartPlug?.id],
     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
   // Print control mutations
   const stopPrintMutation = useMutation({
   const stopPrintMutation = useMutation({
     mutationFn: () => api.stopPrint(printer.id),
     mutationFn: () => api.stopPrint(printer.id),
@@ -2683,6 +2698,28 @@ function PrinterCard({
                 </button>
                 </button>
               </div>
               </div>
             </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>
           </div>
         )}
         )}
 
 

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

@@ -1660,7 +1660,7 @@ export function SettingsPage() {
             </CardHeader>
             </CardHeader>
             <CardContent className="space-y-4">
             <CardContent className="space-y-4">
               <p className="text-sm text-bambu-gray">
               <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>
               </p>
 
 
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">

+ 234 - 0
install/README.md

@@ -0,0 +1,234 @@
+# BamBuddy Installation Scripts
+
+Interactive installation scripts for BamBuddy with support for both native and Docker deployments.
+
+## Quick Start
+
+### Docker Installation (Recommended)
+
+**Linux/macOS:**
+```bash
+curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash
+```
+
+### Native Installation
+
+**Linux/macOS:**
+```bash
+curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash
+```
+
+---
+
+## Scripts Overview
+
+| Script | Platform | Method |
+|--------|----------|--------|
+| `install.sh` | Linux, macOS | Native (Python venv) |
+| `docker-install.sh` | Linux, macOS | Docker |
+
+---
+
+## Native Installation Scripts
+
+### `install.sh` (Linux/macOS)
+
+Installs BamBuddy with Python virtual environment and optional systemd/launchd service.
+
+**Supported Systems:**
+- Debian/Ubuntu (apt)
+- RHEL/Fedora/CentOS (dnf/yum)
+- Arch Linux (pacman)
+- openSUSE (zypper)
+- macOS (Homebrew)
+
+**Options:**
+```
+--path PATH        Installation directory (default: /opt/bambuddy)
+--port PORT        Port to listen on (default: 8000)
+--tz TIMEZONE      Timezone (default: system timezone)
+--data-dir PATH    Data directory (default: INSTALL_PATH/data)
+--log-dir PATH     Log directory (default: INSTALL_PATH/logs)
+--debug            Enable debug mode
+--log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
+--no-service       Skip systemd/launchd service setup
+--yes, -y          Non-interactive mode, accept defaults
+```
+
+**Examples:**
+```bash
+# Interactive installation
+./install.sh
+
+# Unattended with custom settings
+./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes
+
+# Minimal unattended
+./install.sh -y
+
+# Skip service setup
+./install.sh --no-service -y
+```
+
+---
+
+## Docker Installation Scripts
+
+### `docker-install.sh` (Linux/macOS)
+
+Installs BamBuddy using Docker containers.
+
+**Options:**
+```
+--path PATH        Installation directory (default: ~/bambuddy)
+--port PORT        Port to expose (default: 8000)
+--tz TIMEZONE      Timezone (default: system timezone)
+--build            Build from source instead of using pre-built image
+--yes, -y          Non-interactive mode, accept defaults
+```
+
+**Examples:**
+```bash
+# Interactive installation
+./docker-install.sh
+
+# Unattended with custom settings
+./docker-install.sh --path /srv/bambuddy --port 3000 --tz Europe/Berlin --yes
+
+# Build from source
+./docker-install.sh --build --yes
+```
+
+---
+
+## Configuration Options
+
+All scripts support these configuration options:
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| Install Path | Where BamBuddy is installed | `/opt/bambuddy` (Linux/Docker) |
+| Port | HTTP port for web interface | `8000` |
+| Timezone | Server timezone | System timezone or `UTC` |
+| Data Directory | Database and archives | `INSTALL_PATH/data` |
+| Log Directory | Application logs | `INSTALL_PATH/logs` |
+| Debug Mode | Enable verbose logging | `false` |
+| Log Level | INFO, WARNING, ERROR, DEBUG | `INFO` |
+
+---
+
+## Post-Installation
+
+### Accessing BamBuddy
+
+After installation, open your browser to:
+```
+http://localhost:8000
+```
+
+Or use the port you specified during installation.
+
+### Service Management
+
+**Linux (systemd):**
+```bash
+sudo systemctl status bambuddy    # Check status
+sudo systemctl start bambuddy     # Start
+sudo systemctl stop bambuddy      # Stop
+sudo systemctl restart bambuddy   # Restart
+sudo journalctl -u bambuddy -f    # View logs
+```
+
+**macOS (launchd):**
+```bash
+launchctl list | grep bambuddy                              # Check status
+launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist    # Start
+launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist  # Stop
+```
+
+**Docker:**
+```bash
+docker compose ps           # Check status
+docker compose up -d        # Start
+docker compose down         # Stop
+docker compose restart      # Restart
+docker compose logs -f      # View logs
+```
+
+### Updating
+
+**Native installation:**
+```bash
+cd /opt/bambuddy
+git pull
+source venv/bin/activate
+pip install -r requirements.txt
+cd frontend && npm ci && npm run build
+sudo systemctl restart bambuddy  # Linux
+```
+
+**Docker (pre-built image):**
+```bash
+cd ~/bambuddy
+docker compose pull
+docker compose up -d
+```
+
+**Docker (from source):**
+```bash
+cd ~/bambuddy
+git pull
+docker compose up -d --build
+```
+
+---
+
+## Troubleshooting
+
+### Permission Denied (Linux)
+Run with `sudo` or ensure your user has appropriate permissions:
+```bash
+sudo ./install.sh
+```
+
+### Docker: Printer Discovery Not Working
+Docker Desktop for macOS doesn't support host networking. Add printers manually by IP address in the BamBuddy web interface.
+
+### Service Won't Start
+Check logs for errors:
+```bash
+# Linux
+sudo journalctl -u bambuddy -n 50
+
+# Docker
+docker compose logs bambuddy
+```
+
+### Port Already in Use
+Choose a different port during installation or stop the conflicting service:
+```bash
+# Find what's using port 8000
+sudo lsof -i :8000  # Linux/macOS
+```
+
+---
+
+## Requirements
+
+### Native Installation
+- Python 3.10+ (automatically installed if missing)
+- Node.js 18+ (automatically installed if missing)
+- Git (automatically installed if missing)
+- ~500MB disk space
+
+### Docker Installation
+- Docker Engine 20+ or Docker Desktop
+- ~1GB disk space (includes image)
+
+---
+
+## Support
+
+- **Documentation:** https://wiki.bambuddy.cool
+- **Discord:** https://discord.gg/aFS3ZfScHM
+- **Issues:** https://github.com/maziggy/bambuddy/issues

+ 541 - 0
install/docker-install.sh

@@ -0,0 +1,541 @@
+#!/usr/bin/env bash
+#
+# BamBuddy Docker Installation Script
+# Supports: Linux (all distros), macOS
+#
+# Usage:
+#   Interactive:  curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash
+#   Unattended:   ./docker-install.sh --path /opt/bambuddy --port 8000 --yes
+#
+# Options:
+#   --path PATH        Installation directory (default: /opt/bambuddy)
+#   --port PORT        Port to expose (default: 8000)
+#   --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)
+#   --tz TIMEZONE      Timezone (default: system timezone or UTC)
+#   --build            Build from source instead of using pre-built image
+#   --yes, -y          Non-interactive mode, accept defaults
+#   --help, -h         Show this help message
+#
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+BOLD='\033[1m'
+
+# Default values
+DEFAULT_INSTALL_PATH="/opt/bambuddy"
+DEFAULT_PORT="8000"
+DEFAULT_BIND_ADDRESS="0.0.0.0"
+
+# Script variables
+INSTALL_PATH=""
+PORT=""
+BIND_ADDRESS=""
+TIMEZONE=""
+BUILD_FROM_SOURCE="false"
+NON_INTERACTIVE="false"
+OS_TYPE=""
+DOCKER_CMD=""
+
+# -----------------------------------------------------------------------------
+# Helper Functions
+# -----------------------------------------------------------------------------
+
+print_banner() {
+    echo -e "${CYAN}"
+    echo "╔════════════════════════════════════════════════════════╗"
+    echo "║                                                        ║"
+    echo "║   ____                  _               _     _        ║"
+    echo "║  | __ )  __ _ _ __ ___ | |__  _   _  __| | __| |_   _  ║"
+    echo "║  |  _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║"
+    echo "║  | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║"
+    echo "║  |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║"
+    echo "║                                                 |___/  ║"
+    echo "║                                                        ║"
+    echo "║            Docker Installation Script                  ║"
+    echo "║                                                        ║"
+    echo "╚════════════════════════════════════════════════════════╝"
+    echo -e "${NC}"
+}
+
+log_info() {
+    echo -e "${BLUE}[INFO]${NC} $1"
+}
+
+log_success() {
+    echo -e "${GREEN}[OK]${NC} $1"
+}
+
+log_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+prompt() {
+    local prompt_text="$1"
+    local default_value="$2"
+    local var_name="$3"
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        eval "$var_name=\"$default_value\""
+        return
+    fi
+
+    if [[ -n "$default_value" ]]; then
+        echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
+    else
+        echo -en "${BOLD}$prompt_text${NC}: "
+    fi
+
+    read -r input
+    if [[ -z "$input" ]]; then
+        eval "$var_name=\"$default_value\""
+    else
+        eval "$var_name=\"$input\""
+    fi
+}
+
+prompt_yes_no() {
+    local prompt_text="$1"
+    local default="$2"  # y or n
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        [[ "$default" == "y" ]] && return 0 || return 1
+    fi
+
+    local yn_hint="[y/n]"
+    [[ "$default" == "y" ]] && yn_hint="[Y/n]"
+    [[ "$default" == "n" ]] && yn_hint="[y/N]"
+
+    while true; do
+        echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
+        read -r yn
+        [[ -z "$yn" ]] && yn="$default"
+        case "$yn" in
+            [Yy]* ) return 0;;
+            [Nn]* ) return 1;;
+            * ) echo "Please answer yes or no.";;
+        esac
+    done
+}
+
+show_help() {
+    echo "BamBuddy Docker Installation Script"
+    echo ""
+    echo "Usage: $0 [OPTIONS]"
+    echo ""
+    echo "Options:"
+    echo "  --path PATH        Installation directory (default: /opt/bambuddy)"
+    echo "  --port PORT        Port to expose (default: 8000)"
+    echo "  --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)"
+    echo "  --tz TIMEZONE      Timezone (default: system timezone or UTC)"
+    echo "  --build            Build from source instead of using pre-built image"
+    echo "  --yes, -y          Non-interactive mode, accept defaults"
+    echo "  --help, -h         Show this help message"
+    echo ""
+    echo "Examples:"
+    echo "  Interactive installation:"
+    echo "    ./docker-install.sh"
+    echo ""
+    echo "  Unattended installation with custom settings:"
+    echo "    ./docker-install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes"
+    echo ""
+    echo "  Build from source:"
+    echo "    ./docker-install.sh --build --yes"
+    exit 0
+}
+
+# -----------------------------------------------------------------------------
+# System Detection
+# -----------------------------------------------------------------------------
+
+detect_os() {
+    if [[ "$OSTYPE" == "darwin"* ]]; then
+        OS_TYPE="macos"
+        return
+    fi
+
+    if [[ -f /etc/os-release ]]; then
+        OS_TYPE="linux"
+    else
+        log_error "Cannot detect operating system"
+        exit 1
+    fi
+}
+
+detect_docker() {
+    # Check for docker compose (v2) or docker-compose (v1)
+    if docker compose version &>/dev/null 2>&1; then
+        DOCKER_CMD="docker compose"
+        log_success "Found Docker Compose v2"
+        return 0
+    elif docker-compose --version &>/dev/null 2>&1; then
+        DOCKER_CMD="docker-compose"
+        log_success "Found Docker Compose v1"
+        return 0
+    fi
+    return 1
+}
+
+detect_timezone() {
+    if [[ -n "$TIMEZONE" ]]; then
+        return 0
+    fi
+
+    # Try to get system timezone (with error handling for set -e)
+    TIMEZONE=""
+    if [[ -f /etc/timezone ]]; then
+        TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
+        TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
+        TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
+    fi
+
+    # Default to UTC if not found (use if/then to avoid set -e issue with &&)
+    if [[ -z "$TIMEZONE" ]]; then
+        TIMEZONE="UTC"
+    fi
+    return 0
+}
+
+# -----------------------------------------------------------------------------
+# Installation Functions
+# -----------------------------------------------------------------------------
+
+install_docker() {
+    log_info "Docker not found, installing..."
+
+    case "$OS_TYPE" in
+        linux)
+            # Use Docker's convenience script
+            curl -fsSL https://get.docker.com | sh
+
+            # Add current user to docker group
+            if [[ -n "$SUDO_USER" ]]; then
+                sudo usermod -aG docker "$SUDO_USER"
+                log_warn "Added $SUDO_USER to docker group. You may need to log out and back in."
+            else
+                sudo usermod -aG docker "$USER"
+                log_warn "Added $USER to docker group. You may need to log out and back in."
+            fi
+
+            # Start Docker service
+            sudo systemctl enable docker
+            sudo systemctl start docker
+            ;;
+        macos)
+            log_error "Docker Desktop not found."
+            log_error "Please install Docker Desktop for Mac from: https://www.docker.com/products/docker-desktop"
+            exit 1
+            ;;
+    esac
+
+    log_success "Docker installed"
+}
+
+create_install_dir() {
+    log_info "Creating installation directory..."
+
+    mkdir -p "$INSTALL_PATH"
+    cd "$INSTALL_PATH"
+
+    log_success "Directory created: $INSTALL_PATH"
+}
+
+download_compose_file() {
+    log_info "Downloading docker-compose.yml..."
+
+    if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
+        # Clone the full repo for building
+        if [[ -d ".git" ]]; then
+            log_info "Existing repository found, updating..."
+            git fetch origin
+            git reset --hard origin/main
+        else
+            git clone https://github.com/maziggy/bambuddy.git .
+        fi
+    else
+        # Just download the compose file
+        curl -fsSL -o docker-compose.yml \
+            https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml
+    fi
+
+    log_success "docker-compose.yml ready"
+}
+
+create_env_file() {
+    log_info "Creating environment configuration..."
+
+    cat > .env << EOF
+# BamBuddy Docker Configuration
+# Generated by docker-install.sh on $(date)
+
+# Port BamBuddy runs on
+PORT=$PORT
+
+# Timezone
+TZ=$TIMEZONE
+EOF
+
+    log_success "Environment file created"
+}
+
+customize_compose() {
+    # Detect if we need to disable host networking (macOS/Windows in Docker Desktop)
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        log_warn "Docker Desktop detected. Host networking is not supported."
+        log_info "Modifying docker-compose.yml for port mapping..."
+
+        # Create a modified compose file for macOS
+        if [[ -f docker-compose.yml ]]; then
+            # Comment out network_mode: host and uncomment ports section
+            sed -i.bak \
+                -e 's/^[[:space:]]*network_mode: host/#    network_mode: host/' \
+                -e 's/^[[:space:]]*#ports:/    ports:/' \
+                -e 's/^[[:space:]]*#[[:space:]]*- "\${PORT:-8000}:8000"/      - "\${PORT:-8000}:8000"/' \
+                docker-compose.yml
+
+            log_warn "Printer discovery may not work. Add printers manually by IP address."
+        fi
+    fi
+}
+
+start_container() {
+    log_info "Starting BamBuddy..."
+
+    if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
+        $DOCKER_CMD up -d --build
+    else
+        $DOCKER_CMD up -d
+    fi
+
+    # Wait for container to start
+    log_info "Waiting for container to start..."
+    local max_attempts=15
+    local attempt=0
+
+    while [[ $attempt -lt $max_attempts ]]; do
+        # Check if container is running (Up)
+        if $DOCKER_CMD ps | grep -q "Up"; then
+            log_success "BamBuddy container is running"
+            return 0
+        fi
+
+        # Check if container failed
+        if $DOCKER_CMD ps -a | grep -q "Exited"; then
+            log_error "Container failed to start"
+            log_info "Check logs with: $DOCKER_CMD logs bambuddy"
+            return 1
+        fi
+
+        sleep 2
+        ((attempt++))
+    done
+
+    log_warn "Container may still be starting. Check with: $DOCKER_CMD ps"
+}
+
+# -----------------------------------------------------------------------------
+# Main Installation Flow
+# -----------------------------------------------------------------------------
+
+parse_args() {
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --path)
+                INSTALL_PATH="$2"
+                shift 2
+                ;;
+            --port)
+                PORT="$2"
+                shift 2
+                ;;
+            --bind)
+                BIND_ADDRESS="$2"
+                shift 2
+                ;;
+            --tz)
+                TIMEZONE="$2"
+                shift 2
+                ;;
+            --build)
+                BUILD_FROM_SOURCE="true"
+                shift
+                ;;
+            --yes|-y)
+                NON_INTERACTIVE="true"
+                shift
+                ;;
+            --help|-h)
+                show_help
+                ;;
+            *)
+                log_error "Unknown option: $1"
+                show_help
+                ;;
+        esac
+    done
+}
+
+gather_config() {
+    echo ""
+    echo -e "${BOLD}Installation Configuration${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo ""
+
+    # Installation path
+    [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
+
+    # Port
+    [[ -z "$PORT" ]] && prompt "Port to expose" "$DEFAULT_PORT" PORT
+
+    # Bind address
+    if [[ -z "$BIND_ADDRESS" ]]; then
+        echo ""
+        echo "Network access:"
+        echo "  0.0.0.0   - Accessible from other devices on your network (recommended)"
+        echo "  127.0.0.1 - Only accessible from this machine"
+        prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
+    fi
+
+    # Timezone
+    detect_timezone
+    prompt "Timezone" "$TIMEZONE" TIMEZONE
+
+    # Build from source?
+    if [[ "$BUILD_FROM_SOURCE" != "true" ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
+        if prompt_yes_no "Build from source? (No = use pre-built image)" "n"; then
+            BUILD_FROM_SOURCE="true"
+        fi
+    fi
+
+    # Confirm
+    echo ""
+    echo -e "${BOLD}Installation Summary${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo -e "  Install path:  ${GREEN}$INSTALL_PATH${NC}"
+    echo -e "  Port:          ${GREEN}$PORT${NC}"
+    echo -e "  Bind address:  ${GREEN}$BIND_ADDRESS${NC}"
+    echo -e "  Timezone:      ${GREEN}$TIMEZONE${NC}"
+    echo -e "  Build source:  ${GREEN}$BUILD_FROM_SOURCE${NC}"
+    echo ""
+
+    if ! prompt_yes_no "Proceed with installation?" "y"; then
+        echo "Installation cancelled."
+        exit 0
+    fi
+}
+
+main() {
+    parse_args "$@"
+    print_banner
+
+    # Check if running via pipe (curl | bash) - interactive mode won't work
+    if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
+        log_error "Interactive mode requires a terminal."
+        log_info "When using 'curl | bash', you must use non-interactive mode:"
+        echo ""
+        echo "    curl -fsSL URL | bash -s -- --yes"
+        echo ""
+        log_info "Or download and run directly:"
+        echo ""
+        echo "    curl -fsSL URL -o docker-install.sh && chmod +x docker-install.sh && ./docker-install.sh"
+        echo ""
+        exit 1
+    fi
+
+    # Detect system
+    log_info "Detecting system..."
+    detect_os
+    log_success "Detected: $OS_TYPE"
+
+    # Check for Docker
+    if ! command -v docker &>/dev/null; then
+        install_docker
+    fi
+
+    if ! detect_docker; then
+        log_error "Docker Compose not found. Please install Docker Compose."
+        exit 1
+    fi
+
+    # Check if Docker daemon is running
+    if ! docker info &>/dev/null; then
+        log_error "Docker daemon is not running. Please start Docker and try again."
+        exit 1
+    fi
+
+    # Gather configuration
+    gather_config
+
+    # Install steps
+    echo ""
+    echo -e "${BOLD}Starting Installation${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo ""
+
+    create_install_dir
+    download_compose_file
+    create_env_file
+    customize_compose
+    start_container
+
+    # Done!
+    echo ""
+    echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
+    echo -e "${GREEN}║                                                              ║${NC}"
+    echo -e "${GREEN}║              Installation Complete!                          ║${NC}"
+    echo -e "${GREEN}║                                                              ║${NC}"
+    echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
+    echo ""
+    # Show appropriate URL based on bind address
+    if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
+        local ip_addr
+        ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+        echo -e "                    ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
+    else
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Manage container:${NC}"
+    echo -e "    Status:  cd $INSTALL_PATH && $DOCKER_CMD ps"
+    echo -e "    Logs:    cd $INSTALL_PATH && $DOCKER_CMD logs -f bambuddy"
+    echo -e "    Stop:    cd $INSTALL_PATH && $DOCKER_CMD down"
+    echo -e "    Start:   cd $INSTALL_PATH && $DOCKER_CMD up -d"
+    echo -e "    Restart: cd $INSTALL_PATH && $DOCKER_CMD restart"
+    echo ""
+    echo -e "  ${BOLD}Update BamBuddy:${NC}"
+    if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
+        echo -e "    cd $INSTALL_PATH && git pull && $DOCKER_CMD up -d --build"
+    else
+        echo -e "    cd $INSTALL_PATH && $DOCKER_CMD pull && $DOCKER_CMD up -d"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Data location:${NC}  Docker volumes (bambuddy_data, bambuddy_logs)"
+    echo ""
+    echo -e "  ${BOLD}Documentation:${NC}  ${CYAN}https://wiki.bambuddy.cool${NC}"
+    echo ""
+
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        echo -e "  ${YELLOW}Note:${NC} Printer discovery may not work with Docker Desktop."
+        echo -e "        Add printers manually using their IP address."
+        echo ""
+    fi
+}
+
+main "$@"

+ 883 - 0
install/install.sh

@@ -0,0 +1,883 @@
+#!/usr/bin/env bash
+#
+# BamBuddy Native Installation Script
+# Supports: Debian/Ubuntu, RHEL/Fedora/CentOS, Arch Linux, macOS
+#
+# Usage:
+#   Interactive:  curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash
+#   Unattended:   ./install.sh --path /opt/bambuddy --port 8000 --yes
+#
+# Options:
+#   --path PATH        Installation directory (default: /opt/bambuddy)
+#   --port PORT        Port to listen on (default: 8000)
+#   --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)
+#   --tz TIMEZONE      Timezone (default: system timezone or UTC)
+#   --data-dir PATH    Data directory (default: INSTALL_PATH/data)
+#   --log-dir PATH     Log directory (default: INSTALL_PATH/logs)
+#   --debug            Enable debug mode
+#   --log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
+#   --no-service       Skip systemd service setup (Linux only)
+#   --set-system-tz    Set system timezone to match (for unattended installs)
+#   --yes, -y          Non-interactive mode, accept defaults
+#   --help, -h         Show this help message
+#
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+BOLD='\033[1m'
+
+# Default values
+DEFAULT_INSTALL_PATH="/opt/bambuddy"
+DEFAULT_PORT="8000"
+DEFAULT_BIND_ADDRESS="0.0.0.0"
+DEFAULT_LOG_LEVEL="INFO"
+DEFAULT_DEBUG="false"
+
+# Script variables
+INSTALL_PATH=""
+PORT=""
+BIND_ADDRESS=""
+TIMEZONE=""
+DATA_DIR=""
+LOG_DIR=""
+DEBUG_MODE=""
+LOG_LEVEL=""
+SKIP_SERVICE="false"
+SET_SYSTEM_TZ=""
+NON_INTERACTIVE="false"
+OS_TYPE=""
+PKG_MANAGER=""
+PYTHON_CMD=""
+SERVICE_USER="bambuddy"
+
+# -----------------------------------------------------------------------------
+# Helper Functions
+# -----------------------------------------------------------------------------
+
+print_banner() {
+    echo -e "${CYAN}"
+    echo "╔════════════════════════════════════════════════════════╗"
+    echo "║                                                        ║"
+    echo "║   ____                  _               _     _        ║"
+    echo "║  | __ )  __ _ _ __ ___ | |__  _   _  __| | __| |_   _  ║"
+    echo "║  |  _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║"
+    echo "║  | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║"
+    echo "║  |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║"
+    echo "║                                                 |___/  ║"
+    echo "║                                                        ║"
+    echo "║            Native Installation Script                  ║"
+    echo "║                                                        ║"
+    echo "╚════════════════════════════════════════════════════════╝"
+    echo -e "${NC}"
+}
+
+log_info() {
+    echo -e "${BLUE}[INFO]${NC} $1"
+}
+
+log_success() {
+    echo -e "${GREEN}[OK]${NC} $1"
+}
+
+log_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+prompt() {
+    local prompt_text="$1"
+    local default_value="$2"
+    local var_name="$3"
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        eval "$var_name=\"$default_value\""
+        return
+    fi
+
+    if [[ -n "$default_value" ]]; then
+        echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
+    else
+        echo -en "${BOLD}$prompt_text${NC}: "
+    fi
+
+    read -r input
+    if [[ -z "$input" ]]; then
+        eval "$var_name=\"$default_value\""
+    else
+        eval "$var_name=\"$input\""
+    fi
+}
+
+prompt_yes_no() {
+    local prompt_text="$1"
+    local default="$2"  # y or n
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        [[ "$default" == "y" ]] && return 0 || return 1
+    fi
+
+    local yn_hint="[y/n]"
+    [[ "$default" == "y" ]] && yn_hint="[Y/n]"
+    [[ "$default" == "n" ]] && yn_hint="[y/N]"
+
+    while true; do
+        echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
+        read -r yn
+        [[ -z "$yn" ]] && yn="$default"
+        case "$yn" in
+            [Yy]* ) return 0;;
+            [Nn]* ) return 1;;
+            * ) echo "Please answer yes or no.";;
+        esac
+    done
+}
+
+show_help() {
+    echo "BamBuddy Native Installation Script"
+    echo ""
+    echo "Usage: $0 [OPTIONS]"
+    echo ""
+    echo "Options:"
+    echo "  --path PATH        Installation directory (default: /opt/bambuddy)"
+    echo "  --port PORT        Port to listen on (default: 8000)"
+    echo "  --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)"
+    echo "  --tz TIMEZONE      Timezone (default: system timezone or UTC)"
+    echo "  --data-dir PATH    Data directory (default: INSTALL_PATH/data)"
+    echo "  --log-dir PATH     Log directory (default: INSTALL_PATH/logs)"
+    echo "  --debug            Enable debug mode"
+    echo "  --log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)"
+    echo "  --no-service       Skip systemd service setup (Linux only)"
+    echo "  --set-system-tz    Set system timezone to match (for unattended installs)"
+    echo "  --yes, -y          Non-interactive mode, accept defaults"
+    echo "  --help, -h         Show this help message"
+    echo ""
+    echo "Examples:"
+    echo "  Interactive installation:"
+    echo "    ./install.sh"
+    echo ""
+    echo "  Unattended installation with custom settings:"
+    echo "    ./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes"
+    echo ""
+    echo "  Minimal unattended installation:"
+    echo "    ./install.sh -y"
+    exit 0
+}
+
+# -----------------------------------------------------------------------------
+# System Detection
+# -----------------------------------------------------------------------------
+
+detect_os() {
+    if [[ "$OSTYPE" == "darwin"* ]]; then
+        OS_TYPE="macos"
+        PKG_MANAGER="brew"
+        return
+    fi
+
+    if [[ -f /etc/os-release ]]; then
+        . /etc/os-release
+        case "$ID" in
+            ubuntu|debian|raspbian|linuxmint|pop)
+                OS_TYPE="debian"
+                PKG_MANAGER="apt"
+                ;;
+            fedora|rhel|centos|rocky|almalinux|ol)
+                OS_TYPE="rhel"
+                if command -v dnf &>/dev/null; then
+                    PKG_MANAGER="dnf"
+                else
+                    PKG_MANAGER="yum"
+                fi
+                ;;
+            arch|manjaro|endeavouros)
+                OS_TYPE="arch"
+                PKG_MANAGER="pacman"
+                ;;
+            opensuse*|sles)
+                OS_TYPE="suse"
+                PKG_MANAGER="zypper"
+                ;;
+            *)
+                log_error "Unsupported Linux distribution: $ID"
+                exit 1
+                ;;
+        esac
+    else
+        log_error "Cannot detect operating system"
+        exit 1
+    fi
+}
+
+detect_python() {
+    # Try python3 first, then python
+    if command -v python3 &>/dev/null; then
+        PYTHON_CMD="python3"
+    elif command -v python &>/dev/null; then
+        local version
+        version=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1)
+        if [[ "$version" -ge 3 ]]; then
+            PYTHON_CMD="python"
+        fi
+    fi
+
+    if [[ -z "$PYTHON_CMD" ]]; then
+        return 1
+    fi
+
+    # Check version >= 3.10
+    local version
+    version=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
+    local major minor
+    major=$(echo "$version" | cut -d'.' -f1)
+    minor=$(echo "$version" | cut -d'.' -f2)
+
+    if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then
+        log_warn "Python $version found, but 3.10+ is required"
+        return 1
+    fi
+
+    log_success "Found Python $version"
+    return 0
+}
+
+detect_timezone() {
+    if [[ -n "$TIMEZONE" ]]; then
+        return 0
+    fi
+
+    # Try to get system timezone (with error handling for set -e)
+    TIMEZONE=""
+    if [[ -f /etc/timezone ]]; then
+        TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
+        TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
+        TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
+    fi
+
+    # Default to UTC if not found (use if/then to avoid set -e issue with &&)
+    if [[ -z "$TIMEZONE" ]]; then
+        TIMEZONE="UTC"
+    fi
+    return 0
+}
+
+# -----------------------------------------------------------------------------
+# Package Installation
+# -----------------------------------------------------------------------------
+
+install_dependencies() {
+    log_info "Installing system dependencies..."
+
+    case "$PKG_MANAGER" in
+        apt)
+            sudo apt-get update
+            sudo apt-get install -y python3 python3-pip python3-venv git curl ffmpeg
+            ;;
+        dnf|yum)
+            sudo $PKG_MANAGER install -y python3 python3-pip git curl ffmpeg
+            ;;
+        pacman)
+            sudo pacman -Sy --noconfirm python python-pip git curl ffmpeg
+            ;;
+        zypper)
+            sudo zypper install -y python3 python3-pip git curl ffmpeg
+            ;;
+        brew)
+            # Check if Homebrew is installed
+            if ! command -v brew &>/dev/null; then
+                log_error "Homebrew not found. Please install it first: https://brew.sh"
+                exit 1
+            fi
+            brew install python git curl ffmpeg
+            ;;
+    esac
+
+    log_success "System dependencies installed"
+}
+
+# -----------------------------------------------------------------------------
+# Installation Steps
+# -----------------------------------------------------------------------------
+
+create_user() {
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        return  # Skip user creation on macOS
+    fi
+
+    if id "$SERVICE_USER" &>/dev/null; then
+        log_info "User '$SERVICE_USER' already exists"
+        return
+    fi
+
+    log_info "Creating service user '$SERVICE_USER'..."
+    sudo useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$SERVICE_USER"
+    log_success "Service user created"
+}
+
+download_bambuddy() {
+    log_info "Downloading BamBuddy..."
+
+    if [[ -d "$INSTALL_PATH/.git" ]]; then
+        log_info "Existing installation found, updating..."
+        # Add safe.directory to avoid "dubious ownership" error when running as root
+        git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
+        cd "$INSTALL_PATH"
+        git fetch origin
+        git reset --hard origin/main
+        # Ensure correct ownership after update
+        sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
+    else
+        sudo mkdir -p "$INSTALL_PATH"
+        sudo chown "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
+        git clone https://github.com/maziggy/bambuddy.git "$INSTALL_PATH"
+        sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
+    fi
+
+    log_success "BamBuddy downloaded to $INSTALL_PATH"
+}
+
+setup_virtualenv() {
+    log_info "Setting up Python virtual environment..."
+
+    cd "$INSTALL_PATH"
+
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        $PYTHON_CMD -m venv venv
+        source venv/bin/activate
+    else
+        sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv venv 2>/dev/null || $PYTHON_CMD -m venv venv
+        source venv/bin/activate
+    fi
+
+    pip install --upgrade pip
+    pip install -r requirements.txt
+
+    log_success "Virtual environment configured"
+}
+
+check_node_version() {
+    # Returns 0 if Node.js 20+ is available, 1 otherwise
+    if ! command -v node &>/dev/null; then
+        return 1
+    fi
+
+    local version
+    version=$(node --version 2>/dev/null | sed 's/^v//')
+    local major
+    major=$(echo "$version" | cut -d'.' -f1)
+
+    if [[ "$major" -ge 20 ]]; then
+        log_success "Found Node.js v$version"
+        return 0
+    else
+        log_warn "Found Node.js v$version (need 20+)"
+        return 1
+    fi
+}
+
+install_nodejs() {
+    log_info "Installing Node.js 22..."
+    case "$PKG_MANAGER" in
+        apt)
+            # Remove old nodejs if present
+            sudo apt-get remove -y nodejs npm 2>/dev/null || true
+            curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
+            sudo apt-get install -y nodejs
+            ;;
+        dnf|yum)
+            sudo $PKG_MANAGER remove -y nodejs npm 2>/dev/null || true
+            curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
+            sudo $PKG_MANAGER install -y nodejs
+            ;;
+        pacman)
+            sudo pacman -S --noconfirm nodejs npm
+            ;;
+        zypper)
+            sudo zypper install -y nodejs22
+            ;;
+        brew)
+            brew install node@22
+            brew link --overwrite node@22
+            ;;
+        *)
+            log_error "Please install Node.js 20+ manually: https://nodejs.org/"
+            exit 1
+            ;;
+    esac
+    # Refresh PATH
+    hash -r 2>/dev/null || true
+}
+
+build_frontend() {
+    log_info "Building frontend..."
+
+    cd "$INSTALL_PATH/frontend"
+
+    # Check for Node.js 20+
+    if ! check_node_version; then
+        install_nodejs
+        # Verify installation
+        if ! check_node_version; then
+            log_error "Failed to install Node.js 20+. Please install manually."
+            exit 1
+        fi
+    fi
+
+    npm ci
+    npm run build
+
+    log_success "Frontend built"
+}
+
+create_directories() {
+    log_info "Creating data directories..."
+
+    sudo mkdir -p "$DATA_DIR" "$LOG_DIR"
+
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
+    fi
+
+    log_success "Directories created"
+}
+
+create_env_file() {
+    log_info "Creating environment configuration..."
+
+    local env_file="$INSTALL_PATH/.env"
+
+    # Note: Only include settings recognized by the app's pydantic Settings class
+    # Other settings (PORT, BIND_ADDRESS, DATA_DIR, LOG_DIR, TZ) are set in systemd service
+    cat > /tmp/bambuddy.env << EOF
+# BamBuddy Configuration
+# Generated by install.sh on $(date)
+
+# Debug mode (true = verbose logging)
+DEBUG=$DEBUG_MODE
+
+# Log level (only used when DEBUG=false)
+# Options: DEBUG, INFO, WARNING, ERROR
+LOG_LEVEL=$LOG_LEVEL
+
+# Enable file logging
+LOG_TO_FILE=true
+EOF
+
+    sudo mv /tmp/bambuddy.env "$env_file"
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        sudo chown "$SERVICE_USER:$SERVICE_USER" "$env_file"
+    fi
+    sudo chmod 600 "$env_file"
+
+    log_success "Environment file created at $env_file"
+}
+
+create_systemd_service() {
+    if [[ "$OS_TYPE" == "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
+        return
+    fi
+
+    log_info "Creating systemd service..."
+
+    cat > /tmp/bambuddy.service << EOF
+[Unit]
+Description=BamBuddy - Bambu Lab Print Management
+Documentation=https://github.com/maziggy/bambuddy
+After=network.target
+
+[Service]
+Type=simple
+User=$SERVICE_USER
+Group=$SERVICE_USER
+WorkingDirectory=$INSTALL_PATH
+
+# App settings from .env file
+EnvironmentFile=$INSTALL_PATH/.env
+
+# Service settings (not in .env to avoid pydantic validation errors)
+Environment="DATA_DIR=$DATA_DIR"
+Environment="LOG_DIR=$LOG_DIR"
+Environment="TZ=$TIMEZONE"
+
+ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT
+Restart=on-failure
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+
+# Security hardening
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+    sudo mv /tmp/bambuddy.service /etc/systemd/system/bambuddy.service
+    sudo systemctl daemon-reload
+
+    log_success "Systemd service created"
+
+    if prompt_yes_no "Enable BamBuddy to start on boot?" "y"; then
+        sudo systemctl enable bambuddy
+        log_success "Service enabled"
+    fi
+
+    if prompt_yes_no "Start BamBuddy now?" "y"; then
+        sudo systemctl start bambuddy
+        sleep 2
+        if sudo systemctl is-active --quiet bambuddy; then
+            log_success "BamBuddy is running"
+        else
+            log_warn "Service may have failed to start. Check: sudo journalctl -u bambuddy -f"
+        fi
+    fi
+}
+
+create_launchd_service() {
+    if [[ "$OS_TYPE" != "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
+        return
+    fi
+
+    log_info "Creating launchd service..."
+
+    local plist_path="$HOME/Library/LaunchAgents/com.bambuddy.app.plist"
+
+    cat > "$plist_path" << EOF
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>Label</key>
+    <string>com.bambuddy.app</string>
+    <key>ProgramArguments</key>
+    <array>
+        <string>$INSTALL_PATH/venv/bin/uvicorn</string>
+        <string>backend.app.main:app</string>
+        <string>--host</string>
+        <string>$BIND_ADDRESS</string>
+        <string>--port</string>
+        <string>$PORT</string>
+    </array>
+    <key>WorkingDirectory</key>
+    <string>$INSTALL_PATH</string>
+    <key>EnvironmentVariables</key>
+    <dict>
+        <key>DEBUG</key>
+        <string>$DEBUG_MODE</string>
+        <key>LOG_LEVEL</key>
+        <string>$LOG_LEVEL</string>
+        <key>DATA_DIR</key>
+        <string>$DATA_DIR</string>
+        <key>LOG_DIR</key>
+        <string>$LOG_DIR</string>
+        <key>TZ</key>
+        <string>$TIMEZONE</string>
+    </dict>
+    <key>RunAtLoad</key>
+    <true/>
+    <key>KeepAlive</key>
+    <true/>
+    <key>StandardOutPath</key>
+    <string>$LOG_DIR/bambuddy.log</string>
+    <key>StandardErrorPath</key>
+    <string>$LOG_DIR/bambuddy.error.log</string>
+</dict>
+</plist>
+EOF
+
+    log_success "Launchd plist created at $plist_path"
+
+    if prompt_yes_no "Load BamBuddy service now?" "y"; then
+        launchctl load "$plist_path"
+        sleep 2
+        if launchctl list | grep -q "com.bambuddy.app"; then
+            log_success "BamBuddy is running"
+        else
+            log_warn "Service may have failed to start. Check: cat $LOG_DIR/bambuddy.error.log"
+        fi
+    fi
+}
+
+# -----------------------------------------------------------------------------
+# Main Installation Flow
+# -----------------------------------------------------------------------------
+
+parse_args() {
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --path)
+                INSTALL_PATH="$2"
+                shift 2
+                ;;
+            --port)
+                PORT="$2"
+                shift 2
+                ;;
+            --bind)
+                BIND_ADDRESS="$2"
+                shift 2
+                ;;
+            --tz)
+                TIMEZONE="$2"
+                shift 2
+                ;;
+            --data-dir)
+                DATA_DIR="$2"
+                shift 2
+                ;;
+            --log-dir)
+                LOG_DIR="$2"
+                shift 2
+                ;;
+            --debug)
+                DEBUG_MODE="true"
+                shift
+                ;;
+            --log-level)
+                LOG_LEVEL="$2"
+                shift 2
+                ;;
+            --no-service)
+                SKIP_SERVICE="true"
+                shift
+                ;;
+            --set-system-tz)
+                SET_SYSTEM_TZ="true"
+                shift
+                ;;
+            --yes|-y)
+                NON_INTERACTIVE="true"
+                shift
+                ;;
+            --help|-h)
+                show_help
+                ;;
+            *)
+                log_error "Unknown option: $1"
+                show_help
+                ;;
+        esac
+    done
+}
+
+gather_config() {
+    echo ""
+    echo -e "${BOLD}Installation Configuration${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo ""
+
+    # Installation path
+    [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
+
+    # Port
+    [[ -z "$PORT" ]] && prompt "Port to listen on" "$DEFAULT_PORT" PORT
+
+    # Bind address
+    if [[ -z "$BIND_ADDRESS" ]]; then
+        echo ""
+        echo "Network access:"
+        echo "  0.0.0.0   - Accessible from other devices on your network (recommended)"
+        echo "  127.0.0.1 - Only accessible from this machine"
+        prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
+    fi
+
+    # Timezone
+    detect_timezone
+    prompt "Timezone" "$TIMEZONE" TIMEZONE
+
+    # Offer to set system timezone if different from current (skip if already set via --set-system-tz)
+    if [[ -z "$SET_SYSTEM_TZ" ]]; then
+        local current_tz
+        current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
+        if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "$current_tz" ]]; then
+            # Default to "n" so unattended installs don't change system TZ unless --set-system-tz is used
+            if prompt_yes_no "Set system timezone to $TIMEZONE?" "n"; then
+                SET_SYSTEM_TZ="true"
+            else
+                SET_SYSTEM_TZ="false"
+            fi
+        else
+            SET_SYSTEM_TZ="false"
+        fi
+    fi
+
+    # Data directory
+    [[ -z "$DATA_DIR" ]] && DATA_DIR="$INSTALL_PATH/data"
+    prompt "Data directory" "$DATA_DIR" DATA_DIR
+
+    # Log directory
+    [[ -z "$LOG_DIR" ]] && LOG_DIR="$INSTALL_PATH/logs"
+    prompt "Log directory" "$LOG_DIR" LOG_DIR
+
+    # Debug mode
+    if [[ -z "$DEBUG_MODE" ]]; then
+        if prompt_yes_no "Enable debug mode?" "n"; then
+            DEBUG_MODE="true"
+        else
+            DEBUG_MODE="false"
+        fi
+    fi
+
+    # Log level
+    if [[ -z "$LOG_LEVEL" ]]; then
+        echo ""
+        echo "Log levels: DEBUG, INFO, WARNING, ERROR"
+        prompt "Log level" "$DEFAULT_LOG_LEVEL" LOG_LEVEL
+    fi
+
+    # Confirm
+    echo ""
+    echo -e "${BOLD}Installation Summary${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo -e "  Install path:  ${GREEN}$INSTALL_PATH${NC}"
+    echo -e "  Port:          ${GREEN}$PORT${NC}"
+    echo -e "  Bind address:  ${GREEN}$BIND_ADDRESS${NC}"
+    echo -e "  Timezone:      ${GREEN}$TIMEZONE${NC}"
+    echo -e "  Data dir:      ${GREEN}$DATA_DIR${NC}"
+    echo -e "  Log dir:       ${GREEN}$LOG_DIR${NC}"
+    echo -e "  Debug mode:    ${GREEN}$DEBUG_MODE${NC}"
+    echo -e "  Log level:     ${GREEN}$LOG_LEVEL${NC}"
+    echo ""
+
+    if ! prompt_yes_no "Proceed with installation?" "y"; then
+        echo "Installation cancelled."
+        exit 0
+    fi
+}
+
+main() {
+    parse_args "$@"
+    print_banner
+
+    # Check if running via pipe (curl | bash) - interactive mode won't work
+    if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
+        log_error "Interactive mode requires a terminal."
+        log_info "When using 'curl | bash', you must use non-interactive mode:"
+        echo ""
+        echo "    curl -fsSL URL | bash -s -- --yes"
+        echo ""
+        log_info "Or download and run directly:"
+        echo ""
+        echo "    curl -fsSL URL -o install.sh && chmod +x install.sh && ./install.sh"
+        echo ""
+        exit 1
+    fi
+
+    # Check for root (we need sudo for some operations)
+    if [[ "$EUID" -eq 0 ]] && [[ "$OS_TYPE" != "macos" ]]; then
+        log_warn "Running as root. Consider using a regular user with sudo privileges."
+    fi
+
+    # Detect system
+    log_info "Detecting system..."
+    detect_os
+    log_success "Detected: $OS_TYPE (package manager: $PKG_MANAGER)"
+
+    # Check/install Python
+    if ! detect_python; then
+        log_info "Python 3.10+ not found, will install..."
+    fi
+
+    # Gather configuration
+    gather_config
+
+    # Install steps
+    echo ""
+    echo -e "${BOLD}Starting Installation${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo ""
+
+    install_dependencies
+    detect_python || { log_error "Failed to install Python"; exit 1; }
+
+    # Set system timezone if requested
+    if [[ "$SET_SYSTEM_TZ" == "true" ]]; then
+        log_info "Setting system timezone to $TIMEZONE..."
+        if [[ "$OS_TYPE" == "macos" ]]; then
+            sudo systemsetup -settimezone "$TIMEZONE" 2>/dev/null || true
+        else
+            sudo timedatectl set-timezone "$TIMEZONE" 2>/dev/null || true
+        fi
+        log_success "System timezone set to $TIMEZONE"
+    fi
+
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        create_user
+    else
+        SERVICE_USER="$USER"
+    fi
+
+    download_bambuddy
+    setup_virtualenv
+    build_frontend
+    create_directories
+    create_env_file
+
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        create_launchd_service
+    else
+        create_systemd_service
+    fi
+
+    # Done!
+    echo ""
+    echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
+    echo -e "${GREEN}║                                                              ║${NC}"
+    echo -e "${GREEN}║              Installation Complete!                          ║${NC}"
+    echo -e "${GREEN}║                                                              ║${NC}"
+    echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
+    echo ""
+    # Show appropriate URL based on bind address
+    if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
+        local ip_addr
+        ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+        echo -e "                    ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
+    else
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+    fi
+    echo ""
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        echo -e "  ${BOLD}Manage service:${NC}"
+        echo -e "    Start:   launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist"
+        echo -e "    Stop:    launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist"
+        echo -e "    Logs:    tail -f $LOG_DIR/bambuddy.log"
+    else
+        echo -e "  ${BOLD}Manage service:${NC}"
+        echo -e "    Status:  sudo systemctl status bambuddy"
+        echo -e "    Start:   sudo systemctl start bambuddy"
+        echo -e "    Stop:    sudo systemctl stop bambuddy"
+        echo -e "    Logs:    sudo journalctl -u bambuddy -f"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Update BamBuddy:${NC}"
+    echo -e "    cd $INSTALL_PATH && git pull && source venv/bin/activate"
+    echo -e "    pip install -r requirements.txt && cd frontend && npm ci && npm run build"
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        echo -e "    sudo systemctl restart bambuddy"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Documentation:${NC}  ${CYAN}https://wiki.bambuddy.cool${NC}"
+    echo ""
+}
+
+main "$@"

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


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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- 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-Dm1kl7bT.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BQ1e_nWl.css">
+    <script type="module" crossorigin src="/assets/index-BDqcLQLO.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BrclLX7E.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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