diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6f70cd8..894f6dc9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,25 @@ All notable changes to Bambuddy will be documented in this file. - Uses trimesh and matplotlib for 3D rendering with Bambu green color theme - Thumbnails auto-refresh in UI after generation - Graceful handling of complex/invalid STL files +- **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164): + - All-in-one page at `/overlay/:printerId` combining camera feed with status overlay + - Real-time print progress, ETA, layer count, and filename display + - Bambuddy logo branding (links to GitHub) + - Customizable via query parameters: `?size=small|medium|large` and `?show=progress,layers,eta,filename,status,printer` + - No authentication required - designed for OBS browser source embedding + - Gradient overlay at bottom for readable text over camera feed + - Auto-reconnect on camera stream errors +- **MQTT Smart Plug Support** - Add smart plugs that subscribe to MQTT topics for energy monitoring (Issue #173): + - New "MQTT" plug type alongside Tasmota and Home Assistant + - Subscribe to any MQTT topic (Zigbee2MQTT, Shelly, Tasmota discovery, etc.) + - **Separate topics per data type**: Configure different MQTT topics for power, energy, and state + - Configurable JSON paths for data extraction (e.g., `power_l1`, `data.power`) + - **Separate multipliers**: Individual multiplier for power and energy (e.g., mW→W, Wh→kWh) + - **Custom ON value**: Configure what value means "ON" for state (e.g., "ON", "true", "1") + - Monitor-only: displays power/energy data without control capabilities + - Reuses existing MQTT broker settings from Settings → Network + - Energy data included in statistics and per-print tracking + - Full backup/restore support for MQTT plug configurations - **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks: - Prevents Bambuddy from checking Bambu Lab servers for firmware updates - Useful for users who prefer to manage firmware manually or have network restrictions @@ -106,24 +125,6 @@ All notable changes to Bambuddy will be documented in this file. - Bulk edit: printer assignment, print options, queue options - Bulk cancel selected items - Tri-state toggles: unchanged / on / off for each setting -- **Model-Based Queue Assignment** - Queue prints to "any printer of matching model" for load balancing (Issue #162): - - Extract printer model from sliced 3MF files (e.g., "X1C", "P1S") - - Display sliced-for model in archive view - - New queue mode: assign to model instead of specific printer - - Scheduler auto-assigns to first idle printer of matching model - - Filament validation: only assign to printers with required filament types loaded - - Waiting reason display: shows why jobs are waiting (e.g., "Waiting for filament: Printer1 (needs PLA)") - - "Waiting" status badge (purple) distinguishes from regular "Pending" - - Compatibility warnings when file/printer model mismatch -- **Queue Notifications** - Get notified about print queue events: - - Job Added: When a job is added to the queue - - Job Assigned: When a model-based job is assigned to a printer - - Job Started: When a queue job starts printing - - Job Waiting: When a job is waiting for filament (enabled by default) - - Job Skipped: When a job is skipped due to previous failure (enabled by default) - - 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 ### Fixes - **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166): diff --git a/README.md b/README.md index e39468774..2e5c1a24c 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,12 @@ - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support) - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates) - Archive comparison (side-by-side diff) +- Tag management (rename/delete across all archives) ### 📊 Monitoring & Control - Real-time printer status via WebSocket - Live camera streaming (MJPEG) & snapshots with multi-viewer support +- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`) - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment) - Fan status monitoring (part cooling, auxiliary, chamber) @@ -80,7 +82,8 @@ - Per-printer AMS mapping (individual slot configuration for print farms) - Scheduled prints (date/time) - Queue Only mode (stage without auto-start) -- Smart plug integration (Tasmota, Home Assistant) +- Smart plug integration (Tasmota, Home Assistant, MQTT) +- MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring - Energy consumption tracking (per-print kWh and cost) - HA energy sensor support (for plugs with separate power/energy sensors) - Auto power-on before print diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 85241c422..b19855f21 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -515,6 +515,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): if energy_tracking_mode == "total": # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption) from backend.app.models.smart_plug import SmartPlug + from backend.app.services.homeassistant import homeassistant_service + from backend.app.services.mqtt_relay import mqtt_relay from backend.app.services.tasmota import tasmota_service plugs_result = await db.execute(select(SmartPlug)) @@ -522,9 +524,19 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): total_energy_kwh = 0.0 for plug in plugs: - energy = await tasmota_service.get_energy(plug) - if energy and energy.get("total") is not None: - total_energy_kwh += energy["total"] + if plug.plug_type == "tasmota": + energy = await tasmota_service.get_energy(plug) + if energy and energy.get("total") is not None: + total_energy_kwh += energy["total"] + elif plug.plug_type == "homeassistant": + energy = await homeassistant_service.get_energy(plug) + if energy and energy.get("total") is not None: + total_energy_kwh += energy["total"] + elif plug.plug_type == "mqtt": + # MQTT plugs report "today" energy, not lifetime total + mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id) + if mqtt_data and mqtt_data.energy is not None: + total_energy_kwh += mqtt_data.energy total_energy_kwh = round(total_energy_kwh, 3) total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2) @@ -552,6 +564,103 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): ) +@router.get("/tags") +async def get_all_tags(db: AsyncSession = Depends(get_db)): + """List all unique tags with usage counts. + + Returns a list of tags sorted by count (descending), then by name. + """ + # Query all archives with non-null tags + result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None))) + all_tags_rows = result.all() + + # Count occurrences of each tag + tag_counts: dict[str, int] = {} + for (tags_str,) in all_tags_rows: + if tags_str: + for tag in tags_str.split(","): + tag = tag.strip() + if tag: + tag_counts[tag] = tag_counts.get(tag, 0) + 1 + + # Convert to list and sort by count (desc), then name (asc) + tags_list = [{"name": name, "count": count} for name, count in tag_counts.items()] + tags_list.sort(key=lambda x: (-x["count"], x["name"].lower())) + + return tags_list + + +@router.put("/tags/{tag_name}") +async def rename_tag( + tag_name: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Rename a tag across all archives. + + Request body should contain {"new_name": "new tag name"}. + Returns the count of affected archives. + """ + body = await request.json() + new_name = body.get("new_name", "").strip() + + if not new_name: + raise HTTPException(400, "new_name is required") + + if new_name == tag_name: + return {"affected": 0} + + # Find all archives containing the old tag + result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None))) + archives = list(result.scalars().all()) + + affected = 0 + for archive in archives: + if not archive.tags: + continue + tags = [t.strip() for t in archive.tags.split(",")] + if tag_name in tags: + # Replace old tag with new tag + new_tags = [new_name if t == tag_name else t for t in tags] + # Remove duplicates while preserving order + seen = set() + unique_tags = [] + for t in new_tags: + if t not in seen: + seen.add(t) + unique_tags.append(t) + archive.tags = ", ".join(unique_tags) + affected += 1 + + await db.commit() + return {"affected": affected} + + +@router.delete("/tags/{tag_name}") +async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)): + """Delete a tag from all archives. + + Returns the count of affected archives. + """ + # Find all archives containing the tag + result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None))) + archives = list(result.scalars().all()) + + affected = 0 + for archive in archives: + if not archive.tags: + continue + tags = [t.strip() for t in archive.tags.split(",")] + if tag_name in tags: + # Remove the tag + new_tags = [t for t in tags if t != tag_name] + archive.tags = ", ".join(new_tags) if new_tags else None + affected += 1 + + await db.commit() + return {"affected": affected} + + @router.get("/{archive_id}", response_model=ArchiveResponse) async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)): """Get a specific archive.""" diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index db314639b..5b0a3dd45 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -369,6 +369,21 @@ async def export_backup( "ha_power_entity": plug.ha_power_entity, "ha_energy_today_entity": plug.ha_energy_today_entity, "ha_energy_total_entity": plug.ha_energy_total_entity, + # MQTT plug fields (legacy) + "mqtt_topic": plug.mqtt_topic, + "mqtt_multiplier": plug.mqtt_multiplier, + # MQTT power fields + "mqtt_power_topic": plug.mqtt_power_topic, + "mqtt_power_path": plug.mqtt_power_path, + "mqtt_power_multiplier": plug.mqtt_power_multiplier, + # MQTT energy fields + "mqtt_energy_topic": plug.mqtt_energy_topic, + "mqtt_energy_path": plug.mqtt_energy_path, + "mqtt_energy_multiplier": plug.mqtt_energy_multiplier, + # MQTT state fields + "mqtt_state_topic": plug.mqtt_state_topic, + "mqtt_state_path": plug.mqtt_state_path, + "mqtt_state_on_value": plug.mqtt_state_on_value, "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None, "enabled": plug.enabled, "auto_on": plug.auto_on, @@ -385,6 +400,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") @@ -1300,12 +1316,23 @@ async def import_backup( # Determine plug type (default to tasmota for backwards compatibility) plug_type = plug_data.get("plug_type", "tasmota") - # Find existing plug by IP (Tasmota) or entity_id (Home Assistant) + # Find existing plug by IP (Tasmota), entity_id (Home Assistant), or mqtt_topic (MQTT) existing = None + plug_identifier = None if plug_type == "homeassistant" and plug_data.get("ha_entity_id"): result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"])) existing = result.scalar_one_or_none() plug_identifier = plug_data["ha_entity_id"] + elif plug_type == "mqtt" and (plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")): + # Check by mqtt_power_topic first (new format), fall back to mqtt_topic (legacy) + power_topic = plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic") + result = await db.execute( + select(SmartPlug).where( + (SmartPlug.mqtt_power_topic == power_topic) | (SmartPlug.mqtt_topic == power_topic) + ) + ) + existing = result.scalar_one_or_none() + plug_identifier = power_topic elif plug_data.get("ip_address"): result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])) existing = result.scalar_one_or_none() @@ -1322,6 +1349,21 @@ async def import_backup( existing.ha_power_entity = plug_data.get("ha_power_entity") existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity") existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity") + # MQTT fields (legacy) + existing.mqtt_topic = plug_data.get("mqtt_topic") + existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0) + # MQTT power fields + existing.mqtt_power_topic = plug_data.get("mqtt_power_topic") + existing.mqtt_power_path = plug_data.get("mqtt_power_path") + existing.mqtt_power_multiplier = plug_data.get("mqtt_power_multiplier", 1.0) + # MQTT energy fields + existing.mqtt_energy_topic = plug_data.get("mqtt_energy_topic") + existing.mqtt_energy_path = plug_data.get("mqtt_energy_path") + existing.mqtt_energy_multiplier = plug_data.get("mqtt_energy_multiplier", 1.0) + # MQTT state fields + existing.mqtt_state_topic = plug_data.get("mqtt_state_topic") + existing.mqtt_state_path = plug_data.get("mqtt_state_path") + existing.mqtt_state_on_value = plug_data.get("mqtt_state_on_value") existing.printer_id = printer_id existing.enabled = plug_data.get("enabled", True) existing.auto_on = plug_data.get("auto_on", True) @@ -1338,6 +1380,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 @@ -1351,6 +1394,21 @@ async def import_backup( ha_power_entity=plug_data.get("ha_power_entity"), ha_energy_today_entity=plug_data.get("ha_energy_today_entity"), ha_energy_total_entity=plug_data.get("ha_energy_total_entity"), + # MQTT fields (legacy) + mqtt_topic=plug_data.get("mqtt_topic"), + mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0), + # MQTT power fields + mqtt_power_topic=plug_data.get("mqtt_power_topic"), + mqtt_power_path=plug_data.get("mqtt_power_path"), + mqtt_power_multiplier=plug_data.get("mqtt_power_multiplier", 1.0), + # MQTT energy fields + mqtt_energy_topic=plug_data.get("mqtt_energy_topic"), + mqtt_energy_path=plug_data.get("mqtt_energy_path"), + mqtt_energy_multiplier=plug_data.get("mqtt_energy_multiplier", 1.0), + # MQTT state fields + mqtt_state_topic=plug_data.get("mqtt_state_topic"), + mqtt_state_path=plug_data.get("mqtt_state_path"), + mqtt_state_on_value=plug_data.get("mqtt_state_on_value"), printer_id=printer_id, enabled=plug_data.get("enabled", True), auto_on=plug_data.get("auto_on", True), @@ -1367,6 +1425,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..79732a104 100644 --- a/backend/app/api/routes/smart_plugs.py +++ b/backend/app/api/routes/smart_plugs.py @@ -27,6 +27,7 @@ from backend.app.schemas.smart_plug import ( ) from backend.app.services.discovery import tasmota_scanner from backend.app.services.homeassistant import homeassistant_service +from backend.app.services.mqtt_relay import mqtt_relay 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 @@ -56,16 +57,84 @@ 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") + + # For MQTT plugs, ensure MQTT broker is configured and service is connected + if data.plug_type == "mqtt": + # Try to configure the smart plug service if not already configured + if not mqtt_relay.smart_plug_service.is_configured(): + # Get MQTT broker settings from database + mqtt_broker = await get_setting(db, "mqtt_broker") or "" + if not mqtt_broker: + raise HTTPException( + 400, + "MQTT broker not configured. Please set MQTT broker address in Settings → Network → MQTT Publishing.", + ) + + # Configure the smart plug service with broker settings + mqtt_settings = { + "mqtt_enabled": True, # Enable for smart plug subscription + "mqtt_broker": mqtt_broker, + "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"), + "mqtt_username": await get_setting(db, "mqtt_username") or "", + "mqtt_password": await get_setting(db, "mqtt_password") or "", + "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true", + } + await mqtt_relay.smart_plug_service.configure(mqtt_settings) + + # Check if connection succeeded + if not mqtt_relay.smart_plug_service.is_configured(): + raise HTTPException( + 400, + f"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.", + ) plug = SmartPlug(**data.model_dump()) db.add(plug) await db.commit() await db.refresh(plug) - if plug.plug_type == "homeassistant": + # Subscribe MQTT plugs to their topics + if plug.plug_type == "mqtt": + # Determine effective topics (new fields take priority, fall back to legacy) + power_topic = plug.mqtt_power_topic or plug.mqtt_topic + energy_topic = plug.mqtt_energy_topic + state_topic = plug.mqtt_state_topic + + # Only subscribe if at least one topic is configured + if power_topic or energy_topic or state_topic: + mqtt_relay.smart_plug_service.subscribe( + plug_id=plug.id, + # Power source (path is optional) + power_topic=power_topic, + power_path=plug.mqtt_power_path, + power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0, + # Energy source (path is optional) + energy_topic=energy_topic, + energy_path=plug.mqtt_energy_path, + energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0, + # State source (path is optional) + state_topic=state_topic, + state_path=plug.mqtt_state_path, + state_on_value=plug.mqtt_state_on_value, + ) + topics = [t for t in [power_topic, energy_topic, state_topic] if t] + logger.info(f"Created MQTT plug '{plug.name}' subscribed to {', '.join(set(topics))}") + elif plug.plug_type == "homeassistant": logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})") else: logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}") @@ -74,12 +143,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 +392,43 @@ 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") + + # Track old MQTT settings for comparison + old_plug_type = plug.plug_type + old_mqtt_config = { + "power_topic": plug.mqtt_power_topic or plug.mqtt_topic, + "power_path": plug.mqtt_power_path, + "power_multiplier": plug.mqtt_power_multiplier, + "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic, + "energy_path": plug.mqtt_energy_path, + "energy_multiplier": plug.mqtt_energy_multiplier, + "state_topic": plug.mqtt_state_topic or plug.mqtt_topic, + "state_path": plug.mqtt_state_path, + "state_on_value": plug.mqtt_state_on_value, + } for field, value in update_data.items(): setattr(plug, field, value) @@ -302,6 +436,54 @@ async def update_smart_plug( await db.commit() await db.refresh(plug) + # Handle MQTT subscription changes + if old_plug_type == "mqtt" and plug.plug_type != "mqtt": + # Changed away from MQTT - unsubscribe + mqtt_relay.smart_plug_service.unsubscribe(plug.id) + elif plug.plug_type == "mqtt": + # Check if any MQTT config changed + new_mqtt_config = { + "power_topic": plug.mqtt_power_topic or plug.mqtt_topic, + "power_path": plug.mqtt_power_path, + "power_multiplier": plug.mqtt_power_multiplier, + "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic, + "energy_path": plug.mqtt_energy_path, + "energy_multiplier": plug.mqtt_energy_multiplier, + "state_topic": plug.mqtt_state_topic or plug.mqtt_topic, + "state_path": plug.mqtt_state_path, + "state_on_value": plug.mqtt_state_on_value, + } + + mqtt_changed = old_plug_type != "mqtt" or old_mqtt_config != new_mqtt_config + + if mqtt_changed: + # Unsubscribe from old topics first + if old_plug_type == "mqtt": + mqtt_relay.smart_plug_service.unsubscribe(plug.id) + + # Subscribe to new topics + power_topic = plug.mqtt_power_topic or plug.mqtt_topic + energy_topic = plug.mqtt_energy_topic + state_topic = plug.mqtt_state_topic + + # Only subscribe if at least one topic is configured + if power_topic or energy_topic or state_topic: + mqtt_relay.smart_plug_service.subscribe( + plug_id=plug.id, + # Power source (path is optional) + power_topic=power_topic, + power_path=plug.mqtt_power_path, + power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0, + # Energy source (path is optional) + energy_topic=energy_topic, + energy_path=plug.mqtt_energy_path, + energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0, + # State source (path is optional) + state_topic=state_topic, + state_path=plug.mqtt_state_path, + state_on_value=plug.mqtt_state_on_value, + ) + logger.info(f"Updated smart plug '{plug.name}'") return plug @@ -315,6 +497,12 @@ async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)): raise HTTPException(404, "Smart plug not found") plug_name = plug.name + plug_type = plug.plug_type + + # Unsubscribe MQTT plug before deletion + if plug_type == "mqtt": + mqtt_relay.smart_plug_service.unsubscribe(plug_id) + await db.delete(plug) await db.commit() @@ -348,6 +536,13 @@ async def control_smart_plug( if not plug: raise HTTPException(404, "Smart plug not found") + # MQTT plugs are monitor-only - cannot control them + if plug.plug_type == "mqtt": + raise HTTPException( + 400, + "MQTT plugs are monitor-only. Use your MQTT broker or home automation system to control them.", + ) + service = await _get_service_for_plug(plug, db) if control.action == "on": @@ -376,6 +571,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 +603,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.""" @@ -409,6 +642,44 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)): if not plug: raise HTTPException(404, "Smart plug not found") + # Handle MQTT plugs - get data from subscription service + if plug.plug_type == "mqtt": + data = mqtt_relay.smart_plug_service.get_plug_data(plug_id) + is_reachable = mqtt_relay.smart_plug_service.is_reachable(plug_id) + + if data: + # Update last state in database + if is_reachable and data.state: + plug.last_state = data.state + plug.last_checked = datetime.utcnow() + await db.commit() + + energy_data = None + if data.power is not None or data.energy is not None: + energy_data = SmartPlugEnergy( + power=data.power, + today=data.energy, + ) + # Check power alerts + if data.power is not None: + await check_power_alerts(plug, data.power, db) + + return SmartPlugStatus( + state=data.state, + reachable=is_reachable, + device_name=None, + energy=energy_data, + ) + + # No data received yet + return SmartPlugStatus( + state=None, + reachable=False, + device_name=None, + energy=None, + ) + + # Handle Tasmota/HomeAssistant plugs service = await _get_service_for_plug(plug, db) status = await service.get_status(plug) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 9997226c3..e3fca7c70 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -764,45 +764,139 @@ async def run_migrations(conn): except Exception: pass - # Migration: Add sliced_for_model column to print_archives for printer model from 3MF + # 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: - await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)")) + # 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 target_model column to print_queue for model-based assignment + # Migration: Add show_on_printer_card column to smart_plugs try: - await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)")) + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1")) except Exception: pass - # Migration: Add required_filament_types column to print_queue for filament validation + # Migration: Add MQTT smart plug fields (legacy) try: - await conn.execute(text("ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT")) + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0")) except Exception: pass - # Migration: Add waiting_reason column to print_queue for status feedback + # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers) try: - await conn.execute(text("ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT")) + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)")) except Exception: pass - # Migration: Add queue notification event columns to notification_providers - queue_notification_columns = [ - ("on_queue_job_added", "BOOLEAN DEFAULT 0"), - ("on_queue_job_assigned", "BOOLEAN DEFAULT 0"), - ("on_queue_job_started", "BOOLEAN DEFAULT 0"), - ("on_queue_job_waiting", "BOOLEAN DEFAULT 1"), - ("on_queue_job_skipped", "BOOLEAN DEFAULT 1"), - ("on_queue_job_failed", "BOOLEAN DEFAULT 1"), - ("on_queue_completed", "BOOLEAN DEFAULT 0"), - ] - for col_name, col_def in queue_notification_columns: - try: - await conn.execute(text(f"ALTER TABLE notification_providers ADD COLUMN {col_name} {col_def}")) - except Exception: - pass + # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility + try: + await conn.execute( + text(""" + UPDATE smart_plugs + SET mqtt_power_topic = mqtt_topic, + mqtt_power_multiplier = mqtt_multiplier + WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL + """) + ) + except Exception: + pass # Migration: Create groups table for permission-based access control try: diff --git a/backend/app/main.py b/backend/app/main.py index c8601dd93..d4920b859 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -249,9 +249,10 @@ _notified_hms_errors: dict[int, set[str]] = {} async def _get_plug_energy(plug, db) -> dict | None: - """Get energy from plug regardless of type (Tasmota or Home Assistant). + """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT). For HA plugs, configures the service with current settings from DB. + For MQTT plugs, returns data from the subscription service. """ if plug.plug_type == "homeassistant": from backend.app.api.routes.settings import get_setting @@ -260,6 +261,17 @@ async def _get_plug_energy(plug, db) -> dict | None: ha_token = await get_setting(db, "ha_token") or "" homeassistant_service.configure(ha_url, ha_token) return await homeassistant_service.get_energy(plug) + elif plug.plug_type == "mqtt": + # MQTT plugs report "today" energy, not lifetime total + # For per-print tracking, we use "today" as the counter (resets at midnight) + mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id) + if mqtt_data: + return { + "power": mqtt_data.power, + "today": mqtt_data.energy, + "total": mqtt_data.energy, # Use today as total for per-print calculations + } + return None else: return await tasmota_service.get_energy(plug) @@ -2396,6 +2408,27 @@ async def lifespan(app: FastAPI): } await mqtt_relay.configure(mqtt_settings) + # Restore MQTT smart plug subscriptions + if mqtt_settings.get("mqtt_enabled"): + from sqlalchemy import select + + from backend.app.models.smart_plug import SmartPlug + + result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt")) + mqtt_plugs = result.scalars().all() + for plug in mqtt_plugs: + if plug.mqtt_topic: + mqtt_relay.smart_plug_service.subscribe( + plug_id=plug.id, + topic=plug.mqtt_topic, + power_path=plug.mqtt_power_path, + energy_path=plug.mqtt_energy_path, + state_path=plug.mqtt_state_path, + multiplier=plug.mqtt_multiplier or 1.0, + ) + if mqtt_plugs: + logging.info(f"Restored {len(mqtt_plugs)} MQTT smart plug subscriptions") + # Connect to all active printers async with async_session() as db: await init_printer_connections(db) diff --git a/backend/app/models/smart_plug.py b/backend/app/models/smart_plug.py index 2f13ebc38..1d9160ef9 100644 --- a/backend/app/models/smart_plug.py +++ b/backend/app/models/smart_plug.py @@ -7,7 +7,7 @@ from backend.app.core.database import Base class SmartPlug(Base): - """Smart plug for printer power control (Tasmota or Home Assistant).""" + """Smart plug for printer power control (Tasmota, Home Assistant, or MQTT).""" __tablename__ = "smart_plugs" @@ -15,7 +15,7 @@ class SmartPlug(Base): name: Mapped[str] = mapped_column(String(100)) ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) # IPv4/IPv6 (required for Tasmota) - # Plug type: "tasmota" (default) or "homeassistant" + # Plug type: "tasmota" (default), "homeassistant", or "mqtt" plug_type: Mapped[str] = mapped_column(String(20), default="tasmota") # Home Assistant entity ID (e.g., "switch.printer_plug") ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True) @@ -24,6 +24,32 @@ 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 + # MQTT plug fields (required when plug_type="mqtt") + # Legacy field - kept for backward compatibility, now use mqtt_power_topic + mqtt_topic: Mapped[str | None] = mapped_column( + String(200), nullable=True + ) # e.g., "zigbee2mqtt/shelly-working-room" (deprecated, use mqtt_power_topic) + + # Power monitoring + mqtt_power_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for power data + mqtt_power_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "power_l1" or "data.power" + mqtt_power_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Unit conversion for power + + # Energy monitoring + mqtt_energy_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for energy data + mqtt_energy_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "energy_l1" + mqtt_energy_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Unit conversion for energy + + # State monitoring + mqtt_state_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for state data + mqtt_state_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "state_l1" for ON/OFF + mqtt_state_on_value: Mapped[str | None] = mapped_column( + String(50), nullable=True + ) # What value means "ON" (e.g., "ON", "true", "1") + + # Legacy multiplier - kept for backward compatibility + mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Deprecated, use mqtt_power_multiplier + # Link to printer (1:1) printer_id: Mapped[int | None] = mapped_column( ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True @@ -57,6 +83,9 @@ class SmartPlug(Base): # Switchbar visibility show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False) + # Printer card visibility (for scripts) + show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True) + # Status tracking last_state: Mapped[str | None] = mapped_column(String(10), nullable=True) # "ON"/"OFF" last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/backend/app/schemas/smart_plug.py b/backend/app/schemas/smart_plug.py index d60fac854..6f0960cb2 100644 --- a/backend/app/schemas/smart_plug.py +++ b/backend/app/schemas/smart_plug.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, model_validator class SmartPlugBase(BaseModel): name: str = Field(..., min_length=1, max_length=100) - plug_type: Literal["tasmota", "homeassistant"] = "tasmota" + plug_type: Literal["tasmota", "homeassistant", "mqtt"] = "tasmota" # Tasmota fields (required when plug_type="tasmota") ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") @@ -14,12 +14,36 @@ 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_]+$") ha_energy_total_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$") + # MQTT fields (required when plug_type="mqtt") + # Legacy field - kept for backward compatibility + mqtt_topic: str | None = Field(default=None, max_length=200) # Deprecated, use mqtt_power_topic + + # Power monitoring + mqtt_power_topic: str | None = Field(default=None, max_length=200) # Topic for power data + mqtt_power_path: str | None = Field(default=None, max_length=100) # e.g., "power_l1" or "data.power" + mqtt_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion for power + + # Energy monitoring + mqtt_energy_topic: str | None = Field(default=None, max_length=200) # Topic for energy data + mqtt_energy_path: str | None = Field(default=None, max_length=100) # e.g., "energy_l1" + mqtt_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion for energy + + # State monitoring + mqtt_state_topic: str | None = Field(default=None, max_length=200) # Topic for state data + mqtt_state_path: str | None = Field(default=None, max_length=100) # e.g., "state_l1" for ON/OFF + mqtt_state_on_value: str | None = Field( + default=None, max_length=50 + ) # What value means "ON" (e.g., "ON", "true", "1") + + # Legacy multiplier - kept for backward compatibility + mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Deprecated, use mqtt_power_multiplier + printer_id: int | None = None enabled: bool = True auto_on: bool = True @@ -35,8 +59,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": @@ -44,6 +69,17 @@ class SmartPlugBase(BaseModel): raise ValueError("ip_address is required for Tasmota plugs") if self.plug_type == "homeassistant" and not self.ha_entity_id: raise ValueError("ha_entity_id is required for Home Assistant plugs") + if self.plug_type == "mqtt": + # Determine the effective power topic (new field takes priority, fall back to legacy) + power_topic = self.mqtt_power_topic or self.mqtt_topic + # Path is optional - if not set, raw MQTT payload value will be used + has_power = bool(power_topic) + has_energy = bool(self.mqtt_energy_topic) + has_state = bool(self.mqtt_state_topic) + + # At least one data source must be configured (path is optional) + if not has_power and not has_energy and not has_state: + raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring") return self @@ -53,13 +89,28 @@ class SmartPlugCreate(SmartPlugBase): class SmartPlugUpdate(BaseModel): name: str | None = None - plug_type: Literal["tasmota", "homeassistant"] | None = None + plug_type: Literal["tasmota", "homeassistant", "mqtt"] | None = None ip_address: str | None = None ha_entity_id: str | None = None # Home Assistant energy sensor entities (optional) ha_power_entity: str | None = None ha_energy_today_entity: str | None = None ha_energy_total_entity: str | None = None + # MQTT fields (legacy) + mqtt_topic: str | None = None + mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000) + # MQTT power fields + mqtt_power_topic: str | None = None + mqtt_power_path: str | None = None + mqtt_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000) + # MQTT energy fields + mqtt_energy_topic: str | None = None + mqtt_energy_path: str | None = None + mqtt_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000) + # MQTT state fields + mqtt_state_topic: str | None = None + mqtt_state_path: str | None = None + mqtt_state_on_value: str | None = None printer_id: int | None = None enabled: bool | None = None auto_on: bool | None = None @@ -77,8 +128,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 +199,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/mqtt_relay.py b/backend/app/services/mqtt_relay.py index 7bfa18366..f55657125 100644 --- a/backend/app/services/mqtt_relay.py +++ b/backend/app/services/mqtt_relay.py @@ -34,6 +34,8 @@ class MQTTRelayService: self._broker = "" self._port = 1883 self._last_printer_status: dict[int, float] = {} # printer_id -> last publish timestamp + self._smart_plug_service = None # Lazy import to avoid circular dependency + self._settings: dict = {} # Store settings for smart plug service async def configure(self, settings: dict) -> bool: """Configure MQTT connection from settings. @@ -41,9 +43,12 @@ class MQTTRelayService: Returns True if connection was successful or MQTT is disabled. """ self.enabled = settings.get("mqtt_enabled", False) + self._settings = settings # Store for smart plug service if not self.enabled: await self.disconnect() + # Also configure smart plug service (will disable it) + await self._configure_smart_plug_service(settings) logger.info("MQTT relay disabled") return True @@ -67,7 +72,33 @@ class MQTTRelayService: await self.disconnect() # Create and connect client - return await self._connect(broker, port, username, password, use_tls) + result = await self._connect(broker, port, username, password, use_tls) + + # Configure smart plug service with same settings + await self._configure_smart_plug_service(settings) + + return result + + async def _configure_smart_plug_service(self, settings: dict): + """Configure the MQTT smart plug service with the same broker settings.""" + try: + if self._smart_plug_service is None: + from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service + + self._smart_plug_service = mqtt_smart_plug_service + + await self._smart_plug_service.configure(settings) + except Exception as e: + logger.error(f"Failed to configure MQTT smart plug service: {e}") + + @property + def smart_plug_service(self): + """Get the MQTT smart plug service instance.""" + if self._smart_plug_service is None: + from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service + + self._smart_plug_service = mqtt_smart_plug_service + return self._smart_plug_service async def _connect(self, broker: str, port: int, username: str, password: str, use_tls: bool) -> bool: """Establish MQTT connection.""" diff --git a/backend/app/services/mqtt_smart_plug.py b/backend/app/services/mqtt_smart_plug.py new file mode 100644 index 000000000..2ed44ce33 --- /dev/null +++ b/backend/app/services/mqtt_smart_plug.py @@ -0,0 +1,488 @@ +"""MQTT Smart Plug Service for subscribing to external MQTT topics and extracting power/energy data. + +This service enables integration with Shelly, Zigbee2MQTT, and other MQTT-based energy monitoring devices. +""" + +import json +import logging +import threading +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any + +import paho.mqtt.client as mqtt + +logger = logging.getLogger(__name__) + + +@dataclass +class SmartPlugMQTTData: + """Latest data received from an MQTT smart plug.""" + + plug_id: int + power: float | None = None # Current power in watts + energy: float | None = None # Energy in kWh (today) + state: str | None = None # "ON" or "OFF" + last_seen: datetime = field(default_factory=datetime.utcnow) + + +@dataclass +class MQTTDataSourceConfig: + """Configuration for a single MQTT data source (power, energy, or state).""" + + topic: str + path: str + multiplier: float = 1.0 # For power/energy + on_value: str | None = None # For state (what value means "ON") + + +class MQTTSmartPlugService: + """Subscribes to MQTT topics for smart plug energy monitoring.""" + + # Consider plug unreachable if no message received in this time + REACHABLE_TIMEOUT_MINUTES = 5 + + def __init__(self): + self.client: mqtt.Client | None = None + self.connected = False + self._lock = threading.Lock() + # topic -> list of (plug_id, data_type) where data_type is "power", "energy", or "state" + self.subscriptions: dict[str, list[tuple[int, str]]] = {} + # plug_id -> {data_type: MQTTDataSourceConfig} + self.plug_configs: dict[int, dict[str, MQTTDataSourceConfig]] = {} + # plug_id -> latest data + self.plug_data: dict[int, SmartPlugMQTTData] = {} + self._configured = False + self._broker = "" + self._port = 1883 + self._username = "" + self._password = "" + self._use_tls = False + + def is_configured(self) -> bool: + """Check if the MQTT service is configured and connected.""" + return self._configured and self.connected + + def has_broker_settings(self) -> bool: + """Check if broker settings are available (even if not connected yet).""" + return bool(self._broker) + + async def configure(self, settings: dict) -> bool: + """Configure MQTT connection from settings. + + Uses the same broker settings as the MQTT relay service. + Returns True if connection was successful or MQTT is disabled. + """ + enabled = settings.get("mqtt_enabled", False) + + if not enabled: + await self.disconnect() + self._configured = False + logger.debug("MQTT smart plug service disabled (MQTT relay not enabled)") + return True + + broker = settings.get("mqtt_broker", "") + port = settings.get("mqtt_port", 1883) + username = settings.get("mqtt_username", "") + password = settings.get("mqtt_password", "") + use_tls = settings.get("mqtt_use_tls", False) + + if not broker: + logger.warning("MQTT smart plug service: no broker configured") + self._configured = False + return False + + # Check if settings changed + settings_changed = ( + self._broker != broker + or self._port != port + or self._username != username + or self._password != password + or self._use_tls != use_tls + ) + + self._broker = broker + self._port = port + self._username = username + self._password = password + self._use_tls = use_tls + self._configured = True + + # Disconnect and reconnect if settings changed + if settings_changed and self.client: + await self.disconnect() + + # Connect if not already connected + if not self.client or not self.connected: + return await self._connect() + + return True + + async def _connect(self) -> bool: + """Establish MQTT connection.""" + import asyncio + import ssl + + try: + # Create client with callback API version 2 + self.client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=f"bambuddy-smartplug-{id(self)}", + protocol=mqtt.MQTTv311, + ) + + # Set up callbacks + self.client.on_connect = self._on_connect + self.client.on_disconnect = self._on_disconnect + self.client.on_message = self._on_message + + # Configure authentication + if self._username: + self.client.username_pw_set(self._username, self._password) + + # Configure TLS + if self._use_tls: + self.client.tls_set(cert_reqs=ssl.CERT_NONE) + self.client.tls_insecure_set(True) + + # Connect with timeout + try: + await asyncio.wait_for( + asyncio.to_thread(self.client.connect_async, self._broker, self._port, 60), + timeout=3.0, + ) + except TimeoutError: + logger.warning(f"MQTT smart plug connection to {self._broker}:{self._port} timed out") + return False + + self.client.loop_start() + + # Wait briefly for connection + await asyncio.sleep(1.0) + + if self.connected: + logger.info(f"MQTT smart plug service connected to {self._broker}:{self._port}") + # Resubscribe to all topics + self._resubscribe_all() + return True + else: + logger.warning(f"MQTT smart plug connection pending to {self._broker}:{self._port}") + return True # Connection is async + + except Exception as e: + logger.error(f"MQTT smart plug connection failed: {e}") + self.connected = False + return False + + def _on_connect( + self, + client: mqtt.Client, + userdata: Any, + flags: dict, + reason_code: int | mqtt.ReasonCode, + properties: mqtt.Properties | None = None, + ): + """Callback when connected to broker.""" + rc = reason_code if isinstance(reason_code, int) else reason_code.value + if rc == 0: + self.connected = True + logger.info("MQTT smart plug service connected successfully") + # Resubscribe to all topics + self._resubscribe_all() + else: + self.connected = False + logger.error(f"MQTT smart plug connection failed: {reason_code}") + + def _on_disconnect( + self, + client: mqtt.Client, + userdata: Any, + flags_or_rc: dict | int | mqtt.ReasonCode, + reason_code: int | mqtt.ReasonCode | None = None, + properties: mqtt.Properties | None = None, + ): + """Callback when disconnected from broker.""" + self.connected = False + rc = reason_code if reason_code is not None else flags_or_rc + rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0) + if rc_val != 0: + logger.warning(f"MQTT smart plug service disconnected: {rc}") + else: + logger.info("MQTT smart plug service disconnected cleanly") + + def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage): + """Handle incoming MQTT message, extract data using JSON path.""" + topic = msg.topic + + with self._lock: + subscriptions = self.subscriptions.get(topic, []) + if not subscriptions: + return + + # Parse JSON payload (or treat as raw value) + try: + payload = json.loads(msg.payload.decode("utf-8")) + is_json = True + except (json.JSONDecodeError, UnicodeDecodeError): + # Not JSON - treat the whole payload as a raw value + payload = msg.payload.decode("utf-8").strip() + is_json = False + + # Process for each subscribed (plug_id, data_type) + for plug_id, data_type in subscriptions: + configs = self.plug_configs.get(plug_id, {}) + config = configs.get(data_type) + if not config: + continue + + # Extract value using path (or use raw payload if no path) + if is_json and config.path: + raw_value = self._extract_json_path(payload, config.path) + elif is_json and not config.path: + # JSON but no path - if it's a simple value use it, otherwise skip + if isinstance(payload, (int, float, str, bool)): + raw_value = payload + else: + # Can't use a dict/list as a value + logger.debug(f"MQTT plug {plug_id}: JSON payload is object/array but no path configured") + continue + else: + # Raw value (non-JSON) + raw_value = payload + + if raw_value is None: + continue + + # Initialize plug data if needed + if plug_id not in self.plug_data: + self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id) + + data = self.plug_data[plug_id] + data.last_seen = datetime.utcnow() + + # Process based on data type + if data_type == "power": + try: + data.power = float(raw_value) * config.multiplier + logger.debug(f"MQTT smart plug {plug_id}: power={data.power}") + except (ValueError, TypeError): + pass + + elif data_type == "energy": + try: + data.energy = float(raw_value) * config.multiplier + logger.debug(f"MQTT smart plug {plug_id}: energy={data.energy}") + except (ValueError, TypeError): + pass + + elif data_type == "state": + state_str = str(raw_value) + # Check against configured ON value if set + if config.on_value: + # Case-insensitive comparison + if state_str.lower() == config.on_value.lower(): + data.state = "ON" + else: + data.state = "OFF" + else: + # Default behavior: normalize common values + upper_state = state_str.upper() + if upper_state in ("ON", "1", "TRUE"): + data.state = "ON" + elif upper_state in ("OFF", "0", "FALSE"): + data.state = "OFF" + else: + data.state = state_str + logger.debug(f"MQTT smart plug {plug_id}: state={data.state}") + + def _extract_json_path(self, data: dict, path: str) -> Any: + """Extract value using dot notation (e.g., 'power_l1' or 'data.power'). + + Supports simple dot notation for nested objects. + """ + if not path: + return None + + parts = path.split(".") + current = data + + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + + return current + + def _resubscribe_all(self): + """Resubscribe to all registered topics after reconnection.""" + if not self.client or not self.connected: + return + + with self._lock: + for topic in self.subscriptions: + if self.subscriptions[topic]: # Only if there are subscribers + try: + self.client.subscribe(topic, qos=1) + logger.debug(f"MQTT smart plug: resubscribed to {topic}") + except Exception as e: + logger.error(f"MQTT smart plug: failed to resubscribe to {topic}: {e}") + + def subscribe( + self, + plug_id: int, + # Power source + power_topic: str | None = None, + power_path: str | None = None, + power_multiplier: float = 1.0, + # Energy source + energy_topic: str | None = None, + energy_path: str | None = None, + energy_multiplier: float = 1.0, + # State source + state_topic: str | None = None, + state_path: str | None = None, + state_on_value: str | None = None, + # Legacy: single topic/path/multiplier (for backward compatibility) + topic: str | None = None, + multiplier: float = 1.0, + ): + """Subscribe to MQTT topics for a plug. + + Each data type (power, energy, state) can have its own topic. + For backward compatibility, if power_topic is not set but topic is, + topic will be used for all data types that have paths configured. + """ + with self._lock: + # Initialize config for this plug + self.plug_configs[plug_id] = {} + + # Determine topics (new fields take priority, fall back to legacy) + effective_power_topic = power_topic or topic + effective_energy_topic = energy_topic or topic + effective_state_topic = state_topic or topic + + # Use new multipliers or fall back to legacy + effective_power_mult = power_multiplier if power_multiplier != 1.0 else multiplier + effective_energy_mult = energy_multiplier if energy_multiplier != 1.0 else multiplier + + # Configure power subscription (path is optional - empty means use raw payload) + if effective_power_topic: + config = MQTTDataSourceConfig( + topic=effective_power_topic, + path=power_path or "", + multiplier=effective_power_mult, + ) + self.plug_configs[plug_id]["power"] = config + self._add_subscription(plug_id, effective_power_topic, "power") + + # Configure energy subscription (path is optional - empty means use raw payload) + if effective_energy_topic: + config = MQTTDataSourceConfig( + topic=effective_energy_topic, + path=energy_path or "", + multiplier=effective_energy_mult, + ) + self.plug_configs[plug_id]["energy"] = config + self._add_subscription(plug_id, effective_energy_topic, "energy") + + # Configure state subscription (path is optional - empty means use raw payload) + if effective_state_topic: + config = MQTTDataSourceConfig( + topic=effective_state_topic, + path=state_path or "", + on_value=state_on_value, + ) + self.plug_configs[plug_id]["state"] = config + self._add_subscription(plug_id, effective_state_topic, "state") + + # Initialize data entry + if plug_id not in self.plug_data: + self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id) + + logger.info( + f"MQTT smart plug {plug_id}: configured with " + f"power={effective_power_topic if power_path else None}, " + f"energy={effective_energy_topic if energy_path else None}, " + f"state={effective_state_topic if state_path else None}" + ) + + def _add_subscription(self, plug_id: int, topic: str, data_type: str): + """Add a subscription for a plug/data_type to a topic.""" + if topic not in self.subscriptions: + self.subscriptions[topic] = [] + # Actually subscribe if connected + if self.client and self.connected: + try: + self.client.subscribe(topic, qos=1) + logger.info(f"MQTT smart plug: subscribed to {topic}") + except Exception as e: + logger.error(f"MQTT smart plug: failed to subscribe to {topic}: {e}") + + entry = (plug_id, data_type) + if entry not in self.subscriptions[topic]: + self.subscriptions[topic].append(entry) + + def unsubscribe(self, plug_id: int): + """Unsubscribe when plug is deleted/updated.""" + with self._lock: + # Get all configs for this plug + configs = self.plug_configs.pop(plug_id, {}) + if not configs: + # Still clean up any stray subscriptions + pass + + # Collect all topics this plug was subscribed to + topics_to_check = set() + for _data_type, config in configs.items(): + topics_to_check.add(config.topic) + + # Also scan subscriptions to remove any entries for this plug + for topic in list(self.subscriptions.keys()): + # Remove all entries for this plug_id + self.subscriptions[topic] = [(pid, dtype) for pid, dtype in self.subscriptions[topic] if pid != plug_id] + topics_to_check.add(topic) + + # Unsubscribe from topics with no more subscribers + for topic in topics_to_check: + if topic in self.subscriptions and not self.subscriptions[topic]: + del self.subscriptions[topic] + if self.client and self.connected: + try: + self.client.unsubscribe(topic) + logger.info(f"MQTT smart plug: unsubscribed from {topic}") + except Exception as e: + logger.error(f"MQTT smart plug: failed to unsubscribe from {topic}: {e}") + + # Remove data + self.plug_data.pop(plug_id, None) + + def get_plug_data(self, plug_id: int) -> SmartPlugMQTTData | None: + """Get latest data for a plug (called by status endpoint).""" + with self._lock: + return self.plug_data.get(plug_id) + + def is_reachable(self, plug_id: int) -> bool: + """Check if a plug has received data recently.""" + data = self.get_plug_data(plug_id) + if not data: + return False + + timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES) + return datetime.utcnow() - data.last_seen < timeout + + async def disconnect(self): + """Disconnect from MQTT broker.""" + if self.client: + try: + self.client.loop_stop() + self.client.disconnect() + except Exception as e: + logger.debug(f"MQTT smart plug disconnect error (ignored): {e}") + finally: + self.client = None + self.connected = False + + +# Global instance +mqtt_smart_plug_service = MQTTSmartPlugService() 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/conftest.py b/backend/tests/conftest.py index aca80a62c..e56ee875b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -222,6 +222,24 @@ def mock_mqtt_client(): yield mock +@pytest.fixture +def mock_mqtt_smart_plug_service(): + """Mock the MQTT smart plug service for MQTT plug tests.""" + with patch("backend.app.api.routes.smart_plugs.mqtt_relay") as mock: + # Create a mock smart_plug_service + mock_service = MagicMock() + mock_service.is_configured = MagicMock(return_value=True) + mock_service.has_broker_settings = MagicMock(return_value=True) + mock_service.configure = AsyncMock(return_value=True) + mock_service.subscribe = MagicMock() + mock_service.unsubscribe = MagicMock() + mock_service.get_plug_data = MagicMock(return_value=None) + mock_service.is_reachable = MagicMock(return_value=False) + + mock.smart_plug_service = mock_service + yield mock + + @pytest.fixture def mock_ftp_client(): """Mock the FTP client for file transfer tests.""" @@ -302,6 +320,22 @@ def smart_plug_factory(db_session): if plug_type == "homeassistant": defaults["ha_entity_id"] = "switch.test" defaults["ip_address"] = None + elif plug_type == "mqtt": + # Legacy fields (for backward compatibility tests) + defaults["mqtt_topic"] = kwargs.get("mqtt_topic", "test/topic") + defaults["mqtt_multiplier"] = kwargs.get("mqtt_multiplier", 1.0) + # New separate topic/path/multiplier fields + defaults["mqtt_power_topic"] = kwargs.get("mqtt_power_topic") + defaults["mqtt_power_path"] = kwargs.get("mqtt_power_path", "power") + defaults["mqtt_power_multiplier"] = kwargs.get("mqtt_power_multiplier", 1.0) + defaults["mqtt_energy_topic"] = kwargs.get("mqtt_energy_topic") + defaults["mqtt_energy_path"] = kwargs.get("mqtt_energy_path") + defaults["mqtt_energy_multiplier"] = kwargs.get("mqtt_energy_multiplier", 1.0) + defaults["mqtt_state_topic"] = kwargs.get("mqtt_state_topic") + defaults["mqtt_state_path"] = kwargs.get("mqtt_state_path") + defaults["mqtt_state_on_value"] = kwargs.get("mqtt_state_on_value") + defaults["ip_address"] = None + defaults["ha_entity_id"] = None else: defaults["ip_address"] = "192.168.1.100" defaults["ha_entity_id"] = None diff --git a/backend/tests/integration/test_archives_api.py b/backend/tests/integration/test_archives_api.py index 6d8510f17..59c8a30d4 100644 --- a/backend/tests/integration/test_archives_api.py +++ b/backend/tests/integration/test_archives_api.py @@ -434,3 +434,127 @@ class TestArchiveF3DEndpoints: """Verify filament-requirements with plate_id returns 404 for non-existent archive.""" response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1") assert response.status_code == 404 + + # ======================================================================== + # Tag Management endpoints (Issue #183) + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_tags_empty(self, async_client: AsyncClient): + """Verify empty list when no tags exist.""" + response = await async_client.get("/api/v1/archives/tags") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): + """Verify tags are returned with counts.""" + printer = await printer_factory() + await archive_factory(printer.id, print_name="Archive 1", tags="functional, test") + await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration") + await archive_factory(printer.id, print_name="Archive 3", tags="test") + + response = await async_client.get("/api/v1/archives/tags") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + # Convert to dict for easier lookup + tags_dict = {t["name"]: t["count"] for t in data} + assert tags_dict.get("functional") == 2 + assert tags_dict.get("test") == 2 + assert tags_dict.get("calibration") == 1 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_tags_sorted_by_count( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """Verify tags are sorted by count descending, then by name.""" + printer = await printer_factory() + await archive_factory(printer.id, tags="alpha") + await archive_factory(printer.id, tags="beta, alpha") + await archive_factory(printer.id, tags="gamma, beta, alpha") + + response = await async_client.get("/api/v1/archives/tags") + assert response.status_code == 200 + data = response.json() + + # alpha=3, beta=2, gamma=1 + assert data[0]["name"] == "alpha" + assert data[0]["count"] == 3 + assert data[1]["name"] == "beta" + assert data[1]["count"] == 2 + assert data[2]["name"] == "gamma" + assert data[2]["count"] == 1 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): + """Verify renaming a tag updates all archives.""" + printer = await printer_factory() + a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other") + a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag") + await archive_factory(printer.id, print_name="Archive 3", tags="different") + + response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"}) + assert response.status_code == 200 + data = response.json() + assert data["affected"] == 2 + + # Verify the archives were updated + response = await async_client.get(f"/api/v1/archives/{a1.id}") + assert "new-tag" in response.json()["tags"] + assert "old-tag" not in response.json()["tags"] + + response = await async_client.get(f"/api/v1/archives/{a2.id}") + assert response.json()["tags"] == "new-tag" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rename_tag_no_change(self, async_client: AsyncClient): + """Verify renaming to same name returns 0 affected.""" + response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"}) + assert response.status_code == 200 + assert response.json()["affected"] == 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rename_tag_empty_name_error(self, async_client: AsyncClient): + """Verify renaming to empty name returns error.""" + response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""}) + assert response.status_code == 400 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): + """Verify deleting a tag removes it from all archives.""" + printer = await printer_factory() + a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep") + a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me") + await archive_factory(printer.id, print_name="Archive 3", tags="different") + + response = await async_client.delete("/api/v1/archives/tags/delete-me") + assert response.status_code == 200 + data = response.json() + assert data["affected"] == 2 + + # Verify the archives were updated + response = await async_client.get(f"/api/v1/archives/{a1.id}") + assert response.json()["tags"] == "keep" + + response = await async_client.get(f"/api/v1/archives/{a2.id}") + # Should be None or empty when last tag is removed + assert response.json()["tags"] is None or response.json()["tags"] == "" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_tag_not_found(self, async_client: AsyncClient): + """Verify deleting non-existent tag returns 0 affected.""" + response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag") + assert response.status_code == 200 + assert response.json()["affected"] == 0 diff --git a/backend/tests/integration/test_smart_plugs_api.py b/backend/tests/integration/test_smart_plugs_api.py index e6ad7b573..c5e917e10 100644 --- a/backend/tests/integration/test_smart_plugs_api.py +++ b/backend/tests/integration/test_smart_plugs_api.py @@ -573,3 +573,255 @@ class TestSmartPlugsAPI: assert response.status_code == 400 assert "not configured" in response.json()["detail"].lower() + + # ======================================================================== + # MQTT Integration tests + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can be created with topic and JSON paths.""" + data = { + "name": "MQTT Energy Monitor", + "plug_type": "mqtt", + "mqtt_topic": "zigbee2mqtt/shelly-working-room", + "mqtt_power_path": "power_l1", + "mqtt_energy_path": "energy_l1", + "mqtt_state_path": "state_l1", + "mqtt_multiplier": 1.0, + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["name"] == "MQTT Energy Monitor" + assert result["plug_type"] == "mqtt" + assert result["mqtt_topic"] == "zigbee2mqtt/shelly-working-room" + assert result["mqtt_power_path"] == "power_l1" + assert result["mqtt_energy_path"] == "energy_l1" + assert result["mqtt_state_path"] == "state_l1" + assert result["mqtt_multiplier"] == 1.0 + assert result["ip_address"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient): + """Verify creating MQTT plug without topic fails.""" + data = { + "name": "MQTT Plug", + "plug_type": "mqtt", + # Missing mqtt_topic + "mqtt_power_path": "power", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 422 # Validation error + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient): + """Verify creating MQTT plug without any topic fails.""" + data = { + "name": "MQTT Plug", + "plug_type": "mqtt", + # No topic configured at all + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 422 # Validation error + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_with_multiplier(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can use multiplier for unit conversion.""" + data = { + "name": "MQTT mW to W", + "plug_type": "mqtt", + "mqtt_topic": "sensors/power", + "mqtt_power_path": "power_mw", + "mqtt_multiplier": 0.001, # Convert mW to W + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_multiplier"] == 0.001 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_control_mqtt_plug_returns_error(self, async_client: AsyncClient, smart_plug_factory, db_session): + """Verify MQTT plugs cannot be controlled (monitor-only).""" + plug = await smart_plug_factory( + plug_type="mqtt", + mqtt_topic="test/topic", + mqtt_power_path="power", + ) + + response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"}) + + assert response.status_code == 400 + assert "monitor-only" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_mqtt_plug_topic(self, async_client: AsyncClient, smart_plug_factory, db_session): + """Verify MQTT plug topic can be updated.""" + plug = await smart_plug_factory( + plug_type="mqtt", + mqtt_topic="old/topic", + mqtt_power_path="power", + ) + + response = await async_client.patch( + f"/api/v1/smart-plugs/{plug.id}", + json={ + "mqtt_topic": "new/topic", + "mqtt_power_path": "new_power", + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_topic"] == "new/topic" + assert result["mqtt_power_path"] == "new_power" + + # ======================================================================== + # Enhanced MQTT Integration tests (separate topics per data type) + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_with_separate_topics(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can be created with separate topics for power, energy, and state.""" + data = { + "name": "MQTT Separate Topics", + "plug_type": "mqtt", + "mqtt_power_topic": "zigbee/power", + "mqtt_power_path": "power_l1", + "mqtt_power_multiplier": 0.001, + "mqtt_energy_topic": "zigbee/energy", + "mqtt_energy_path": "energy_total", + "mqtt_energy_multiplier": 1.0, + "mqtt_state_topic": "zigbee/state", + "mqtt_state_path": "state", + "mqtt_state_on_value": "ON", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["name"] == "MQTT Separate Topics" + assert result["plug_type"] == "mqtt" + # Power fields + assert result["mqtt_power_topic"] == "zigbee/power" + assert result["mqtt_power_path"] == "power_l1" + assert result["mqtt_power_multiplier"] == 0.001 + # Energy fields + assert result["mqtt_energy_topic"] == "zigbee/energy" + assert result["mqtt_energy_path"] == "energy_total" + assert result["mqtt_energy_multiplier"] == 1.0 + # State fields + assert result["mqtt_state_topic"] == "zigbee/state" + assert result["mqtt_state_path"] == "state" + assert result["mqtt_state_on_value"] == "ON" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_energy_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can be created with only energy monitoring.""" + data = { + "name": "Energy Only Monitor", + "plug_type": "mqtt", + "mqtt_energy_topic": "sensors/energy", + "mqtt_energy_path": "kwh", + "mqtt_energy_multiplier": 0.001, # Wh to kWh + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_energy_topic"] == "sensors/energy" + assert result["mqtt_energy_path"] == "kwh" + assert result["mqtt_energy_multiplier"] == 0.001 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_state_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can be created with only state monitoring.""" + data = { + "name": "State Only Monitor", + "plug_type": "mqtt", + "mqtt_state_topic": "switches/outlet", + "mqtt_state_path": "state", + "mqtt_state_on_value": "true", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_state_topic"] == "switches/outlet" + assert result["mqtt_state_path"] == "state" + assert result["mqtt_state_on_value"] == "true" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_topic_only_succeeds(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify creating MQTT plug with topic only (no path) succeeds for raw values.""" + data = { + "name": "Raw MQTT Plug", + "plug_type": "mqtt", + # Topic only, no path - valid for raw numeric MQTT values + "mqtt_power_topic": "zigbee/power", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 # Should succeed + result = response.json() + assert result["mqtt_power_topic"] == "zigbee/power" + assert result["mqtt_power_path"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_mqtt_plug_separate_multipliers( + self, async_client: AsyncClient, smart_plug_factory, db_session, mock_mqtt_smart_plug_service + ): + """Verify MQTT plug multipliers can be updated separately.""" + plug = await smart_plug_factory( + plug_type="mqtt", + mqtt_power_topic="test/power", + mqtt_power_path="power", + mqtt_power_multiplier=1.0, + mqtt_energy_topic="test/energy", + mqtt_energy_path="energy", + mqtt_energy_multiplier=1.0, + ) + + response = await async_client.patch( + f"/api/v1/smart-plugs/{plug.id}", + json={ + "mqtt_power_multiplier": 0.001, # Change power multiplier only + "mqtt_energy_multiplier": 0.001, # Change energy multiplier only + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_power_multiplier"] == 0.001 + assert result["mqtt_energy_multiplier"] == 0.001 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=
Monitor Only
++ MQTT plugs receive power/energy data via MQTT subscription. On/off control is not available - use your MQTT broker or home automation system. +
+Power Monitoring
+
+ JSON path extracts value from JSON payload (e.g., "power_l1"). Leave empty if topic publishes raw numeric values.
+ Use multiplier 0.001 for mW→W, 1000 for kW→W.
+
Energy Monitoring (optional)
+
+ JSON path extracts value from JSON payload. Leave empty for raw values.
+ Use multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh.
+
State Monitoring (optional)
+
+ JSON path extracts value from JSON payload. Leave empty for raw values.
+ ON value: the exact string that means "ON". Leave empty for auto-detect (ON, true, 1).
+
- Linking enables automatic on/off when prints start/complete -
-+ Linking enables automatic on/off when prints start/complete +
+- Automatically turn the plug on/off at these times daily. Leave empty to skip that action. -
++ 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} +
+ {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
{Math.round(status.energy.power)}W
+Power
+{status.energy.today.toFixed(2)}
+kWh Today
+Enabled
-Enable automation for this plug
-Enabled
+Enable automation for this plug
+Auto On
-Turn on when print starts
-Auto On
+Turn on when print starts
+Auto Off
-Turn off when print completes (one-shot)
+ {/* Auto Off */} +Auto Off
+Turn off when print completes (one-shot)
- 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.
Invalid printer ID
+Loading...
+
+
+
+ {/* Status overlay - bottom */}
+ yn?(In=qt,qt=null):In=qt.sibling;var Xn=at(Ze,qt,nt[yn],yt);if(Xn===null){qt===null&&(qt=In);break}h&&qt&&Xn.alternate===null&&p(Ze,qt),Ve=L(Xn,Ve,yn),Kn===null?Yt=Xn:Kn.sibling=Xn,Kn=Xn,qt=In}if(yn===nt.length)return _(Ze,qt),Bn&&sc(Ze,yn),Yt;if(qt===null){for(;yn