From 00e3478a3ebc03f0780c2dd913f0d47e5156597b Mon Sep 17 00:00:00 2001 From: maziggy Date: Fri, 30 Jan 2026 08:31:12 +0100 Subject: [PATCH] Add MQTT smart plug support for energy monitoring (Issue #173) Add support for MQTT-based smart plugs that subscribe to external MQTT topics and extract power/energy data from JSON payloads. This enables integration with Zigbee2MQTT, Shelly, Tasmota discovery, and other MQTT-enabled energy monitoring devices. Features: - New "mqtt" plug type alongside tasmota and homeassistant - Subscribe to any MQTT topic with configurable JSON paths - Extract power, energy, and state values using dot notation - Optional multiplier for unit conversion (mW to W, etc.) - Monitor-only mode (no on/off control) with teal color scheme - Reuses existing MQTT broker settings from network configuration - Energy data included in statistics and per-print tracking - Full backup/restore support for MQTT plug configurations Closes #173 --- CHANGELOG.md | 9 + backend/app/api/routes/archives.py | 18 +- backend/app/api/routes/settings.py | 25 +- backend/app/api/routes/smart_plugs.py | 120 ++- backend/app/core/database.py | 22 + backend/app/main.py | 35 +- backend/app/models/smart_plug.py | 13 +- backend/app/schemas/smart_plug.py | 22 +- backend/app/services/mqtt_relay.py | 33 +- backend/app/services/mqtt_smart_plug.py | 401 ++++++++ backend/tests/conftest.py | 24 + .../tests/integration/test_smart_plugs_api.py | 121 +++ .../components/SmartPlugCard.test.tsx | 76 ++ frontend/src/api/client.ts | 24 +- frontend/src/components/AddSmartPlugModal.tsx | 265 ++++-- frontend/src/components/SmartPlugCard.tsx | 201 ++-- frontend/src/components/SwitchbarPopover.tsx | 90 +- frontend/src/pages/SettingsPage.tsx | 16 +- .../{index-CKmo1TxA.js => index-CmjSB2jQ.js} | 856 +++++++++--------- static/assets/index-Cs7zD_Fu.css | 1 + static/assets/index-DFuUL8IF.css | 1 - static/index.html | 4 +- 22 files changed, 1756 insertions(+), 621 deletions(-) create mode 100644 backend/app/services/mqtt_smart_plug.py rename static/assets/{index-CKmo1TxA.js => index-CmjSB2jQ.js} (63%) create mode 100644 static/assets/index-Cs7zD_Fu.css delete mode 100644 static/assets/index-DFuUL8IF.css diff --git a/CHANGELOG.md b/CHANGELOG.md index e6cb5e142..338c0697a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to Bambuddy will be documented in this file. ## [0.1.6-final] - Not released ### New Features +- **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.) + - Configurable JSON paths for power, energy, and state extraction (e.g., `power_l1`, `data.power`) + - Optional multiplier for unit conversion (mW to W, etc.) + - 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 diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 8aff35dcb..6c06387c5 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -514,6 +514,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)) @@ -521,9 +523,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) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index de1e235e6..798b343b8 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -360,6 +360,12 @@ 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 + "mqtt_topic": plug.mqtt_topic, + "mqtt_power_path": plug.mqtt_power_path, + "mqtt_energy_path": plug.mqtt_energy_path, + "mqtt_state_path": plug.mqtt_state_path, + "mqtt_multiplier": plug.mqtt_multiplier, "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None, "enabled": plug.enabled, "auto_on": plug.auto_on, @@ -1257,12 +1263,17 @@ 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_topic"): + result = await db.execute(select(SmartPlug).where(SmartPlug.mqtt_topic == plug_data["mqtt_topic"])) + existing = result.scalar_one_or_none() + plug_identifier = plug_data["mqtt_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() @@ -1279,6 +1290,12 @@ 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 + existing.mqtt_topic = plug_data.get("mqtt_topic") + existing.mqtt_power_path = plug_data.get("mqtt_power_path") + existing.mqtt_energy_path = plug_data.get("mqtt_energy_path") + existing.mqtt_state_path = plug_data.get("mqtt_state_path") + existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0) existing.printer_id = printer_id existing.enabled = plug_data.get("enabled", True) existing.auto_on = plug_data.get("auto_on", True) @@ -1308,6 +1325,12 @@ 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 + mqtt_topic=plug_data.get("mqtt_topic"), + mqtt_power_path=plug_data.get("mqtt_power_path"), + mqtt_energy_path=plug_data.get("mqtt_energy_path"), + mqtt_state_path=plug_data.get("mqtt_state_path"), + mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0), printer_id=printer_id, enabled=plug_data.get("enabled", True), auto_on=plug_data.get("auto_on", True), diff --git a/backend/app/api/routes/smart_plugs.py b/backend/app/api/routes/smart_plugs.py index 1a73338cf..b782e126c 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 @@ -60,12 +61,53 @@ async def create_smart_plug( if result.scalar_one_or_none(): 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 topic + if plug.plug_type == "mqtt" and 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, + ) + logger.info(f"Created MQTT plug '{plug.name}' subscribed to {plug.mqtt_topic}") + 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}") @@ -296,12 +338,37 @@ async def update_smart_plug( if result.scalar_one_or_none(): raise HTTPException(400, "This printer already has a smart plug assigned") + # Check if MQTT topic is changing - need to resubscribe + old_topic = plug.mqtt_topic + old_plug_type = plug.plug_type + for field, value in update_data.items(): setattr(plug, field, value) 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": + # Is now MQTT - check if topic changed or newly MQTT + if old_plug_type != "mqtt" or old_topic != plug.mqtt_topic: + # Unsubscribe from old topic first + if old_plug_type == "mqtt": + mqtt_relay.smart_plug_service.unsubscribe(plug.id) + # Subscribe to new topic + 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, + ) + logger.info(f"Updated smart plug '{plug.name}'") return plug @@ -315,6 +382,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 +421,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": @@ -409,6 +489,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 4b2506d76..683347868 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -760,6 +760,28 @@ async def run_migrations(conn): except Exception: pass + # Migration: Add MQTT smart plug fields + try: + 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 + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" diff --git a/backend/app/main.py b/backend/app/main.py index 40e49a34b..6d0e00d70 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -248,9 +248,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 @@ -259,6 +260,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) @@ -2367,6 +2379,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..99d63ed87 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,15 @@ 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") + mqtt_topic: Mapped[str | None] = mapped_column( + String(200), nullable=True + ) # e.g., "zigbee2mqtt/shelly-working-room" + mqtt_power_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "power_l1" or "data.power" + mqtt_energy_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "energy_l1" + mqtt_state_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "state_l1" for ON/OFF + mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Unit conversion (e.g., 0.001 for mW→W) + # Link to printer (1:1) printer_id: Mapped[int | None] = mapped_column( ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True diff --git a/backend/app/schemas/smart_plug.py b/backend/app/schemas/smart_plug.py index d60fac854..ed4d2be5c 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}$") @@ -20,6 +20,13 @@ class SmartPlugBase(BaseModel): 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") + mqtt_topic: str | None = Field(default=None, max_length=200) + mqtt_power_path: str | None = Field(default=None, max_length=100) # e.g., "power_l1" or "data.power" + mqtt_energy_path: str | None = Field(default=None, max_length=100) # e.g., "energy_l1" + mqtt_state_path: str | None = Field(default=None, max_length=100) # e.g., "state_l1" for ON/OFF + mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion (e.g., 0.001 for mW→W) + printer_id: int | None = None enabled: bool = True auto_on: bool = True @@ -44,6 +51,11 @@ 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": + if not self.mqtt_topic: + raise ValueError("mqtt_topic is required for MQTT plugs") + if not self.mqtt_power_path and not self.mqtt_state_path: + raise ValueError("At least mqtt_power_path or mqtt_state_path is required for MQTT plugs") return self @@ -53,13 +65,19 @@ 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 + mqtt_topic: str | None = None + mqtt_power_path: str | None = None + mqtt_energy_path: str | None = None + mqtt_state_path: str | None = None + mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000) printer_id: int | None = None enabled: bool | None = None auto_on: bool | None = None 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..35e913153 --- /dev/null +++ b/backend/app/services/mqtt_smart_plug.py @@ -0,0 +1,401 @@ +"""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) + + +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_ids (multiple plugs can subscribe to same topic with different paths) + self.subscriptions: dict[str, list[int]] = {} + # plug_id -> (topic, power_path, energy_path, state_path, multiplier) + self.plug_configs: dict[int, tuple[str, str | None, str | None, str | None, float]] = {} + # 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: + plug_ids = self.subscriptions.get(topic, []) + if not plug_ids: + return + + # Parse JSON payload + try: + payload = json.loads(msg.payload.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + logger.debug(f"MQTT smart plug: failed to parse message on {topic}: {e}") + return + + # Process for each subscribed plug + for plug_id in plug_ids: + config = self.plug_configs.get(plug_id) + if not config: + continue + + _, power_path, energy_path, state_path, multiplier = config + + # Extract values + power = None + energy = None + state = None + + if power_path: + raw_power = self._extract_json_path(payload, power_path) + if raw_power is not None: + try: + power = float(raw_power) * multiplier + except (ValueError, TypeError): + pass + + if energy_path: + raw_energy = self._extract_json_path(payload, energy_path) + if raw_energy is not None: + try: + energy = float(raw_energy) * multiplier + except (ValueError, TypeError): + pass + + if state_path: + raw_state = self._extract_json_path(payload, state_path) + if raw_state is not None: + # Normalize state to ON/OFF + state_str = str(raw_state).upper() + if state_str in ("ON", "1", "TRUE"): + state = "ON" + elif state_str in ("OFF", "0", "FALSE"): + state = "OFF" + else: + state = state_str + + # Update plug data + if plug_id in self.plug_data: + data = self.plug_data[plug_id] + if power is not None: + data.power = power + if energy is not None: + data.energy = energy + if state is not None: + data.state = state + data.last_seen = datetime.utcnow() + else: + self.plug_data[plug_id] = SmartPlugMQTTData( + plug_id=plug_id, + power=power, + energy=energy, + state=state, + last_seen=datetime.utcnow(), + ) + + logger.debug(f"MQTT smart plug {plug_id}: power={power}, energy={energy}, state={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: + 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, + topic: str, + power_path: str | None = None, + energy_path: str | None = None, + state_path: str | None = None, + multiplier: float = 1.0, + ): + """Subscribe to a topic for a plug.""" + with self._lock: + # Store configuration + self.plug_configs[plug_id] = (topic, power_path, energy_path, state_path, multiplier) + + # Add to subscriptions + 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 {plug_id}: subscribed to {topic}") + except Exception as e: + logger.error(f"MQTT smart plug: failed to subscribe to {topic}: {e}") + + if plug_id not in self.subscriptions[topic]: + self.subscriptions[topic].append(plug_id) + + # Initialize data entry + if plug_id not in self.plug_data: + self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id) + + def unsubscribe(self, plug_id: int): + """Unsubscribe when plug is deleted/updated.""" + with self._lock: + # Get the topic for this plug + config = self.plug_configs.pop(plug_id, None) + if not config: + return + + topic = config[0] + + # Remove from subscriptions + if topic in self.subscriptions: + if plug_id in self.subscriptions[topic]: + self.subscriptions[topic].remove(plug_id) + + # If no more plugs on this topic, unsubscribe + if 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/tests/conftest.py b/backend/tests/conftest.py index 0cd7046cc..69b520fd1 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -216,6 +216,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.""" @@ -296,6 +314,12 @@ def smart_plug_factory(db_session): if plug_type == "homeassistant": defaults["ha_entity_id"] = "switch.test" defaults["ip_address"] = None + elif plug_type == "mqtt": + defaults["mqtt_topic"] = kwargs.get("mqtt_topic", "test/topic") + defaults["mqtt_power_path"] = kwargs.get("mqtt_power_path", "power") + defaults["mqtt_multiplier"] = kwargs.get("mqtt_multiplier", 1.0) + 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_smart_plugs_api.py b/backend/tests/integration/test_smart_plugs_api.py index e6ad7b573..90b7b1e8f 100644 --- a/backend/tests/integration/test_smart_plugs_api.py +++ b/backend/tests/integration/test_smart_plugs_api.py @@ -573,3 +573,124 @@ 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_paths(self, async_client: AsyncClient): + """Verify creating MQTT plug without any JSON paths fails.""" + data = { + "name": "MQTT Plug", + "plug_type": "mqtt", + "mqtt_topic": "test/topic", + # Missing both mqtt_power_path and mqtt_state_path + "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" diff --git a/frontend/src/__tests__/components/SmartPlugCard.test.tsx b/frontend/src/__tests__/components/SmartPlugCard.test.tsx index 561d7cc0b..da294867b 100644 --- a/frontend/src/__tests__/components/SmartPlugCard.test.tsx +++ b/frontend/src/__tests__/components/SmartPlugCard.test.tsx @@ -21,6 +21,12 @@ const createMockPlug = (overrides: Partial = {}): SmartPlug => ({ plug_type: 'tasmota', ip_address: '192.168.1.100', ha_entity_id: null, + // MQTT fields + mqtt_topic: null, + mqtt_power_path: null, + mqtt_energy_path: null, + mqtt_state_path: null, + mqtt_multiplier: 1.0, printer_id: 1, enabled: true, auto_on: true, @@ -272,4 +278,74 @@ describe('SmartPlugCard', () => { expect(buttons.length).toBeGreaterThan(0); }); }); + + describe('MQTT plugs', () => { + it('renders MQTT plug with topic instead of IP', () => { + const plug = createMockPlug({ + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'zigbee2mqtt/shelly-power', + mqtt_power_path: 'power_l1', + }); + render(); + + // Should show topic, not IP + expect(screen.getByText('zigbee2mqtt/shelly-power')).toBeInTheDocument(); + expect(screen.queryByText('192.168.1.100')).not.toBeInTheDocument(); + }); + + it('renders MQTT plug name correctly', () => { + const plug = createMockPlug({ + name: 'MQTT Energy Monitor', + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'sensors/power', + mqtt_power_path: 'power', + }); + render(); + + expect(screen.getByText('MQTT Energy Monitor')).toBeInTheDocument(); + }); + + it('shows Monitor Only badge for MQTT plug', () => { + const plug = createMockPlug({ + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'test/topic', + mqtt_power_path: 'power', + }); + render(); + + expect(screen.getByText('Monitor Only')).toBeInTheDocument(); + }); + + it('does not show power control buttons for MQTT plug', () => { + const plug = createMockPlug({ + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'test/topic', + mqtt_power_path: 'power', + }); + render(); + + // On/Off buttons should not be present for monitor-only plugs + expect(screen.queryByRole('button', { name: /^on$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^off$/i })).not.toBeInTheDocument(); + }); + + it('shows Settings instead of Automation Settings for MQTT plug', async () => { + const user = userEvent.setup(); + const plug = createMockPlug({ + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'test/topic', + mqtt_power_path: 'power', + }); + render(); + + // Should show "Settings" not "Automation Settings" + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.queryByText('Automation Settings')).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 75eea481c..c512fd3d4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -840,13 +840,19 @@ export interface CloudDevice { export interface SmartPlug { id: number; name: string; - plug_type: 'tasmota' | 'homeassistant'; + plug_type: 'tasmota' | 'homeassistant' | 'mqtt'; ip_address: string | null; // Required for Tasmota ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug") // Home Assistant energy sensor entities (optional) ha_power_entity: string | null; ha_energy_today_entity: string | null; ha_energy_total_entity: string | null; + // MQTT fields (required when plug_type="mqtt") + mqtt_topic: string | null; // e.g., "zigbee2mqtt/shelly-working-room" + mqtt_power_path: string | null; // e.g., "power_l1" or "data.power" + mqtt_energy_path: string | null; // e.g., "energy_l1" + mqtt_state_path: string | null; // e.g., "state_l1" for ON/OFF + mqtt_multiplier: number; // Unit conversion (e.g., 0.001 for mW→W) printer_id: number | null; enabled: boolean; auto_on: boolean; @@ -877,13 +883,19 @@ export interface SmartPlug { export interface SmartPlugCreate { name: string; - plug_type?: 'tasmota' | 'homeassistant'; + plug_type?: 'tasmota' | 'homeassistant' | 'mqtt'; ip_address?: string | null; // Required for Tasmota ha_entity_id?: string | null; // Required for Home Assistant // Home Assistant energy sensor entities (optional) ha_power_entity?: string | null; ha_energy_today_entity?: string | null; ha_energy_total_entity?: string | null; + // MQTT fields (required when plug_type="mqtt") + mqtt_topic?: string | null; + mqtt_power_path?: string | null; + mqtt_energy_path?: string | null; + mqtt_state_path?: string | null; + mqtt_multiplier?: number; printer_id?: number | null; enabled?: boolean; auto_on?: boolean; @@ -907,13 +919,19 @@ export interface SmartPlugCreate { export interface SmartPlugUpdate { name?: string; - plug_type?: 'tasmota' | 'homeassistant'; + plug_type?: 'tasmota' | 'homeassistant' | 'mqtt'; ip_address?: string | null; ha_entity_id?: string | null; // Home Assistant energy sensor entities (optional) ha_power_entity?: string | null; ha_energy_today_entity?: string | null; ha_energy_total_entity?: string | null; + // MQTT fields + mqtt_topic?: string | null; + mqtt_power_path?: string | null; + mqtt_energy_path?: string | null; + mqtt_state_path?: string | null; + mqtt_multiplier?: number; printer_id?: number | null; enabled?: boolean; auto_on?: boolean; diff --git a/frontend/src/components/AddSmartPlugModal.tsx b/frontend/src/components/AddSmartPlugModal.tsx index bba6c11d0..eccde0ac2 100644 --- a/frontend/src/components/AddSmartPlugModal.tsx +++ b/frontend/src/components/AddSmartPlugModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; -import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home } from 'lucide-react'; +import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio } from 'lucide-react'; import { api } from '../api/client'; import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client'; import { Button } from './Button'; @@ -15,7 +15,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { const isEditing = !!plug; // Plug type selection - const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant'>(plug?.plug_type || 'tasmota'); + const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt'>(plug?.plug_type || 'tasmota'); const [name, setName] = useState(plug?.name || ''); // Tasmota fields @@ -24,6 +24,12 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { const [password, setPassword] = useState(plug?.password || ''); // Home Assistant fields const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || ''); + // MQTT fields + const [mqttTopic, setMqttTopic] = useState(plug?.mqtt_topic || ''); + const [mqttPowerPath, setMqttPowerPath] = useState(plug?.mqtt_power_path || ''); + const [mqttEnergyPath, setMqttEnergyPath] = useState(plug?.mqtt_energy_path || ''); + const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || ''); + const [mqttMultiplier, setMqttMultiplier] = useState(plug?.mqtt_multiplier?.toString() || '1'); // HA energy sensor entities (optional) const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || ''); const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || ''); @@ -279,6 +285,17 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { return; } + if (plugType === 'mqtt') { + if (!mqttTopic.trim()) { + setError('MQTT topic is required for MQTT plugs'); + return; + } + if (!mqttPowerPath.trim() && !mqttStatePath.trim()) { + setError('At least power path or state path is required for MQTT plugs'); + return; + } + } + const data = { name: name.trim(), plug_type: plugType, @@ -288,6 +305,12 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null, ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null, ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null, + // MQTT fields + mqtt_topic: plugType === 'mqtt' ? mqttTopic.trim() : null, + mqtt_power_path: plugType === 'mqtt' ? (mqttPowerPath.trim() || null) : null, + mqtt_energy_path: plugType === 'mqtt' ? (mqttEnergyPath.trim() || null) : null, + mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null, + mqtt_multiplier: plugType === 'mqtt' ? (parseFloat(mqttMultiplier) || 1) : 1, username: plugType === 'tasmota' ? (username.trim() || null) : null, password: plugType === 'tasmota' ? (password.trim() || null) : null, printer_id: printerId, @@ -352,7 +375,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { setTestResult(null); setError(null); }} - className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${ + className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${ plugType === 'tasmota' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary' @@ -368,14 +391,30 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { setTestResult(null); setError(null); }} - className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${ + className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${ plugType === 'homeassistant' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary' }`} > - Home Assistant + HA + + )} @@ -857,6 +896,94 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { )} + {/* MQTT Configuration - only show when MQTT is selected */} + {plugType === 'mqtt' && ( +
+ {/* MQTT broker not configured */} + {!settings?.mqtt_broker && ( +
+ MQTT broker not configured. Set broker address in{' '} + Settings → Network → MQTT Publishing + {' '}(you don't need to enable publishing, just fill in the broker details). +
+ )} + + {/* MQTT broker configured - show fields */} + {settings?.mqtt_broker && ( + <> +
+

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. +

+
+ +
+ + setMqttTopic(e.target.value)} + placeholder="zigbee2mqtt/shelly-working-room" + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +

The MQTT topic to subscribe to

+
+ +
+ + setMqttPowerPath(e.target.value)} + placeholder="power_l1 or data.power" + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +

Path to power value in JSON (dot notation)

+
+ +
+ + setMqttStatePath(e.target.value)} + placeholder="state_l1" + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +

Path to ON/OFF state (optional)

+
+ +
+ + setMqttEnergyPath(e.target.value)} + placeholder="energy_l1" + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +

