diff --git a/CHANGELOG.md b/CHANGELOG.md index 4719b3b42..596ed3146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Bambuddy will be documented in this file. ## [0.1.6-final] - Not released ### New Features +- **Home Assistant Script Support** - Control multiple devices together using HA scripts (Issue #176): + - Add HA script entities (e.g., `script.turn_on_printer_setup`) as smart plugs + - Trigger scripts to control printer + enclosure fan + other devices simultaneously + - Scripts show "Run" button instead of On/Off (scripts execute once when triggered) + - Script automation: Run scripts automatically when main printer plug turns on/off + - Show/hide scripts on printer cards (configurable per script) + - Scripts appear in dedicated row on printer cards with quick-run buttons - **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156): - Checkbox option when uploading STL files to generate thumbnails automatically - 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) - Queue Complete: When all queued jobs finish - 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 - **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 - **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 +- **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 - Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index b687d3ff9..67b4cd89e 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -383,6 +383,7 @@ async def export_backup( "schedule_on_time": plug.schedule_on_time, "schedule_off_time": plug.schedule_off_time, "show_in_switchbar": plug.show_in_switchbar, + "show_on_printer_card": plug.show_on_printer_card, } ) backup["included"].append("smart_plugs") @@ -1316,6 +1317,7 @@ async def import_backup( existing.schedule_on_time = plug_data.get("schedule_on_time") existing.schedule_off_time = plug_data.get("schedule_off_time") existing.show_in_switchbar = plug_data.get("show_in_switchbar", False) + existing.show_on_printer_card = plug_data.get("show_on_printer_card", True) restored["smart_plugs"] += 1 else: skipped["smart_plugs"] += 1 @@ -1345,6 +1347,7 @@ async def import_backup( schedule_on_time=plug_data.get("schedule_on_time"), schedule_off_time=plug_data.get("schedule_off_time"), show_in_switchbar=plug_data.get("show_in_switchbar", False), + show_on_printer_card=plug_data.get("show_on_printer_card", True), ) db.add(plug) restored["smart_plugs"] += 1 diff --git a/backend/app/api/routes/smart_plugs.py b/backend/app/api/routes/smart_plugs.py index 1a73338cf..29aec241f 100644 --- a/backend/app/api/routes/smart_plugs.py +++ b/backend/app/api/routes/smart_plugs.py @@ -56,9 +56,21 @@ async def create_smart_plug( raise HTTPException(400, "Printer not found") # Check if printer already has a plug assigned - result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id)) - if result.scalar_one_or_none(): - raise HTTPException(400, "This printer already has a smart plug assigned") + # Scripts can coexist with other plugs (they're for multi-device control, not power on/off) + is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.") + if not is_script: + # For non-script plugs, check there's no other non-script plug assigned + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id)) + existing = result.scalar_one_or_none() + if existing: + # Allow if existing plug is a script + existing_is_script = ( + existing.plug_type == "homeassistant" + and existing.ha_entity_id + and existing.ha_entity_id.startswith("script.") + ) + if not existing_is_script: + raise HTTPException(400, "This printer already has a smart plug assigned") plug = SmartPlug(**data.model_dump()) db.add(plug) @@ -74,12 +86,48 @@ async def create_smart_plug( @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None) async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)): - """Get the smart plug assigned to a printer.""" + """Get the main smart plug assigned to a printer. + + When multiple plugs are assigned (e.g., a regular plug + script), + returns the main (non-script) plug for power control. + """ result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) - plug = result.scalar_one_or_none() - if not plug: + plugs = result.scalars().all() + + if not plugs: return None - return plug + + # If multiple plugs, prefer the non-script one (main power plug) + for plug in plugs: + is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.") + if not is_script: + return plug + + # All are scripts, return the first one + return plugs[0] + + +@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse]) +async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)): + """Get all HA script plugs assigned to a printer. + + Returns only script entities (script.*) for the printer that have + show_on_printer_card enabled. + Used to display "Run Script" buttons alongside the main power plug. + """ + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) + plugs = result.scalars().all() + + # Filter to only scripts with show_on_printer_card enabled + scripts = [ + plug + for plug in plugs + if plug.plug_type == "homeassistant" + and plug.ha_entity_id + and plug.ha_entity_id.startswith("script.") + and plug.show_on_printer_card + ] + return scripts # Tasmota Discovery Endpoints @@ -287,14 +335,29 @@ async def update_smart_plug( raise HTTPException(400, "Printer not found") # Check if that printer already has a different plug assigned - result = await db.execute( - select(SmartPlug).where( - SmartPlug.printer_id == new_printer_id, - SmartPlug.id != plug_id, + # Scripts can coexist with other plugs + # Determine if the plug being updated is/will be a script + new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id) + new_plug_type = update_data.get("plug_type", plug.plug_type) + is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.") + + if not is_script: + result = await db.execute( + select(SmartPlug).where( + SmartPlug.printer_id == new_printer_id, + SmartPlug.id != plug_id, + ) ) - ) - if result.scalar_one_or_none(): - raise HTTPException(400, "This printer already has a smart plug assigned") + existing = result.scalar_one_or_none() + if existing: + # Allow if existing plug is a script + existing_is_script = ( + existing.plug_type == "homeassistant" + and existing.ha_entity_id + and existing.ha_entity_id.startswith("script.") + ) + if not existing_is_script: + raise HTTPException(400, "This printer already has a smart plug assigned") for field, value in update_data.items(): setattr(plug, field, value) @@ -376,6 +439,13 @@ async def control_smart_plug( plug.last_checked = datetime.utcnow() await db.commit() + # Trigger associated scripts if this is a main (non-script) plug + is_main_plug = not ( + plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.") + ) + if is_main_plug and plug.printer_id and expected_state: + await trigger_associated_scripts(plug.printer_id, expected_state, db) + # MQTT relay - publish smart plug state change if expected_state: try: @@ -401,6 +471,37 @@ async def control_smart_plug( return {"success": True, "action": control.action} +async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession): + """Trigger scripts linked to a printer based on main plug state change. + + When the main plug turns ON, triggers scripts with auto_on=True. + When the main plug turns OFF, triggers scripts with auto_off=True. + """ + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) + plugs = result.scalars().all() + + # Find scripts that should be triggered + for plug in plugs: + is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.") + if not is_script: + continue + + should_trigger = False + if plug_state == "ON" and plug.auto_on: + should_trigger = True + logger.info(f"Auto-triggering script '{plug.name}' on printer power-on") + elif plug_state == "OFF" and plug.auto_off: + should_trigger = True + logger.info(f"Auto-triggering script '{plug.name}' on printer power-off") + + if should_trigger: + try: + service = await _get_service_for_plug(plug, db) + await service.turn_on(plug) # Scripts are triggered by calling turn_on + except Exception as e: + logger.error(f"Failed to trigger script '{plug.name}': {e}") + + @router.get("/{plug_id}/status", response_model=SmartPlugStatus) async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)): """Get current plug status from device including energy data.""" diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 5b3175ff4..0bd28ed08 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -760,6 +760,79 @@ async def run_migrations(conn): except Exception: pass + # Migration: Remove UNIQUE constraint from smart_plugs.printer_id + # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control) + # SQLite requires table recreation to drop constraints + try: + # Check if we need to migrate (if UNIQUE constraint exists) + result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'")) + row = result.fetchone() + if row and "printer_id INTEGER UNIQUE" in (row[0] or ""): + # Create new table without UNIQUE constraint on printer_id + await conn.execute( + text(""" + CREATE TABLE smart_plugs_temp ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) NOT NULL, + ip_address VARCHAR(45), + plug_type VARCHAR(20) DEFAULT 'tasmota', + ha_entity_id VARCHAR(100), + ha_power_entity VARCHAR(100), + ha_energy_today_entity VARCHAR(100), + ha_energy_total_entity VARCHAR(100), + printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL, + enabled BOOLEAN NOT NULL DEFAULT 1, + auto_on BOOLEAN NOT NULL DEFAULT 1, + auto_off BOOLEAN NOT NULL DEFAULT 1, + off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time', + off_delay_minutes INTEGER NOT NULL DEFAULT 5, + off_temp_threshold INTEGER NOT NULL DEFAULT 70, + username VARCHAR(50), + password VARCHAR(100), + power_alert_enabled BOOLEAN NOT NULL DEFAULT 0, + power_alert_high FLOAT, + power_alert_low FLOAT, + power_alert_last_triggered DATETIME, + schedule_enabled BOOLEAN NOT NULL DEFAULT 0, + schedule_on_time VARCHAR(5), + schedule_off_time VARCHAR(5), + show_in_switchbar BOOLEAN DEFAULT 0, + last_state VARCHAR(10), + last_checked DATETIME, + auto_off_executed BOOLEAN NOT NULL DEFAULT 0, + auto_off_pending BOOLEAN DEFAULT 0, + auto_off_pending_since DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """) + ) + # Copy data + await conn.execute( + text(""" + INSERT INTO smart_plugs_temp + SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity, + ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled, + auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold, + username, password, power_alert_enabled, power_alert_high, power_alert_low, + power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time, + show_in_switchbar, last_state, last_checked, auto_off_executed, + auto_off_pending, auto_off_pending_since, created_at, updated_at + FROM smart_plugs + """) + ) + # Drop old table and rename new one + await conn.execute(text("DROP TABLE smart_plugs")) + await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs")) + except Exception: + pass + + # Migration: Add show_on_printer_card column to smart_plugs + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1")) + except Exception: + pass + # Migration: Add sliced_for_model column to print_archives for printer model from 3MF try: await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)")) diff --git a/backend/app/models/smart_plug.py b/backend/app/models/smart_plug.py index 2f13ebc38..8f0ff1650 100644 --- a/backend/app/models/smart_plug.py +++ b/backend/app/models/smart_plug.py @@ -24,10 +24,8 @@ class SmartPlug(Base): ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_today ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_total - # Link to printer (1:1) - printer_id: Mapped[int | None] = mapped_column( - ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True - ) + # Link to printer (scripts can coexist with regular plugs for multi-device control) + printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True) # Automation settings enabled: Mapped[bool] = mapped_column(Boolean, default=True) @@ -54,8 +52,9 @@ class SmartPlug(Base): schedule_on_time: Mapped[str | None] = mapped_column(String(5), nullable=True) # "HH:MM" format schedule_off_time: Mapped[str | None] = mapped_column(String(5), nullable=True) # "HH:MM" format - # Switchbar visibility + # Visibility options show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False) + show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True) # For scripts: show on printer card # Status tracking last_state: Mapped[str | None] = mapped_column(String(10), nullable=True) # "ON"/"OFF" diff --git a/backend/app/schemas/smart_plug.py b/backend/app/schemas/smart_plug.py index d60fac854..ca9816228 100644 --- a/backend/app/schemas/smart_plug.py +++ b/backend/app/schemas/smart_plug.py @@ -14,7 +14,7 @@ class SmartPlugBase(BaseModel): password: str | None = None # Home Assistant fields (required when plug_type="homeassistant") - ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean)\.[a-z0-9_]+$") + ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean|script)\.[a-z0-9_]+$") # Home Assistant energy sensor entities (optional, for separate energy sensors) ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$") ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$") @@ -35,8 +35,9 @@ class SmartPlugBase(BaseModel): schedule_enabled: bool = False schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format - # Switchbar visibility + # Visibility options show_in_switchbar: bool = False + show_on_printer_card: bool = True # For scripts: show on printer card @model_validator(mode="after") def validate_plug_type_fields(self) -> "SmartPlugBase": @@ -77,8 +78,9 @@ class SmartPlugUpdate(BaseModel): schedule_enabled: bool | None = None schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") - # Switchbar visibility + # Visibility options show_in_switchbar: bool | None = None + show_on_printer_card: bool | None = None class SmartPlugResponse(SmartPlugBase): @@ -147,7 +149,7 @@ class HAEntity(BaseModel): entity_id: str friendly_name: str state: str | None = None - domain: str # "switch", "light", "input_boolean" + domain: str # "switch", "light", "input_boolean", "script" class HASensorEntity(BaseModel): diff --git a/backend/app/services/homeassistant.py b/backend/app/services/homeassistant.py index 38fca794b..45ba70920 100644 --- a/backend/app/services/homeassistant.py +++ b/backend/app/services/homeassistant.py @@ -228,7 +228,7 @@ class HomeAssistantService: - domain: str """ # Default domains for smart plug control - default_domains = {"switch", "light", "input_boolean"} + default_domains = {"switch", "light", "input_boolean", "script"} try: async with httpx.AsyncClient(timeout=self.timeout) as client: diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 81ab1e520..d96d09883 100644 --- a/backend/app/services/print_scheduler.py +++ b/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.notification_service import notification_service 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 logger = logging.getLogger(__name__) @@ -348,15 +348,18 @@ class PrintScheduler: 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 - status = await tasmota_service.get_status(plug) + status = await service.get_status(plug) if not status.get("reachable"): logger.warning(f"Smart plug '{plug.name}' is not reachable") return False # Turn on if not already on if status.get("state") != "ON": - success = await tasmota_service.turn_on(plug) + success = await service.turn_on(plug) if not success: logger.warning(f"Failed to turn on smart plug '{plug.name}'") return False @@ -425,7 +428,8 @@ class PrintScheduler: # Wait for cooldown (up to 10 minutes) 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}") - 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: """Get a human-readable name for a queue item.""" diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py index 39366d7da..e788068a9 100644 --- a/backend/app/services/smart_plug_manager.py +++ b/backend/app/services/smart_plug_manager.py @@ -27,7 +27,7 @@ class SmartPlugManager: self._scheduler_task: asyncio.Task | None = None 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. For HA plugs, configures the service with current settings from DB. @@ -110,7 +110,7 @@ class SmartPlugManager: plugs = result.scalars().all() 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 if plug.schedule_on_time == current_time: @@ -166,7 +166,7 @@ class SmartPlugManager: # Turn on the plug 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) if success: @@ -195,6 +195,11 @@ class SmartPlugManager: logger.debug(f"Smart plug '{plug.name}' auto_off is disabled") return + # Skip auto-off for HA script entities (scripts can only be triggered, not turned off) + if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."): + logger.debug(f"Smart plug '{plug.name}' is a HA script entity, skipping auto-off") + return + # Only auto-off on successful completion, not on failures # This allows the user to investigate errors before power-off if status != "completed": @@ -261,7 +266,7 @@ class SmartPlugManager: self.name = f"plug_{plug_id}" 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) logger.info(f"Turned off plug {plug_id} after time delay") @@ -353,7 +358,7 @@ class SmartPlugManager: self.name = f"plug_{plug_id}" 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) logger.info( 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 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) if success: await self._mark_auto_off_executed(plug.id) diff --git a/backend/tests/integration/test_smart_plugs_api.py b/backend/tests/integration/test_smart_plugs_api.py index e6ad7b573..8c47c2b5f 100644 --- a/backend/tests/integration/test_smart_plugs_api.py +++ b/backend/tests/integration/test_smart_plugs_api.py @@ -573,3 +573,200 @@ class TestSmartPlugsAPI: assert response.status_code == 400 assert "not configured" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_homeassistant_script_plug(self, async_client: AsyncClient): + """Verify Home Assistant script entity can be created as a plug. + + Scripts allow users to trigger HA automations that control multiple devices + (e.g., turn on printer + fan together). Scripts can only be triggered (turn_on), + not turned off. + """ + data = { + "name": "Turn On Printer Setup", + "plug_type": "homeassistant", + "ha_entity_id": "script.turn_on_printer_and_fan", + "enabled": True, + "auto_on": True, + "auto_off": False, # Scripts don't support auto_off + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["name"] == "Turn On Printer Setup" + assert result["plug_type"] == "homeassistant" + assert result["ha_entity_id"] == "script.turn_on_printer_and_fan" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_control_homeassistant_script( + self, async_client: AsyncClient, smart_plug_factory, mock_homeassistant_service, db_session + ): + """Verify HA script entity can be triggered via control endpoint.""" + plug = await smart_plug_factory(plug_type="homeassistant", ha_entity_id="script.turn_on_printer") + + # Scripts use "on" action to trigger + response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"}) + + assert response.status_code == 200 + result = response.json() + assert result["success"] is True + assert result["action"] == "on" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_invalid_ha_entity_domain(self, async_client: AsyncClient): + """Verify invalid HA entity domains are rejected.""" + data = { + "name": "Invalid Entity", + "plug_type": "homeassistant", + "ha_entity_id": "sensor.some_sensor", # sensor domain not allowed + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 422 # Validation error + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_script_can_coexist_with_regular_plug( + self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session + ): + """Verify HA scripts can be assigned to printers that already have a regular plug. + + Scripts are for multi-device control (e.g., turn on printer + fan together), + so they should coexist with the main power plug. + """ + # Create a printer + printer = await printer_factory(name="Test Printer") + + # Create a regular Tasmota plug assigned to the printer + main_plug = await smart_plug_factory( + name="Main Power Plug", + plug_type="tasmota", + ip_address="192.168.1.100", + printer_id=printer.id, + ) + assert main_plug.printer_id == printer.id + + # Now try to create a script also assigned to the same printer + script_data = { + "name": "Turn On Everything", + "plug_type": "homeassistant", + "ha_entity_id": "script.turn_on_printer_setup", + "printer_id": printer.id, + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=script_data) + + # Should succeed - scripts can coexist with regular plugs + assert response.status_code == 200 + result = response.json() + assert result["printer_id"] == printer.id + assert result["ha_entity_id"] == "script.turn_on_printer_setup" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_regular_plug_blocked_when_another_exists( + self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session + ): + """Verify regular plugs cannot be assigned if printer already has one.""" + # Create a printer + printer = await printer_factory(name="Test Printer") + + # Create a regular plug assigned to the printer + await smart_plug_factory( + name="Main Power Plug", + plug_type="tasmota", + ip_address="192.168.1.100", + printer_id=printer.id, + ) + + # Try to create another regular plug for the same printer + another_plug = { + "name": "Second Plug", + "plug_type": "homeassistant", + "ha_entity_id": "switch.another_plug", + "printer_id": printer.id, + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=another_plug) + + # Should fail - only one regular plug per printer + assert response.status_code == 400 + assert "already has a smart plug" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_scripts_by_printer_filters_by_show_on_printer_card( + self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session + ): + """Verify scripts endpoint only returns scripts with show_on_printer_card=True.""" + printer = await printer_factory(name="Test Printer") + + # Create a script with show_on_printer_card=True (default) + visible_script = await smart_plug_factory( + name="Visible Script", + plug_type="homeassistant", + ha_entity_id="script.visible_script", + printer_id=printer.id, + show_on_printer_card=True, + ) + + # Create a script with show_on_printer_card=False + await smart_plug_factory( + name="Hidden Script", + plug_type="homeassistant", + ha_entity_id="script.hidden_script", + printer_id=printer.id, + show_on_printer_card=False, + ) + + response = await async_client.get(f"/api/v1/smart-plugs/by-printer/{printer.id}/scripts") + + assert response.status_code == 200 + scripts = response.json() + # Should only return the visible script + assert len(scripts) == 1 + assert scripts[0]["id"] == visible_script.id + assert scripts[0]["name"] == "Visible Script" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_script_auto_on_auto_off_fields( + self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session + ): + """Verify scripts can have auto_on and auto_off set for automation triggers.""" + printer = await printer_factory(name="Test Printer") + + # Create a script with custom auto_on/auto_off settings + script_data = { + "name": "Fan Control Script", + "plug_type": "homeassistant", + "ha_entity_id": "script.fan_control", + "printer_id": printer.id, + "auto_on": True, + "auto_off": False, + "show_on_printer_card": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=script_data) + + assert response.status_code == 200 + result = response.json() + assert result["auto_on"] is True + assert result["auto_off"] is False + assert result["show_on_printer_card"] is True + + # Update the script's auto_off setting + update_response = await async_client.patch(f"/api/v1/smart-plugs/{result['id']}", json={"auto_off": True}) + + assert update_response.status_code == 200 + updated = update_response.json() + assert updated["auto_off"] is True diff --git a/deploy/bambuddy.service b/deploy/bambuddy.service index b096b3d29..93c779466 100644 --- a/deploy/bambuddy.service +++ b/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] -Description=BamBuddy Print Archive +Description=BamBuddy - Bambu Lab Print Management +Documentation=https://github.com/maziggy/bambuddy After=network.target [Service] Type=simple -User=claude -Group=claude -WorkingDirectory=
{debouncedSearch ? `Searching all entities (${availableEntities.length} found)` - : `Showing switch, light, input_boolean (${availableEntities.length} available)`} + : `Showing switch, light, input_boolean, script (${availableEntities.length} available)`}
); })()} - {/* Energy Monitoring Section (Optional) */} - {haEntityId && haSensorEntities && haSensorEntities.length > 0 && ( + {/* Energy Monitoring Section (Optional) - hidden for scripts */} + {haEntityId && !isScript && haSensorEntities && haSensorEntities.length > 0 && (Energy Monitoring (Optional)
@@ -975,110 +998,183 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { ))}- 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'}
Execute script when main plug is switched on
+Execute script when main plug is switched off
++ Get notified when power consumption crosses these thresholds. Leave empty to disable that direction. +
++ Automatically turn the plug on/off at these times daily. Leave empty to skip that action. +
+Display script button on printer card
- Get notified when power consumption crosses these thresholds. Leave empty to disable that direction. -
+- Automatically turn the plug on/off at these times daily. Leave empty to skip that action. -
-+
{plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
Show on Printer Card
+Display script button on printer card
+Auto On
-Turn on when print starts
+{isScript ? 'Run when printer turns on' : 'Auto On'}
++ {isScript ? 'Execute script when main plug is switched on' : 'Turn on when print starts'} +
Auto Off
-Turn off when print completes (one-shot)
+{isScript ? 'Run when printer turns off' : 'Auto Off'}
++ {isScript ? 'Execute script when main plug is switched off' : 'Turn off when print completes (one-shot)'} +
Turn Off Delay Mode
@@ -401,12 +482,14 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { /> )} - {/* Power On Confirmation */} + {/* Power On / Run Script Confirmation */} {showPowerOnConfirm && ({plug.name}
+{plug.name}
+ {isScript && ( + Script + )} +