From 914adde5aa25230b8aec018a5872b46ad2ff5500 Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 1 Apr 2026 10:06:01 +0200 Subject: [PATCH] Add REST/Webhook smart plug type (#472) --- CHANGELOG.md | 1 + README.md | 3 +- backend/app/api/routes/archives.py | 6 + backend/app/api/routes/smart_plugs.py | 15 + backend/app/core/database.py | 46 ++ backend/app/main.py | 7 +- backend/app/models/smart_plug.py | 22 +- backend/app/schemas/smart_plug.py | 47 ++- backend/app/services/rest_smart_plug.py | 260 ++++++++++++ backend/app/services/smart_plug_manager.py | 31 ++ backend/tests/conftest.py | 6 + .../tests/integration/test_smart_plugs_api.py | 132 ++++++ .../unit/services/test_rest_smart_plug.py | 231 ++++++++++ frontend/src/api/client.ts | 49 ++- frontend/src/components/AddSmartPlugModal.tsx | 229 +++++++++- frontend/src/components/SmartPlugCard.tsx | 15 +- frontend/src/i18n/locales/de.ts | 21 + frontend/src/i18n/locales/en.ts | 21 + frontend/src/i18n/locales/fr.ts | 21 + frontend/src/i18n/locales/it.ts | 21 + frontend/src/i18n/locales/ja.ts | 21 + frontend/src/i18n/locales/pt-BR.ts | 21 + frontend/src/i18n/locales/zh-CN.ts | 21 + .../{index-vkI-ayst.js => index-CHZDJElS.js} | 394 +++++++++--------- static/index.html | 2 +- 25 files changed, 1430 insertions(+), 213 deletions(-) create mode 100644 backend/app/services/rest_smart_plug.py create mode 100644 backend/tests/unit/services/test_rest_smart_plug.py rename static/assets/{index-vkI-ayst.js => index-CHZDJElS.js} (63%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 283d6d71e..a087d2004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to Bambuddy will be documented in this file. - **Per-User Statistics Filtering** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — Admins can now filter the Statistics page by user. A user dropdown appears in the stats header for users with the new `stats:filter_by_user` permission (Administrators only by default). Filter by a specific user to see their prints, filament usage, and costs, or select "No User (System)" to view prints without user attribution (e.g. slicer-initiated or pre-auth prints). The filter applies to all stats widgets and exports. Requested by @3823u44238. - **Bulk Printer Actions** ([#825](https://github.com/maziggy/bambuddy/issues/825)) — Select multiple printer cards and apply bulk actions from a floating toolbar. Toggle selection mode from the header, then click cards to select. Use "Select All", "Select by State" (printing, paused, finished, idle, error, offline), or "Select by Location" to quickly pick printers. Available actions: Stop, Pause, Resume, Clear Notifications, and Clear Bed — each button is smart-enabled based on the selected printers' current states. Confirmation modals for destructive actions (Stop, Pause, Clear Bed). The status summary bar now shows all printer states (printing, paused, finished, idle, error, offline). Requested by @therevoman. - **Prefer Lowest Remaining Filament** ([#805](https://github.com/maziggy/bambuddy/issues/805)) — New optional setting in Settings → Filament that prefers AMS spools with the lowest remaining filament during auto-matching. When multiple spools match the same type and color, the one with the least filament remaining is selected first. Helps consume partial spools before starting new ones. Applies to queue scheduling, print modal, and multi-printer mapping. Unknown remain values (e.g. external spools without sensors) are treated as full. Disabled by default. Requested by @Mofoss. +- **REST/Webhook Smart Plug Type** ([#472](https://github.com/maziggy/bambuddy/issues/472)) — New "REST" smart plug type for controlling power via generic HTTP APIs. Works with any home automation platform that has an HTTP endpoint (openHAB, ioBroker, FHEM, Node-RED, etc.). Configure separate ON/OFF URLs with custom HTTP methods (GET/POST/PUT/PATCH), request bodies, and headers. Optional status polling via a GET endpoint with JSON path extraction for state, power, and energy monitoring. Fully controllable — supports auto on/off with prints, daily scheduling, sidebar quick-toggle, and power alerts. Requested by @Percy2Live. ### Improved - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance. diff --git a/README.md b/README.md index a239e1d51..597ef507f 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer - Scheduled prints (date/time) - Queue Only mode (stage without auto-start) - Clear plate confirmation between queued prints (can be disabled in settings for farm workflows) -- Smart plug integration (Tasmota, Home Assistant, MQTT) +- Smart plug integration (Tasmota, Home Assistant, MQTT, REST/Webhook) +- REST smart plugs: Control any device with an HTTP API (openHAB, ioBroker, FHEM, Node-RED) - 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) diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 10b9b4200..6a8bf7e34 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -797,6 +797,12 @@ async def get_archive_stats( 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 + elif plug.plug_type == "rest": + from backend.app.services.rest_smart_plug import rest_smart_plug_service + + energy = await rest_smart_plug_service.get_energy(plug) + if energy and energy.get("today") is not None: + total_energy_kwh += energy["today"] total_energy_kwh = round(total_energy_kwh, 3) total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3) diff --git a/backend/app/api/routes/smart_plugs.py b/backend/app/api/routes/smart_plugs.py index 40cb69312..71f050fb6 100644 --- a/backend/app/api/routes/smart_plugs.py +++ b/backend/app/api/routes/smart_plugs.py @@ -20,6 +20,8 @@ from backend.app.schemas.smart_plug import ( HASensorEntity, HATestConnectionRequest, HATestConnectionResponse, + RESTTestConnectionRequest, + RESTTestConnectionResponse, SmartPlugControl, SmartPlugCreate, SmartPlugEnergy, @@ -33,6 +35,7 @@ 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.rest_smart_plug import rest_smart_plug_service from backend.app.services.tasmota import tasmota_service logger = logging.getLogger(__name__) @@ -341,6 +344,16 @@ async def test_ha_connection( return HATestConnectionResponse(**result) +@router.post("/rest/test-connection", response_model=RESTTestConnectionResponse) +async def test_rest_connection( + request: RESTTestConnectionRequest, + _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL), +): + """Test connection to a REST/HTTP endpoint.""" + result = await rest_smart_plug_service.test_connection(request.url, request.method, request.headers) + return RESTTestConnectionResponse(**result) + + @router.get("/ha/entities", response_model=list[HAEntity]) async def list_ha_entities( db: AsyncSession = Depends(get_db), @@ -557,6 +570,8 @@ async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession): ha_settings = await get_homeassistant_settings(db) homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"]) return homeassistant_service + if plug.plug_type == "rest": + return rest_smart_plug_service return tasmota_service diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 3dc68e939..eab8941d0 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1563,6 +1563,52 @@ async def run_migrations(conn): except OperationalError: pass # Already applied + # Migration: Add REST/Webhook smart plug fields + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_on_url VARCHAR(500)")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_on_body TEXT")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_off_url VARCHAR(500)")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_off_body TEXT")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_method VARCHAR(10)")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_headers TEXT")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_status_url VARCHAR(500)")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_status_path VARCHAR(200)")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_status_on_value VARCHAR(50)")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_power_path VARCHAR(200)")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_energy_path VARCHAR(200)")) + except OperationalError: + pass # Already applied + # Seed default settings keys that must exist on fresh install default_settings = [ ("advanced_auth_enabled", "false"), diff --git a/backend/app/main.py b/backend/app/main.py index 75115b69b..10100e55a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -308,10 +308,11 @@ _expected_prints_cleanup_task: asyncio.Task | None = None async def _get_plug_energy(plug, db) -> dict | None: - """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT). + """Get energy from plug regardless of type (Tasmota, Home Assistant, MQTT, or REST). For HA plugs, configures the service with current settings from DB. For MQTT plugs, returns data from the subscription service. + For REST plugs, polls the status URL with JSON path extraction. """ if plug.plug_type == "homeassistant": from backend.app.api.routes.settings import get_homeassistant_settings @@ -330,6 +331,10 @@ async def _get_plug_energy(plug, db) -> dict | None: "total": mqtt_data.energy, # Use today as total for per-print calculations } return None + elif plug.plug_type == "rest": + from backend.app.services.rest_smart_plug import rest_smart_plug_service + + return await rest_smart_plug_service.get_energy(plug) else: return await tasmota_service.get_energy(plug) diff --git a/backend/app/models/smart_plug.py b/backend/app/models/smart_plug.py index f0c6cd106..5e22ee85d 100644 --- a/backend/app/models/smart_plug.py +++ b/backend/app/models/smart_plug.py @@ -1,13 +1,13 @@ from datetime import datetime -from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from backend.app.core.database import Base class SmartPlug(Base): - """Smart plug for printer power control (Tasmota, Home Assistant, or MQTT).""" + """Smart plug for printer power control (Tasmota, Home Assistant, MQTT, or REST).""" __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), "homeassistant", or "mqtt" + # Plug type: "tasmota" (default), "homeassistant", "mqtt", or "rest" 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) @@ -50,6 +50,22 @@ class SmartPlug(Base): # Legacy multiplier - kept for backward compatibility mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Deprecated, use mqtt_power_multiplier + # REST/Webhook plug fields (required when plug_type="rest") + # Control URLs + rest_on_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # Full URL to turn ON + rest_on_body: Mapped[str | None] = mapped_column(Text, nullable=True) # Request body for ON + rest_off_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # Full URL to turn OFF + rest_off_body: Mapped[str | None] = mapped_column(Text, nullable=True) # Request body for OFF + rest_method: Mapped[str | None] = mapped_column(String(10), nullable=True) # HTTP method: POST, PUT, GET + rest_headers: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string of custom headers + # Status polling (optional) + rest_status_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # GET endpoint for state + rest_status_path: Mapped[str | None] = mapped_column(String(200), nullable=True) # JSON path to state value + rest_status_on_value: Mapped[str | None] = mapped_column(String(50), nullable=True) # What value means ON + # Energy monitoring (optional, extracted from status response) + rest_power_path: Mapped[str | None] = mapped_column(String(200), nullable=True) # JSON path for power (watts) + rest_energy_path: Mapped[str | None] = mapped_column(String(200), nullable=True) # JSON path for energy (kWh) + # Link to printer (multiple plugs/scripts can be linked to one printer) printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True) diff --git a/backend/app/schemas/smart_plug.py b/backend/app/schemas/smart_plug.py index e7734f1e8..2d95a512a 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", "mqtt"] = "tasmota" + plug_type: Literal["tasmota", "homeassistant", "mqtt", "rest"] = "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}$") @@ -44,6 +44,19 @@ class SmartPlugBase(BaseModel): # Legacy multiplier - kept for backward compatibility mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Deprecated, use mqtt_power_multiplier + # REST/Webhook fields (required when plug_type="rest") + rest_on_url: str | None = Field(default=None, max_length=500) + rest_on_body: str | None = None + rest_off_url: str | None = Field(default=None, max_length=500) + rest_off_body: str | None = None + rest_method: Literal["GET", "POST", "PUT", "PATCH"] | None = None + rest_headers: str | None = None # JSON string of custom headers + rest_status_url: str | None = Field(default=None, max_length=500) + rest_status_path: str | None = Field(default=None, max_length=200) + rest_status_on_value: str | None = Field(default=None, max_length=50) + rest_power_path: str | None = Field(default=None, max_length=200) + rest_energy_path: str | None = Field(default=None, max_length=200) + printer_id: int | None = None enabled: bool = True auto_on: bool = True @@ -81,6 +94,9 @@ class SmartPlugBase(BaseModel): # 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") + if self.plug_type == "rest": + if not self.rest_on_url and not self.rest_off_url: + raise ValueError("At least one of ON URL or OFF URL is required for REST plugs") return self @@ -90,7 +106,7 @@ class SmartPlugCreate(SmartPlugBase): class SmartPlugUpdate(BaseModel): name: str | None = None - plug_type: Literal["tasmota", "homeassistant", "mqtt"] | None = None + plug_type: Literal["tasmota", "homeassistant", "mqtt", "rest"] | None = None ip_address: str | None = None ha_entity_id: str | None = None # Home Assistant energy sensor entities (optional) @@ -112,6 +128,18 @@ class SmartPlugUpdate(BaseModel): mqtt_state_topic: str | None = None mqtt_state_path: str | None = None mqtt_state_on_value: str | None = None + # REST fields + rest_on_url: str | None = None + rest_on_body: str | None = None + rest_off_url: str | None = None + rest_off_body: str | None = None + rest_method: Literal["GET", "POST", "PUT", "PATCH"] | None = None + rest_headers: str | None = None + rest_status_url: str | None = None + rest_status_path: str | None = None + rest_status_on_value: str | None = None + rest_power_path: str | None = None + rest_energy_path: str | None = None printer_id: int | None = None enabled: bool | None = None auto_on: bool | None = None @@ -211,3 +239,18 @@ class HASensorEntity(BaseModel): friendly_name: str state: str | None = None unit_of_measurement: str | None = None # "W", "kW", "kWh", "Wh" + + +class RESTTestConnectionRequest(BaseModel): + """Request to test a REST smart plug connection.""" + + url: str = Field(..., min_length=1) + method: str = Field(default="GET") + headers: str | None = None # JSON string of custom headers + + +class RESTTestConnectionResponse(BaseModel): + """Response from REST connection test.""" + + success: bool + error: str | None = None diff --git a/backend/app/services/rest_smart_plug.py b/backend/app/services/rest_smart_plug.py new file mode 100644 index 000000000..85102f03c --- /dev/null +++ b/backend/app/services/rest_smart_plug.py @@ -0,0 +1,260 @@ +"""Service for controlling smart plugs via generic REST/HTTP API.""" + +import ipaddress +import json +import logging +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +import httpx + +if TYPE_CHECKING: + from backend.app.models.smart_plug import SmartPlug + +logger = logging.getLogger(__name__) + + +class RESTSmartPlugService: + """Service for controlling smart plugs via generic REST/HTTP API. + + Supports any home automation platform with an HTTP API (openHAB, ioBroker, FHEM, Node-RED, etc.). + """ + + def __init__(self, timeout: float = 10.0): + self.timeout = timeout + + @staticmethod + def _validate_url(url: str) -> bool: + """Block cloud metadata and link-local IPs.""" + try: + parsed = urlparse(url) + hostname = parsed.hostname + if not hostname: + return False + addr = ipaddress.ip_address(hostname) + return not addr.is_loopback and not addr.is_link_local + except ValueError: + # Hostname is not an IP (e.g., "openhab.local") — allow it + return True + + def _parse_headers(self, headers_json: str | None) -> dict[str, str]: + """Parse JSON string to dict of headers.""" + if not headers_json: + return {} + try: + headers = json.loads(headers_json) + if isinstance(headers, dict): + return {str(k): str(v) for k, v in headers.items()} + except (json.JSONDecodeError, TypeError): + logger.warning("Failed to parse REST headers JSON: %s", headers_json) + return {} + + @staticmethod + def _extract_json_path(data: Any, path: str) -> Any: + """Extract value using dot notation (e.g., 'state' or 'data.power.status').""" + 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 + + async def _send_request( + self, + url: str, + method: str = "POST", + headers: dict[str, str] | None = None, + body: str | None = None, + ) -> httpx.Response | None: + """Send an HTTP request and return the response.""" + if not self._validate_url(url): + logger.warning("Blocked REST request to invalid URL: %s", url) + return None + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + kwargs: dict[str, Any] = {"headers": headers or {}} + if body is not None: + # Try to detect if body is JSON + try: + json.loads(body) + kwargs["content"] = body + if "Content-Type" not in (headers or {}): + kwargs["headers"]["Content-Type"] = "application/json" + except (json.JSONDecodeError, TypeError): + kwargs["content"] = body + + response = await client.request(method.upper(), url, **kwargs) + response.raise_for_status() + return response + except httpx.TimeoutException: + logger.warning("REST smart plug at %s timed out", url) + return None + except httpx.HTTPStatusError as e: + logger.warning("REST smart plug at %s returned error: %s", url, e) + return None + except httpx.RequestError as e: + logger.warning("Failed to connect to REST smart plug at %s: %s", url, e) + return None + except Exception as e: + logger.error("Unexpected error communicating with REST smart plug at %s: %s", url, e) + return None + + async def turn_on(self, plug: "SmartPlug") -> bool: + """Turn on the plug. Returns True if successful.""" + if not plug.rest_on_url: + logger.warning("No ON URL configured for REST plug '%s'", plug.name) + return False + + headers = self._parse_headers(plug.rest_headers) + method = plug.rest_method or "POST" + response = await self._send_request(plug.rest_on_url, method, headers, plug.rest_on_body) + + if response is not None: + logger.info("Turned ON REST smart plug '%s' via %s %s", plug.name, method, plug.rest_on_url) + return True + + logger.warning("Failed to turn ON REST smart plug '%s'", plug.name) + return False + + async def turn_off(self, plug: "SmartPlug") -> bool: + """Turn off the plug. Returns True if successful.""" + if not plug.rest_off_url: + logger.warning("No OFF URL configured for REST plug '%s'", plug.name) + return False + + headers = self._parse_headers(plug.rest_headers) + method = plug.rest_method or "POST" + response = await self._send_request(plug.rest_off_url, method, headers, plug.rest_off_body) + + if response is not None: + logger.info("Turned OFF REST smart plug '%s' via %s %s", plug.name, method, plug.rest_off_url) + return True + + logger.warning("Failed to turn OFF REST smart plug '%s'", plug.name) + return False + + async def toggle(self, plug: "SmartPlug") -> bool: + """Toggle the plug state by checking status first.""" + status = await self.get_status(plug) + if status["state"] == "ON": + return await self.turn_off(plug) + else: + return await self.turn_on(plug) + + async def get_status(self, plug: "SmartPlug") -> dict: + """Get current power state. + + Returns dict with: + - state: "ON" or "OFF" or None if unreachable + - reachable: bool + - device_name: None (REST plugs don't report device names) + """ + if not plug.rest_status_url: + return {"state": None, "reachable": True, "device_name": None} + + headers = self._parse_headers(plug.rest_headers) + response = await self._send_request(plug.rest_status_url, "GET", headers) + + if response is None: + return {"state": None, "reachable": False, "device_name": None} + + # Try to extract state from response + state = None + try: + data = response.json() + if plug.rest_status_path: + raw_value = self._extract_json_path(data, plug.rest_status_path) + if raw_value is not None: + on_value = (plug.rest_status_on_value or "ON").upper() + state = "ON" if str(raw_value).upper() == on_value else "OFF" + else: + # No path configured — try common patterns + raw_value = str(data).upper() if not isinstance(data, dict) else None + if raw_value in ("ON", "TRUE", "1"): + state = "ON" + elif raw_value in ("OFF", "FALSE", "0"): + state = "OFF" + except Exception: + # Response is not JSON — try raw text + text = response.text.strip().upper() + on_value = (plug.rest_status_on_value or "ON").upper() + state = "ON" if text == on_value else "OFF" + + return {"state": state, "reachable": True, "device_name": None} + + async def get_energy(self, plug: "SmartPlug") -> dict | None: + """Get energy monitoring data from the status endpoint. + + Returns dict with energy data or None if not available. + """ + if not plug.rest_status_url or (not plug.rest_power_path and not plug.rest_energy_path): + return None + + headers = self._parse_headers(plug.rest_headers) + response = await self._send_request(plug.rest_status_url, "GET", headers) + + if response is None: + return None + + try: + data = response.json() + except Exception: + return None + + energy: dict[str, float | None] = {} + + if plug.rest_power_path: + raw = self._extract_json_path(data, plug.rest_power_path) + if raw is not None: + try: + energy["power"] = float(raw) + except (ValueError, TypeError): + pass + + if plug.rest_energy_path: + raw = self._extract_json_path(data, plug.rest_energy_path) + if raw is not None: + try: + energy["today"] = float(raw) + except (ValueError, TypeError): + pass + + return energy if energy else None + + async def test_connection(self, url: str, method: str = "GET", headers: str | None = None) -> dict: + """Test connection to a REST endpoint. + + Returns dict with: + - success: bool + - error: error message if failed + """ + if not self._validate_url(url): + return {"success": False, "error": "Invalid URL (loopback/link-local addresses are blocked)"} + + parsed_headers = self._parse_headers(headers) + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.request(method.upper(), url, headers=parsed_headers) + response.raise_for_status() + return {"success": True, "error": None} + except httpx.TimeoutException: + return {"success": False, "error": "Connection timed out"} + except httpx.HTTPStatusError as e: + return {"success": False, "error": f"HTTP {e.response.status_code}: {e.response.reason_phrase}"} + except httpx.RequestError as e: + return {"success": False, "error": f"Connection failed: {e}"} + except Exception as e: + return {"success": False, "error": str(e)} + + +# Singleton instance +rest_smart_plug_service = RESTSmartPlugService() diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py index aa33442fe..24045e672 100644 --- a/backend/app/services/smart_plug_manager.py +++ b/backend/app/services/smart_plug_manager.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.app.services.homeassistant import homeassistant_service from backend.app.services.printer_manager import printer_manager +from backend.app.services.rest_smart_plug import rest_smart_plug_service from backend.app.services.tasmota import tasmota_service if TYPE_CHECKING: @@ -36,6 +37,8 @@ class SmartPlugManager: # Configure HA service with current settings await self._configure_ha_service(db) return homeassistant_service + if plug.plug_type == "rest": + return rest_smart_plug_service return tasmota_service async def _configure_ha_service(self, db: AsyncSession | None = None): @@ -248,6 +251,10 @@ class SmartPlugManager: plug.password, printer_id, delay_seconds, + rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None, + rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None, + rest_method=plug.rest_method if plug.plug_type == "rest" else None, + rest_headers=plug.rest_headers if plug.plug_type == "rest" else None, ) ) self._pending_off[plug.id] = task @@ -262,6 +269,11 @@ class SmartPlugManager: password: str | None, printer_id: int, delay_seconds: int, + *, + rest_off_url: str | None = None, + rest_off_body: str | None = None, + rest_method: str | None = None, + rest_headers: str | None = None, ): """Wait and turn off.""" try: @@ -276,6 +288,11 @@ class SmartPlugManager: self.username = username self.password = password self.name = f"plug_{plug_id}" + # REST fields + self.rest_off_url = rest_off_url + self.rest_off_body = rest_off_body + self.rest_method = rest_method + self.rest_headers = rest_headers plug_info = PlugInfo() service = await self.get_service_for_plug(plug_info) @@ -313,6 +330,10 @@ class SmartPlugManager: plug.password, printer_id, temp_threshold, + rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None, + rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None, + rest_method=plug.rest_method if plug.plug_type == "rest" else None, + rest_headers=plug.rest_headers if plug.plug_type == "rest" else None, ) ) self._pending_off[plug.id] = task @@ -327,6 +348,11 @@ class SmartPlugManager: password: str | None, printer_id: int, temp_threshold: int, + *, + rest_off_url: str | None = None, + rest_off_body: str | None = None, + rest_method: str | None = None, + rest_headers: str | None = None, ): """Poll temperature until below threshold, then turn off. @@ -370,6 +396,11 @@ class SmartPlugManager: self.username = username self.password = password self.name = f"plug_{plug_id}" + # REST fields + self.rest_off_url = rest_off_url + self.rest_off_body = rest_off_body + self.rest_method = rest_method + self.rest_headers = rest_headers plug_info = PlugInfo() service = await self.get_service_for_plug(plug_info) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b2868880f..e84b64e0d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -360,6 +360,12 @@ def smart_plug_factory(db_session): defaults["mqtt_state_on_value"] = kwargs.get("mqtt_state_on_value") defaults["ip_address"] = None defaults["ha_entity_id"] = None + elif plug_type == "rest": + defaults["rest_on_url"] = kwargs.get("rest_on_url", "http://192.168.1.100/api/plug/on") + defaults["rest_off_url"] = kwargs.get("rest_off_url", "http://192.168.1.100/api/plug/off") + defaults["rest_method"] = kwargs.get("rest_method", "POST") + 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 c5e917e10..c09e93e47 100644 --- a/backend/tests/integration/test_smart_plugs_api.py +++ b/backend/tests/integration/test_smart_plugs_api.py @@ -825,3 +825,135 @@ class TestSmartPlugsAPI: result = response.json() assert result["mqtt_power_multiplier"] == 0.001 assert result["mqtt_energy_multiplier"] == 0.001 + + # ======================================================================== + # REST Smart Plug Integration tests + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_rest_plug(self, async_client: AsyncClient): + """Verify REST plug can be created with ON/OFF URLs.""" + data = { + "name": "REST Plug", + "plug_type": "rest", + "rest_on_url": "http://openhab:8080/rest/items/MyPlug", + "rest_on_body": "ON", + "rest_off_url": "http://openhab:8080/rest/items/MyPlug", + "rest_off_body": "OFF", + "rest_method": "POST", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["name"] == "REST Plug" + assert result["plug_type"] == "rest" + assert result["rest_on_url"] == "http://openhab:8080/rest/items/MyPlug" + assert result["rest_on_body"] == "ON" + assert result["rest_off_url"] == "http://openhab:8080/rest/items/MyPlug" + assert result["rest_off_body"] == "OFF" + assert result["rest_method"] == "POST" + assert result["ip_address"] is None + assert result["ha_entity_id"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_rest_plug_on_url_only(self, async_client: AsyncClient): + """Verify REST plug can be created with only ON URL.""" + data = { + "name": "REST ON Only", + "plug_type": "rest", + "rest_on_url": "http://iobroker:8087/set/plug?value=true", + "rest_method": "GET", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["rest_on_url"] == "http://iobroker:8087/set/plug?value=true" + assert result["rest_off_url"] is None + assert result["rest_method"] == "GET" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_rest_plug_missing_urls_fails(self, async_client: AsyncClient): + """Verify creating REST plug without any URL fails.""" + data = { + "name": "REST Plug", + "plug_type": "rest", + "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_rest_plug_with_status_and_energy(self, async_client: AsyncClient): + """Verify REST plug with status polling and energy monitoring.""" + data = { + "name": "REST Full", + "plug_type": "rest", + "rest_on_url": "http://ha:8080/api/plug/on", + "rest_off_url": "http://ha:8080/api/plug/off", + "rest_method": "POST", + "rest_headers": '{"Authorization": "Bearer test-token"}', + "rest_status_url": "http://ha:8080/api/plug/status", + "rest_status_path": "state", + "rest_status_on_value": "ON", + "rest_power_path": "power.current", + "rest_energy_path": "energy.today", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["rest_headers"] == '{"Authorization": "Bearer test-token"}' + assert result["rest_status_url"] == "http://ha:8080/api/plug/status" + assert result["rest_status_path"] == "state" + assert result["rest_status_on_value"] == "ON" + assert result["rest_power_path"] == "power.current" + assert result["rest_energy_path"] == "energy.today" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_rest_plug(self, async_client: AsyncClient, smart_plug_factory, db_session): + """Verify REST plug fields can be updated.""" + plug = await smart_plug_factory(plug_type="rest") + + response = await async_client.patch( + f"/api/v1/smart-plugs/{plug.id}", + json={ + "rest_on_url": "http://new-host:8080/on", + "rest_method": "PUT", + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["rest_on_url"] == "http://new-host:8080/on" + assert result["rest_method"] == "PUT" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rest_plug_is_controllable(self, async_client: AsyncClient, smart_plug_factory, db_session): + """Verify REST plugs can be controlled (not monitor-only like MQTT).""" + plug = await smart_plug_factory(plug_type="rest") + + # REST plugs should NOT return 400 like MQTT plugs + response = await async_client.post( + f"/api/v1/smart-plugs/{plug.id}/control", + json={"action": "on"}, + ) + + # Should attempt to send the request (may fail with 503 since URL is not real, + # but should NOT return 400 "monitor-only") + assert response.status_code != 400 diff --git a/backend/tests/unit/services/test_rest_smart_plug.py b/backend/tests/unit/services/test_rest_smart_plug.py new file mode 100644 index 000000000..123549ebd --- /dev/null +++ b/backend/tests/unit/services/test_rest_smart_plug.py @@ -0,0 +1,231 @@ +"""Unit tests for REST smart plug service.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from backend.app.services.rest_smart_plug import RESTSmartPlugService + + +@pytest.fixture +def service(): + return RESTSmartPlugService(timeout=5.0) + + +@pytest.fixture +def mock_plug(): + plug = MagicMock() + plug.name = "Test REST Plug" + plug.plug_type = "rest" + plug.rest_on_url = "http://192.168.1.50:8080/api/plug/on" + plug.rest_on_body = '{"state": "on"}' + plug.rest_off_url = "http://192.168.1.50:8080/api/plug/off" + plug.rest_off_body = '{"state": "off"}' + plug.rest_method = "POST" + plug.rest_headers = '{"Authorization": "Bearer test-token"}' + plug.rest_status_url = "http://192.168.1.50:8080/api/plug/status" + plug.rest_status_path = "state" + plug.rest_status_on_value = "ON" + plug.rest_power_path = "power" + plug.rest_energy_path = "energy.today" + return plug + + +class TestURLValidation: + def test_valid_ip_url(self, service): + assert service._validate_url("http://192.168.1.50:8080/api") is True + + def test_hostname_url(self, service): + assert service._validate_url("http://openhab.local:8080/api") is True + + def test_loopback_blocked(self, service): + assert service._validate_url("http://127.0.0.1/api") is False + + def test_link_local_blocked(self, service): + assert service._validate_url("http://169.254.1.1/api") is False + + def test_empty_hostname(self, service): + assert service._validate_url("http:///api") is False + + +class TestParseHeaders: + def test_valid_json(self, service): + headers = service._parse_headers('{"Authorization": "Bearer abc", "X-Custom": "val"}') + assert headers == {"Authorization": "Bearer abc", "X-Custom": "val"} + + def test_none_headers(self, service): + assert service._parse_headers(None) == {} + + def test_empty_string(self, service): + assert service._parse_headers("") == {} + + def test_invalid_json(self, service): + assert service._parse_headers("not json") == {} + + +class TestExtractJsonPath: + def test_simple_path(self, service): + data = {"state": "ON"} + assert service._extract_json_path(data, "state") == "ON" + + def test_nested_path(self, service): + data = {"data": {"power": {"current": 42.5}}} + assert service._extract_json_path(data, "data.power.current") == 42.5 + + def test_missing_path(self, service): + data = {"state": "ON"} + assert service._extract_json_path(data, "missing") is None + + def test_empty_path(self, service): + assert service._extract_json_path({"a": 1}, "") is None + + def test_none_path(self, service): + assert service._extract_json_path({"a": 1}, None) is None + + +class TestTurnOn: + @pytest.mark.asyncio + async def test_turn_on_success(self, service, mock_plug): + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response): + result = await service.turn_on(mock_plug) + + assert result is True + + @pytest.mark.asyncio + async def test_turn_on_failure(self, service, mock_plug): + with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=None): + result = await service.turn_on(mock_plug) + + assert result is False + + @pytest.mark.asyncio + async def test_turn_on_no_url(self, service, mock_plug): + mock_plug.rest_on_url = None + result = await service.turn_on(mock_plug) + assert result is False + + +class TestTurnOff: + @pytest.mark.asyncio + async def test_turn_off_success(self, service, mock_plug): + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response): + result = await service.turn_off(mock_plug) + + assert result is True + + @pytest.mark.asyncio + async def test_turn_off_no_url(self, service, mock_plug): + mock_plug.rest_off_url = None + result = await service.turn_off(mock_plug) + assert result is False + + +class TestGetStatus: + @pytest.mark.asyncio + async def test_status_on(self, service, mock_plug): + mock_response = MagicMock() + mock_response.json.return_value = {"state": "ON"} + + with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response): + result = await service.get_status(mock_plug) + + assert result["state"] == "ON" + assert result["reachable"] is True + + @pytest.mark.asyncio + async def test_status_off(self, service, mock_plug): + mock_response = MagicMock() + mock_response.json.return_value = {"state": "OFF"} + + with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response): + result = await service.get_status(mock_plug) + + assert result["state"] == "OFF" + assert result["reachable"] is True + + @pytest.mark.asyncio + async def test_status_unreachable(self, service, mock_plug): + with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=None): + result = await service.get_status(mock_plug) + + assert result["state"] is None + assert result["reachable"] is False + + @pytest.mark.asyncio + async def test_status_no_url(self, service, mock_plug): + mock_plug.rest_status_url = None + result = await service.get_status(mock_plug) + + assert result["state"] is None + assert result["reachable"] is True # No URL = assume reachable + + +class TestGetEnergy: + @pytest.mark.asyncio + async def test_energy_with_paths(self, service, mock_plug): + mock_response = MagicMock() + mock_response.json.return_value = {"power": 42.5, "energy": {"today": 1.23}} + + with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response): + result = await service.get_energy(mock_plug) + + assert result["power"] == 42.5 + assert result["today"] == 1.23 + + @pytest.mark.asyncio + async def test_energy_no_status_url(self, service, mock_plug): + mock_plug.rest_status_url = None + result = await service.get_energy(mock_plug) + assert result is None + + @pytest.mark.asyncio + async def test_energy_no_paths(self, service, mock_plug): + mock_plug.rest_power_path = None + mock_plug.rest_energy_path = None + result = await service.get_energy(mock_plug) + assert result is None + + +class TestTestConnection: + @pytest.mark.asyncio + async def test_connection_success(self, service): + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_client.request = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_cls.return_value = mock_client + + result = await service.test_connection("http://192.168.1.50:8080/api") + + assert result["success"] is True + + @pytest.mark.asyncio + async def test_connection_timeout(self, service): + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.request = AsyncMock(side_effect=httpx.TimeoutException("timeout")) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_cls.return_value = mock_client + + result = await service.test_connection("http://192.168.1.50:8080/api") + + assert result["success"] is False + assert "timed out" in result["error"] + + @pytest.mark.asyncio + async def test_connection_invalid_url(self, service): + result = await service.test_connection("http://127.0.0.1/api") + assert result["success"] is False + assert "blocked" in result["error"].lower() diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0c9c62b21..74e1a11b6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1072,7 +1072,7 @@ export interface CloudDevice { export interface SmartPlug { id: number; name: string; - plug_type: 'tasmota' | 'homeassistant' | 'mqtt'; + plug_type: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest'; ip_address: string | null; // Required for Tasmota ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer") // Home Assistant energy sensor entities (optional) @@ -1095,6 +1095,18 @@ export interface SmartPlug { mqtt_state_topic: string | null; // Topic for state data mqtt_state_path: string | null; // e.g., "state_l1" for ON/OFF mqtt_state_on_value: string | null; // What value means "ON" (e.g., "ON", "true", "1") + // REST/Webhook fields (required when plug_type="rest") + rest_on_url: string | null; + rest_on_body: string | null; + rest_off_url: string | null; + rest_off_body: string | null; + rest_method: string | null; + rest_headers: string | null; + rest_status_url: string | null; + rest_status_path: string | null; + rest_status_on_value: string | null; + rest_power_path: string | null; + rest_energy_path: string | null; printer_id: number | null; enabled: boolean; auto_on: boolean; @@ -1127,7 +1139,7 @@ export interface SmartPlug { export interface SmartPlugCreate { name: string; - plug_type?: 'tasmota' | 'homeassistant' | 'mqtt'; + plug_type?: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest'; ip_address?: string | null; // Required for Tasmota ha_entity_id?: string | null; // Required for Home Assistant // Home Assistant energy sensor entities (optional) @@ -1150,6 +1162,18 @@ export interface SmartPlugCreate { mqtt_state_topic?: string | null; mqtt_state_path?: string | null; mqtt_state_on_value?: string | null; + // REST fields + rest_on_url?: string | null; + rest_on_body?: string | null; + rest_off_url?: string | null; + rest_off_body?: string | null; + rest_method?: string | null; + rest_headers?: string | null; + rest_status_url?: string | null; + rest_status_path?: string | null; + rest_status_on_value?: string | null; + rest_power_path?: string | null; + rest_energy_path?: string | null; printer_id?: number | null; enabled?: boolean; auto_on?: boolean; @@ -1175,7 +1199,7 @@ export interface SmartPlugCreate { export interface SmartPlugUpdate { name?: string; - plug_type?: 'tasmota' | 'homeassistant' | 'mqtt'; + plug_type?: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest'; ip_address?: string | null; ha_entity_id?: string | null; // Home Assistant energy sensor entities (optional) @@ -1197,6 +1221,18 @@ export interface SmartPlugUpdate { mqtt_state_topic?: string | null; mqtt_state_path?: string | null; mqtt_state_on_value?: string | null; + // REST fields + rest_on_url?: string | null; + rest_on_body?: string | null; + rest_off_url?: string | null; + rest_off_body?: string | null; + rest_method?: string | null; + rest_headers?: string | null; + rest_status_url?: string | null; + rest_status_path?: string | null; + rest_status_on_value?: string | null; + rest_power_path?: string | null; + rest_energy_path?: string | null; printer_id?: number | null; enabled?: boolean; auto_on?: boolean; @@ -3362,6 +3398,13 @@ export const api = { getHASensorEntities: () => request('/smart-plugs/ha/sensors'), + // REST smart plug + testRESTConnection: (url: string, method: string = 'GET', headers?: string | null) => + request<{ success: boolean; error: string | null }>('/smart-plugs/rest/test-connection', { + method: 'POST', + body: JSON.stringify({ url, method, headers }), + }), + // Print Queue getQueue: (printerId?: number, status?: string, targetModel?: string) => { const params = new URLSearchParams(); diff --git a/frontend/src/components/AddSmartPlugModal.tsx b/frontend/src/components/AddSmartPlugModal.tsx index e6fdddeae..35b14a1e9 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, Radio, Eye } from 'lucide-react'; +import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio, Eye, Globe } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { api } from '../api/client'; import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client'; @@ -17,7 +17,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { const isEditing = !!plug; // Plug type selection - const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt'>(plug?.plug_type || 'tasmota'); + const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt' | 'rest'>(plug?.plug_type || 'tasmota'); const [name, setName] = useState(plug?.name || ''); // Tasmota fields @@ -42,6 +42,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || ''); const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || ''); const [mqttStateOnValue, setMqttStateOnValue] = useState(plug?.mqtt_state_on_value || ''); + // REST fields + const [restOnUrl, setRestOnUrl] = useState(plug?.rest_on_url || ''); + const [restOnBody, setRestOnBody] = useState(plug?.rest_on_body || ''); + const [restOffUrl, setRestOffUrl] = useState(plug?.rest_off_url || ''); + const [restOffBody, setRestOffBody] = useState(plug?.rest_off_body || ''); + const [restMethod, setRestMethod] = useState(plug?.rest_method || 'POST'); + const [restHeaders, setRestHeaders] = useState(plug?.rest_headers || ''); + const [restStatusUrl, setRestStatusUrl] = useState(plug?.rest_status_url || ''); + const [restStatusPath, setRestStatusPath] = useState(plug?.rest_status_path || ''); + const [restStatusOnValue, setRestStatusOnValue] = useState(plug?.rest_status_on_value || ''); + const [restPowerPath, setRestPowerPath] = useState(plug?.rest_power_path || ''); + const [restEnergyPath, setRestEnergyPath] = useState(plug?.rest_energy_path || ''); // HA energy sensor entities (optional) const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || ''); const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || ''); @@ -321,6 +333,13 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { } } + if (plugType === 'rest') { + if (!restOnUrl.trim() && !restOffUrl.trim()) { + setError(t('smartPlugs.restUrlRequired')); + return; + } + } + const data = { name: name.trim(), plug_type: plugType, @@ -342,6 +361,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null, mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null, mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.trim() || null) : null, + // REST fields + rest_on_url: plugType === 'rest' ? (restOnUrl.trim() || null) : null, + rest_on_body: plugType === 'rest' ? (restOnBody.trim() || null) : null, + rest_off_url: plugType === 'rest' ? (restOffUrl.trim() || null) : null, + rest_off_body: plugType === 'rest' ? (restOffBody.trim() || null) : null, + rest_method: plugType === 'rest' ? restMethod : null, + rest_headers: plugType === 'rest' ? (restHeaders.trim() || null) : null, + rest_status_url: plugType === 'rest' ? (restStatusUrl.trim() || null) : null, + rest_status_path: plugType === 'rest' ? (restStatusPath.trim() || null) : null, + rest_status_on_value: plugType === 'rest' ? (restStatusOnValue.trim() || null) : null, + rest_power_path: plugType === 'rest' ? (restPowerPath.trim() || null) : null, + rest_energy_path: plugType === 'rest' ? (restEnergyPath.trim() || null) : null, username: plugType === 'tasmota' ? (username.trim() || null) : null, password: plugType === 'tasmota' ? (password.trim() || null) : null, printer_id: printerId, @@ -448,6 +479,22 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { MQTT + )} @@ -1074,6 +1121,184 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { )} + {/* REST API Section */} + {plugType === 'rest' && ( +
+ {/* Control Section */} +
+

{t('smartPlugs.restControl')}

+
+ + +
+
+ + { setRestOnUrl(e.target.value); setTestResult(null); }} + placeholder="http://openhab:8080/rest/items/MyPlug" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+ + setRestOnBody(e.target.value)} + placeholder={t('smartPlugs.restBodyHint')} + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+ + { setRestOffUrl(e.target.value); setTestResult(null); }} + placeholder="http://openhab:8080/rest/items/MyPlug" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+ + setRestOffBody(e.target.value)} + placeholder={t('smartPlugs.restBodyHint')} + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+ + {/* Headers Section */} +
+

{t('smartPlugs.restHeaders')} ({t('smartPlugs.optional')})

+
+