Path to energy/kWh value (optional)

+
+ +
+ + setMqttMultiplier(e.target.value)} + placeholder="1" + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +

+ Multiply values by this factor. Use 0.001 to convert mW to W, 1000 for kW to W. +

+
+ + )} +
+ )} + {/* IP Address - only show for Tasmota */} {plugType === 'tasmota' && (
@@ -959,25 +1086,27 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { )} - {/* Link to Printer */} -
- - -

- Linking enables automatic on/off when prints start/complete -

-
+ {/* Link to Printer - not shown for MQTT plugs (monitor-only) */} + {plugType !== 'mqtt' && ( +
+ + +

+ Linking enables automatic on/off when prints start/complete +

+
+ )} {/* Power Alerts */}
@@ -1031,51 +1160,53 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { )}
- {/* Schedule */} -
-
-
- - Daily Schedule -
- -
- {scheduleEnabled && ( -
-
-
- - setScheduleOnTime(e.target.value)} - className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" - /> -
-
- - setScheduleOffTime(e.target.value)} - className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" - /> -
+ {/* Schedule - not shown for MQTT plugs (monitor-only) */} + {plugType !== 'mqtt' && ( +
+
+
+ + Daily Schedule
-

- Automatically turn the plug on/off at these times daily. Leave empty to skip that action. -

+
- )} -
+ {scheduleEnabled && ( +
+
+
+ + setScheduleOnTime(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + /> +
+
+ + setScheduleOffTime(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + /> +
+
+

+ Automatically turn the plug on/off at these times daily. Leave empty to skip that action. +

+
+ )} +
+ )} {/* Switchbar Visibility */}
diff --git a/frontend/src/components/SmartPlugCard.tsx b/frontend/src/components/SmartPlugCard.tsx index 4ec49c610..0895dd0a1 100644 --- a/frontend/src/components/SmartPlugCard.tsx +++ b/frontend/src/components/SmartPlugCard.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; -import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home } from 'lucide-react'; +import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye } from 'lucide-react'; import { api } from '../api/client'; import type { SmartPlug, SmartPlugUpdate } from '../api/client'; import { Card, CardContent } from './Card'; @@ -92,7 +92,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { }); const isOn = status?.state === 'ON'; - const isReachable = status?.reachable ?? false; + // For MQTT plugs, consider reachable if we have power data (even if backend says not reachable) + const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined); + const isReachable = (status?.reachable ?? false) || hasMqttData; const isPending = controlMutation.isPending; // Generate admin URL with auto-login credentials (Tasmota only) @@ -113,27 +115,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { {/* Header Row */} -
-
-
- {plug.plug_type === 'homeassistant' ? ( +
+
+
+ {plug.plug_type === 'mqtt' ? ( + + ) : plug.plug_type === 'homeassistant' ? ( ) : ( )}
-
-

{plug.name}

-

- {plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address} +

+

{plug.name}

+

+ {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}

{/* Status indicator */} -
+
{statusLoading ? ( + ) : plug.plug_type === 'mqtt' ? ( + /* MQTT plugs - show badge and checkmark when receiving data */ +
+ MQTT + {isReachable && ✓} +
+ ) : plug.plug_type === 'homeassistant' ? ( +
+ HA + + {isReachable ? (status?.state || '?') : 'Offline'} + +
) : isReachable ? (
@@ -170,8 +194,14 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { )} {/* Feature Badges */} - {(plug.power_alert_enabled || plug.schedule_enabled) && ( + {(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
+ {plug.plug_type === 'mqtt' && ( + + + Monitor Only + + )} {plug.power_alert_enabled && ( @@ -191,29 +221,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
)} - {/* Quick Controls */} -
- - -
+ {/* Quick Controls - hidden for MQTT plugs (monitor-only) */} + {plug.plug_type !== 'mqtt' && ( +
+ + +
+ )} + + {/* Energy display for MQTT plugs */} + {plug.plug_type === 'mqtt' && status?.energy && ( +
+ {status.energy.power !== null && status.energy.power !== undefined && ( +
+

{Math.round(status.energy.power)}W

+

Power

+
+ )} + {status.energy.today !== null && status.energy.today !== undefined && ( +
+

{status.energy.today.toFixed(2)}

+

kWh Today

+
+ )} +
+ )} {/* Toggle Settings Panel */} @@ -250,45 +300,48 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
- {/* Enabled Toggle */} -
-
-

Enabled

-

Enable automation for this plug

-
- -
+ {/* Automation controls - only for controllable plugs (not MQTT) */} + {plug.plug_type !== 'mqtt' && ( + <> + {/* Enabled Toggle */} +
+
+

Enabled

+

Enable automation for this plug

+
+ +
- {/* Auto On */} -
-
-

Auto On

-

Turn on when print starts

-
- -
+ {/* Auto On */} +
+
+

Auto On

+

Turn on when print starts

+
+ +
- {/* Auto Off */} -
-
-

Auto Off

-

Turn off when print completes (one-shot)

+ {/* Auto Off */} +
+
+

Auto Off

+

Turn off when print completes (one-shot)