Browse Source

Add REST/Webhook smart plug type (#472)

maziggy 1 month ago
parent
commit
914adde5aa

+ 1 - 0
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.
 - **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.
 - **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.
 - **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
 ### 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.
 - **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.

+ 2 - 1
README.md

@@ -117,7 +117,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Scheduled prints (date/time)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
 - Queue Only mode (stage without auto-start)
 - Clear plate confirmation between queued prints (can be disabled in settings for farm workflows)
 - 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
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
 - Energy consumption tracking (per-print kWh and cost)
 - Energy consumption tracking (per-print kWh and cost)
 - HA energy sensor support (for plugs with separate power/energy sensors)
 - HA energy sensor support (for plugs with separate power/energy sensors)

+ 6 - 0
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)
                 mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
                 if mqtt_data and mqtt_data.energy is not None:
                 if mqtt_data and mqtt_data.energy is not None:
                     total_energy_kwh += mqtt_data.energy
                     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_kwh = round(total_energy_kwh, 3)
         total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3)
         total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3)

+ 15 - 0
backend/app/api/routes/smart_plugs.py

@@ -20,6 +20,8 @@ from backend.app.schemas.smart_plug import (
     HASensorEntity,
     HASensorEntity,
     HATestConnectionRequest,
     HATestConnectionRequest,
     HATestConnectionResponse,
     HATestConnectionResponse,
+    RESTTestConnectionRequest,
+    RESTTestConnectionResponse,
     SmartPlugControl,
     SmartPlugControl,
     SmartPlugCreate,
     SmartPlugCreate,
     SmartPlugEnergy,
     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.mqtt_relay import mqtt_relay
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.rest_smart_plug import rest_smart_plug_service
 from backend.app.services.tasmota import tasmota_service
 from backend.app.services.tasmota import tasmota_service
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -341,6 +344,16 @@ async def test_ha_connection(
     return HATestConnectionResponse(**result)
     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])
 @router.get("/ha/entities", response_model=list[HAEntity])
 async def list_ha_entities(
 async def list_ha_entities(
     db: AsyncSession = Depends(get_db),
     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)
         ha_settings = await get_homeassistant_settings(db)
         homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
         homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
         return homeassistant_service
         return homeassistant_service
+    if plug.plug_type == "rest":
+        return rest_smart_plug_service
     return tasmota_service
     return tasmota_service
 
 
 
 

+ 46 - 0
backend/app/core/database.py

@@ -1563,6 +1563,52 @@ async def run_migrations(conn):
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         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
     # Seed default settings keys that must exist on fresh install
     default_settings = [
     default_settings = [
         ("advanced_auth_enabled", "false"),
         ("advanced_auth_enabled", "false"),

+ 6 - 1
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:
 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 HA plugs, configures the service with current settings from DB.
     For MQTT plugs, returns data from the subscription service.
     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":
     if plug.plug_type == "homeassistant":
         from backend.app.api.routes.settings import get_homeassistant_settings
         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
                 "total": mqtt_data.energy,  # Use today as total for per-print calculations
             }
             }
         return None
         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:
     else:
         return await tasmota_service.get_energy(plug)
         return await tasmota_service.get_energy(plug)
 
 

+ 19 - 3
backend/app/models/smart_plug.py

@@ -1,13 +1,13 @@
 from datetime import datetime
 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 sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
 
 
 
 
 class SmartPlug(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"
     __tablename__ = "smart_plugs"
 
 
@@ -15,7 +15,7 @@ class SmartPlug(Base):
     name: Mapped[str] = mapped_column(String(100))
     name: Mapped[str] = mapped_column(String(100))
     ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)  # IPv4/IPv6 (required for Tasmota)
     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")
     plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
     # Home Assistant entity ID (e.g., "switch.printer_plug")
     # Home Assistant entity ID (e.g., "switch.printer_plug")
     ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
     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
     # Legacy multiplier - kept for backward compatibility
     mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Deprecated, use mqtt_power_multiplier
     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)
     # 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)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
 
 

+ 45 - 2
backend/app/schemas/smart_plug.py

@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, model_validator
 
 
 class SmartPlugBase(BaseModel):
 class SmartPlugBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     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")
     # 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}$")
     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
     # Legacy multiplier - kept for backward compatibility
     mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Deprecated, use mqtt_power_multiplier
     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
     printer_id: int | None = None
     enabled: bool = True
     enabled: bool = True
     auto_on: bool = True
     auto_on: bool = True
@@ -81,6 +94,9 @@ class SmartPlugBase(BaseModel):
             # At least one data source must be configured (path is optional)
             # At least one data source must be configured (path is optional)
             if not has_power and not has_energy and not has_state:
             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")
                 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
         return self
 
 
 
 
@@ -90,7 +106,7 @@ class SmartPlugCreate(SmartPlugBase):
 
 
 class SmartPlugUpdate(BaseModel):
 class SmartPlugUpdate(BaseModel):
     name: str | None = None
     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
     ip_address: str | None = None
     ha_entity_id: str | None = None
     ha_entity_id: str | None = None
     # Home Assistant energy sensor entities (optional)
     # Home Assistant energy sensor entities (optional)
@@ -112,6 +128,18 @@ class SmartPlugUpdate(BaseModel):
     mqtt_state_topic: str | None = None
     mqtt_state_topic: str | None = None
     mqtt_state_path: str | None = None
     mqtt_state_path: str | None = None
     mqtt_state_on_value: 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
     printer_id: int | None = None
     enabled: bool | None = None
     enabled: bool | None = None
     auto_on: bool | None = None
     auto_on: bool | None = None
@@ -211,3 +239,18 @@ class HASensorEntity(BaseModel):
     friendly_name: str
     friendly_name: str
     state: str | None = None
     state: str | None = None
     unit_of_measurement: str | None = None  # "W", "kW", "kWh", "Wh"
     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

+ 260 - 0
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()

+ 31 - 0
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.homeassistant import homeassistant_service
 from backend.app.services.printer_manager import printer_manager
 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
 from backend.app.services.tasmota import tasmota_service
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
@@ -36,6 +37,8 @@ class SmartPlugManager:
             # Configure HA service with current settings
             # Configure HA service with current settings
             await self._configure_ha_service(db)
             await self._configure_ha_service(db)
             return homeassistant_service
             return homeassistant_service
+        if plug.plug_type == "rest":
+            return rest_smart_plug_service
         return tasmota_service
         return tasmota_service
 
 
     async def _configure_ha_service(self, db: AsyncSession | None = None):
     async def _configure_ha_service(self, db: AsyncSession | None = None):
@@ -248,6 +251,10 @@ class SmartPlugManager:
                 plug.password,
                 plug.password,
                 printer_id,
                 printer_id,
                 delay_seconds,
                 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
         self._pending_off[plug.id] = task
@@ -262,6 +269,11 @@ class SmartPlugManager:
         password: str | None,
         password: str | None,
         printer_id: int,
         printer_id: int,
         delay_seconds: 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."""
         """Wait and turn off."""
         try:
         try:
@@ -276,6 +288,11 @@ class SmartPlugManager:
                     self.username = username
                     self.username = username
                     self.password = password
                     self.password = password
                     self.name = f"plug_{plug_id}"
                     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()
             plug_info = PlugInfo()
             service = await self.get_service_for_plug(plug_info)
             service = await self.get_service_for_plug(plug_info)
@@ -313,6 +330,10 @@ class SmartPlugManager:
                 plug.password,
                 plug.password,
                 printer_id,
                 printer_id,
                 temp_threshold,
                 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
         self._pending_off[plug.id] = task
@@ -327,6 +348,11 @@ class SmartPlugManager:
         password: str | None,
         password: str | None,
         printer_id: int,
         printer_id: int,
         temp_threshold: 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.
         """Poll temperature until below threshold, then turn off.
 
 
@@ -370,6 +396,11 @@ class SmartPlugManager:
                                 self.username = username
                                 self.username = username
                                 self.password = password
                                 self.password = password
                                 self.name = f"plug_{plug_id}"
                                 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()
                         plug_info = PlugInfo()
                         service = await self.get_service_for_plug(plug_info)
                         service = await self.get_service_for_plug(plug_info)

+ 6 - 0
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["mqtt_state_on_value"] = kwargs.get("mqtt_state_on_value")
             defaults["ip_address"] = None
             defaults["ip_address"] = None
             defaults["ha_entity_id"] = 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:
         else:
             defaults["ip_address"] = "192.168.1.100"
             defaults["ip_address"] = "192.168.1.100"
             defaults["ha_entity_id"] = None
             defaults["ha_entity_id"] = None

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

@@ -825,3 +825,135 @@ class TestSmartPlugsAPI:
         result = response.json()
         result = response.json()
         assert result["mqtt_power_multiplier"] == 0.001
         assert result["mqtt_power_multiplier"] == 0.001
         assert result["mqtt_energy_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

+ 231 - 0
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()

+ 46 - 3
frontend/src/api/client.ts

@@ -1072,7 +1072,7 @@ export interface CloudDevice {
 export interface SmartPlug {
 export interface SmartPlug {
   id: number;
   id: number;
   name: string;
   name: string;
-  plug_type: 'tasmota' | 'homeassistant' | 'mqtt';
+  plug_type: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest';
   ip_address: string | null;  // Required for Tasmota
   ip_address: string | null;  // Required for Tasmota
   ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer")
   ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer")
   // Home Assistant energy sensor entities (optional)
   // Home Assistant energy sensor entities (optional)
@@ -1095,6 +1095,18 @@ export interface SmartPlug {
   mqtt_state_topic: string | null;  // Topic for state data
   mqtt_state_topic: string | null;  // Topic for state data
   mqtt_state_path: string | null;  // e.g., "state_l1" for ON/OFF
   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")
   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;
   printer_id: number | null;
   enabled: boolean;
   enabled: boolean;
   auto_on: boolean;
   auto_on: boolean;
@@ -1127,7 +1139,7 @@ export interface SmartPlug {
 
 
 export interface SmartPlugCreate {
 export interface SmartPlugCreate {
   name: string;
   name: string;
-  plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
+  plug_type?: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest';
   ip_address?: string | null;  // Required for Tasmota
   ip_address?: string | null;  // Required for Tasmota
   ha_entity_id?: string | null;  // Required for Home Assistant
   ha_entity_id?: string | null;  // Required for Home Assistant
   // Home Assistant energy sensor entities (optional)
   // Home Assistant energy sensor entities (optional)
@@ -1150,6 +1162,18 @@ export interface SmartPlugCreate {
   mqtt_state_topic?: string | null;
   mqtt_state_topic?: string | null;
   mqtt_state_path?: string | null;
   mqtt_state_path?: string | null;
   mqtt_state_on_value?: 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;
   printer_id?: number | null;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
@@ -1175,7 +1199,7 @@ export interface SmartPlugCreate {
 
 
 export interface SmartPlugUpdate {
 export interface SmartPlugUpdate {
   name?: string;
   name?: string;
-  plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
+  plug_type?: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest';
   ip_address?: string | null;
   ip_address?: string | null;
   ha_entity_id?: string | null;
   ha_entity_id?: string | null;
   // Home Assistant energy sensor entities (optional)
   // Home Assistant energy sensor entities (optional)
@@ -1197,6 +1221,18 @@ export interface SmartPlugUpdate {
   mqtt_state_topic?: string | null;
   mqtt_state_topic?: string | null;
   mqtt_state_path?: string | null;
   mqtt_state_path?: string | null;
   mqtt_state_on_value?: 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;
   printer_id?: number | null;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
@@ -3362,6 +3398,13 @@ export const api = {
   getHASensorEntities: () =>
   getHASensorEntities: () =>
     request<HASensorEntity[]>('/smart-plugs/ha/sensors'),
     request<HASensorEntity[]>('/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
   // Print Queue
   getQueue: (printerId?: number, status?: string, targetModel?: string) => {
   getQueue: (printerId?: number, status?: string, targetModel?: string) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();

+ 227 - 2
frontend/src/components/AddSmartPlugModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, 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 { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
@@ -17,7 +17,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const isEditing = !!plug;
   const isEditing = !!plug;
 
 
   // Plug type selection
   // 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 || '');
   const [name, setName] = useState(plug?.name || '');
   // Tasmota fields
   // Tasmota fields
@@ -42,6 +42,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || '');
   const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || '');
   const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || '');
   const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || '');
   const [mqttStateOnValue, setMqttStateOnValue] = useState(plug?.mqtt_state_on_value || '');
   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)
   // HA energy sensor entities (optional)
   const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
   const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
   const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_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 = {
     const data = {
       name: name.trim(),
       name: name.trim(),
       plug_type: plugType,
       plug_type: plugType,
@@ -342,6 +361,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null,
       mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null,
       mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null,
       mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null,
       mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.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,
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
       printer_id: printerId,
@@ -448,6 +479,22 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                 <Radio className="w-4 h-4" />
                 <Radio className="w-4 h-4" />
                 MQTT
                 MQTT
               </button>
               </button>
+              <button
+                type="button"
+                onClick={() => {
+                  setPlugType('rest');
+                  setTestResult(null);
+                  setError(null);
+                }}
+                className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
+                  plugType === 'rest'
+                    ? 'bg-bambu-green text-white'
+                    : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
+                }`}
+              >
+                <Globe className="w-4 h-4" />
+                REST
+              </button>
             </div>
             </div>
           )}
           )}
 
 
@@ -1074,6 +1121,184 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </div>
             </div>
           )}
           )}
 
 
+          {/* REST API Section */}
+          {plugType === 'rest' && (
+            <div className="space-y-3">
+              {/* Control Section */}
+              <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+                <p className="text-white font-medium text-sm">{t('smartPlugs.restControl')}</p>
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restMethod')}</label>
+                  <select
+                    value={restMethod}
+                    onChange={(e) => setRestMethod(e.target.value)}
+                    className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  >
+                    <option value="GET">GET</option>
+                    <option value="POST">POST</option>
+                    <option value="PUT">PUT</option>
+                    <option value="PATCH">PATCH</option>
+                  </select>
+                </div>
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restOnUrl')}</label>
+                  <input
+                    type="text"
+                    value={restOnUrl}
+                    onChange={(e) => { 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"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restOnBody')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></label>
+                  <input
+                    type="text"
+                    value={restOnBody}
+                    onChange={(e) => 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"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restOffUrl')}</label>
+                  <input
+                    type="text"
+                    value={restOffUrl}
+                    onChange={(e) => { 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"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restOffBody')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></label>
+                  <input
+                    type="text"
+                    value={restOffBody}
+                    onChange={(e) => 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"
+                  />
+                </div>
+              </div>
+
+              {/* Headers Section */}
+              <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+                <p className="text-white font-medium text-sm">{t('smartPlugs.restHeaders')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
+                <div>
+                  <textarea
+                    value={restHeaders}
+                    onChange={(e) => setRestHeaders(e.target.value)}
+                    placeholder={t('smartPlugs.restHeadersHint')}
+                    rows={2}
+                    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 font-mono text-sm"
+                  />
+                </div>
+              </div>
+
+              {/* Status Polling Section (optional) */}
+              <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+                <p className="text-white font-medium text-sm">{t('smartPlugs.stateMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restStatusUrl')}</label>
+                  <input
+                    type="text"
+                    value={restStatusUrl}
+                    onChange={(e) => setRestStatusUrl(e.target.value)}
+                    placeholder={t('smartPlugs.restStatusHint')}
+                    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"
+                  />
+                </div>
+                <div className="grid grid-cols-2 gap-3">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restStatusPath')}</label>
+                    <input
+                      type="text"
+                      value={restStatusPath}
+                      onChange={(e) => setRestStatusPath(e.target.value)}
+                      placeholder={t('smartPlugs.restPathHint')}
+                      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"
+                    />
+                  </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restStatusOnValue')}</label>
+                    <input
+                      type="text"
+                      value={restStatusOnValue}
+                      onChange={(e) => setRestStatusOnValue(e.target.value)}
+                      placeholder="ON"
+                      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"
+                    />
+                  </div>
+                </div>
+              </div>
+
+              {/* Energy Monitoring (optional) */}
+              <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+                <p className="text-white font-medium text-sm">{t('smartPlugs.energyMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
+                <div className="grid grid-cols-2 gap-3">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restPowerPath')}</label>
+                    <input
+                      type="text"
+                      value={restPowerPath}
+                      onChange={(e) => setRestPowerPath(e.target.value)}
+                      placeholder={t('smartPlugs.restPathHint')}
+                      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"
+                    />
+                  </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyPath')}</label>
+                    <input
+                      type="text"
+                      value={restEnergyPath}
+                      onChange={(e) => setRestEnergyPath(e.target.value)}
+                      placeholder={t('smartPlugs.restPathHint')}
+                      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"
+                    />
+                  </div>
+                </div>
+                <p className="text-xs text-bambu-gray">
+                  {t('smartPlugs.restEnergyHint')}
+                </p>
+              </div>
+
+              {/* Test Connection */}
+              {(restOnUrl.trim() || restOffUrl.trim()) && (
+                <div className="flex gap-2">
+                  <Button
+                    type="button"
+                    variant="secondary"
+                    onClick={async () => {
+                      setTestResult(null);
+                      try {
+                        const url = restOnUrl.trim() || restOffUrl.trim();
+                        const result = await api.testRESTConnection(url, restMethod, restHeaders.trim() || null);
+                        setTestResult({ success: result.success });
+                        if (!result.success) {
+                          setError(result.error || t('smartPlugs.addSmartPlug.connectionFailed'));
+                        }
+                      } catch {
+                        setTestResult({ success: false });
+                        setError(t('smartPlugs.addSmartPlug.connectionFailed'));
+                      }
+                    }}
+                    className="w-full"
+                  >
+                    <Wifi className="w-4 h-4" />
+                    {t('smartPlugs.testConnection')}
+                  </Button>
+                </div>
+              )}
+              {testResult && (
+                <div className={`flex items-center gap-2 text-sm ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
+                  {testResult.success ? <CheckCircle className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />}
+                  {testResult.success ? t('smartPlugs.connectionSuccess') : t('smartPlugs.addSmartPlug.connectionFailed')}
+                </div>
+              )}
+            </div>
+          )}
+
           {/* IP Address - only show for Tasmota */}
           {/* IP Address - only show for Tasmota */}
           {plugType === 'tasmota' && (
           {plugType === 'tasmota' && (
             <div>
             <div>

+ 12 - 3
frontend/src/components/SmartPlugCard.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye } from 'lucide-react';
+import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye, Globe } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
@@ -133,6 +133,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                   <Radio className={`w-5 h-5 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
                   <Radio className={`w-5 h-5 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
                 ) : plug.plug_type === 'homeassistant' ? (
                 ) : plug.plug_type === 'homeassistant' ? (
                   <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                   <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+                ) : plug.plug_type === 'rest' ? (
+                  <Globe className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                 ) : (
                 ) : (
                   <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                   <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                 )}
                 )}
@@ -141,9 +143,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 <h3 className="font-medium text-white truncate">{plug.name}</h3>
                 <h3 className="font-medium text-white truncate">{plug.name}</h3>
                 <p
                 <p
                   className="text-sm text-bambu-gray truncate"
                   className="text-sm text-bambu-gray truncate"
-                  title={plug.plug_type === 'mqtt' ? plug.mqtt_topic ?? undefined : plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.ip_address ?? undefined}
+                  title={plug.plug_type === 'mqtt' ? plug.mqtt_topic ?? undefined : plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.plug_type === 'rest' ? plug.rest_on_url ?? plug.rest_off_url ?? undefined : plug.ip_address ?? undefined}
                 >
                 >
-                  {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
+                  {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.plug_type === 'rest' ? (plug.rest_on_url || plug.rest_off_url) : plug.ip_address}
                 </p>
                 </p>
               </div>
               </div>
             </div>
             </div>
@@ -165,6 +167,13 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                     {isReachable ? (status?.state || '?') : t('smartPlugs.offline')}
                     {isReachable ? (status?.state || '?') : t('smartPlugs.offline')}
                   </span>
                   </span>
                 </div>
                 </div>
+              ) : plug.plug_type === 'rest' ? (
+                <div className="flex items-center gap-1 text-sm">
+                  <span className="px-1 py-0.5 bg-purple-500/20 text-purple-400 text-[10px] font-medium rounded">REST</span>
+                  <span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>
+                    {isReachable ? (status?.state || '?') : t('smartPlugs.offline')}
+                  </span>
+                </div>
               ) : isReachable ? (
               ) : isReachable ? (
                 <div className="flex items-center gap-1 text-sm">
                 <div className="flex items-center gap-1 text-sm">
                   <Wifi className="w-4 h-4 text-status-ok" />
                   <Wifi className="w-4 h-4 text-status-ok" />

+ 21 - 0
frontend/src/i18n/locales/de.ts

@@ -3837,6 +3837,27 @@ export default {
     mqttPowerHint: 'JSON-Pfad extrahiert Wert aus JSON-Payload (z.B. "power_l1"). Leer lassen wenn Topic rohe numerische Werte sendet.\nMultiplikator 0.001 für mW→W, 1000 für kW→W verwenden.',
     mqttPowerHint: 'JSON-Pfad extrahiert Wert aus JSON-Payload (z.B. "power_l1"). Leer lassen wenn Topic rohe numerische Werte sendet.\nMultiplikator 0.001 für mW→W, 1000 für kW→W verwenden.',
     mqttEnergyHint: 'JSON-Pfad extrahiert Wert aus JSON-Payload. Leer lassen für rohe Werte.\nMultiplikator 0.001 für Wh→kWh, 1000 für MWh→kWh verwenden.',
     mqttEnergyHint: 'JSON-Pfad extrahiert Wert aus JSON-Payload. Leer lassen für rohe Werte.\nMultiplikator 0.001 für Wh→kWh, 1000 für MWh→kWh verwenden.',
     mqttStateHint: 'JSON-Pfad extrahiert Wert aus JSON-Payload. Leer lassen für rohe Werte.\nEIN-Wert: der genaue String der "EIN" bedeutet. Leer lassen für Auto-Erkennung (ON, true, 1).',
     mqttStateHint: 'JSON-Pfad extrahiert Wert aus JSON-Payload. Leer lassen für rohe Werte.\nEIN-Wert: der genaue String der "EIN" bedeutet. Leer lassen für Auto-Erkennung (ON, true, 1).',
+    // REST smart plug
+    restControl: 'Control',
+    restOnUrl: 'Turn ON URL',
+    restOffUrl: 'Turn OFF URL',
+    restOnBody: 'ON Request Body',
+    restOffBody: 'OFF Request Body',
+    restMethod: 'HTTP Method',
+    restHeaders: 'Custom Headers (JSON)',
+    restStatusUrl: 'Status URL',
+    restStatusPath: 'State JSON Path',
+    restStatusOnValue: 'ON Value',
+    restPowerPath: 'Power JSON Path',
+    restEnergyPath: 'Energy JSON Path',
+    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
+    restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
+    restBodyHint: 'e.g. ON, {"state": "on"}',
+    restStatusHint: 'URL to poll for current state',
+    restPathHint: 'e.g. state or data.power.status',
+    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    testConnection: 'Test Connection',
+    connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: 'Keine Schalter in der Schaltleiste',
     noSwitchesInSwitchbar: 'Keine Schalter in der Schaltleiste',
     enableSwitchbarHint: '"In Schaltleiste anzeigen" unter Einstellungen > Smart Plugs aktivieren',
     enableSwitchbarHint: '"In Schaltleiste anzeigen" unter Einstellungen > Smart Plugs aktivieren',
   },
   },

+ 21 - 0
frontend/src/i18n/locales/en.ts

@@ -3843,6 +3843,27 @@ export default {
     mqttPowerHint: 'JSON path extracts value from JSON payload (e.g., "power_l1"). Leave empty if topic publishes raw numeric values.\nUse multiplier 0.001 for mW→W, 1000 for kW→W.',
     mqttPowerHint: 'JSON path extracts value from JSON payload (e.g., "power_l1"). Leave empty if topic publishes raw numeric values.\nUse multiplier 0.001 for mW→W, 1000 for kW→W.',
     mqttEnergyHint: 'JSON path extracts value from JSON payload. Leave empty for raw values.\nUse multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh.',
     mqttEnergyHint: 'JSON path extracts value from JSON payload. Leave empty for raw values.\nUse multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh.',
     mqttStateHint: 'JSON path extracts value from JSON payload. Leave empty for raw values.\nON value: the exact string that means "ON". Leave empty for auto-detect (ON, true, 1).',
     mqttStateHint: 'JSON path extracts value from JSON payload. Leave empty for raw values.\nON value: the exact string that means "ON". Leave empty for auto-detect (ON, true, 1).',
+    // REST smart plug
+    restControl: 'Control',
+    restOnUrl: 'Turn ON URL',
+    restOffUrl: 'Turn OFF URL',
+    restOnBody: 'ON Request Body',
+    restOffBody: 'OFF Request Body',
+    restMethod: 'HTTP Method',
+    restHeaders: 'Custom Headers (JSON)',
+    restStatusUrl: 'Status URL',
+    restStatusPath: 'State JSON Path',
+    restStatusOnValue: 'ON Value',
+    restPowerPath: 'Power JSON Path',
+    restEnergyPath: 'Energy JSON Path',
+    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
+    restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
+    restBodyHint: 'e.g. ON, {"state": "on"}',
+    restStatusHint: 'URL to poll for current state',
+    restPathHint: 'e.g. state or data.power.status',
+    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    testConnection: 'Test Connection',
+    connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: 'No switches in switchbar',
     noSwitchesInSwitchbar: 'No switches in switchbar',
     enableSwitchbarHint: 'Enable "Show in Switchbar" in Settings > Smart Plugs',
     enableSwitchbarHint: 'Enable "Show in Switchbar" in Settings > Smart Plugs',
   },
   },

+ 21 - 0
frontend/src/i18n/locales/fr.ts

@@ -3829,6 +3829,27 @@ export default {
     mqttPowerHint: 'Le chemin JSON extrait la valeur du payload JSON (ex: "power_l1"). Laisser vide si le topic publie des valeurs numériques brutes.\nUtiliser le multiplicateur 0.001 pour mW→W, 1000 pour kW→W.',
     mqttPowerHint: 'Le chemin JSON extrait la valeur du payload JSON (ex: "power_l1"). Laisser vide si le topic publie des valeurs numériques brutes.\nUtiliser le multiplicateur 0.001 pour mW→W, 1000 pour kW→W.',
     mqttEnergyHint: 'Le chemin JSON extrait la valeur du payload JSON. Laisser vide pour les valeurs brutes.\nUtiliser le multiplicateur 0.001 pour Wh→kWh, 1000 pour MWh→kWh.',
     mqttEnergyHint: 'Le chemin JSON extrait la valeur du payload JSON. Laisser vide pour les valeurs brutes.\nUtiliser le multiplicateur 0.001 pour Wh→kWh, 1000 pour MWh→kWh.',
     mqttStateHint: 'Le chemin JSON extrait la valeur du payload JSON. Laisser vide pour les valeurs brutes.\nValeur ON : la chaîne exacte signifiant "ON". Laisser vide pour la détection auto (ON, true, 1).',
     mqttStateHint: 'Le chemin JSON extrait la valeur du payload JSON. Laisser vide pour les valeurs brutes.\nValeur ON : la chaîne exacte signifiant "ON". Laisser vide pour la détection auto (ON, true, 1).',
+    // REST smart plug
+    restControl: 'Control',
+    restOnUrl: 'Turn ON URL',
+    restOffUrl: 'Turn OFF URL',
+    restOnBody: 'ON Request Body',
+    restOffBody: 'OFF Request Body',
+    restMethod: 'HTTP Method',
+    restHeaders: 'Custom Headers (JSON)',
+    restStatusUrl: 'Status URL',
+    restStatusPath: 'State JSON Path',
+    restStatusOnValue: 'ON Value',
+    restPowerPath: 'Power JSON Path',
+    restEnergyPath: 'Energy JSON Path',
+    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
+    restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
+    restBodyHint: 'e.g. ON, {"state": "on"}',
+    restStatusHint: 'URL to poll for current state',
+    restPathHint: 'e.g. state or data.power.status',
+    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    testConnection: 'Test Connection',
+    connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: 'Aucun commutateur dans la barre',
     noSwitchesInSwitchbar: 'Aucun commutateur dans la barre',
     enableSwitchbarHint: 'Activez "Afficher dans la barre de commutateurs" dans Paramètres > Smart Plugs',
     enableSwitchbarHint: 'Activez "Afficher dans la barre de commutateurs" dans Paramètres > Smart Plugs',
   },
   },

+ 21 - 0
frontend/src/i18n/locales/it.ts

@@ -3828,6 +3828,27 @@ export default {
     mqttPowerHint: 'Il percorso JSON estrae il valore dal payload JSON (es. "power_l1"). Lascia vuoto se il topic pubblica valori numerici grezzi.\nUsa moltiplicatore 0.001 per mW→W, 1000 per kW→W.',
     mqttPowerHint: 'Il percorso JSON estrae il valore dal payload JSON (es. "power_l1"). Lascia vuoto se il topic pubblica valori numerici grezzi.\nUsa moltiplicatore 0.001 per mW→W, 1000 per kW→W.',
     mqttEnergyHint: 'Il percorso JSON estrae il valore dal payload JSON. Lascia vuoto per valori grezzi.\nUsa moltiplicatore 0.001 per Wh→kWh, 1000 per MWh→kWh.',
     mqttEnergyHint: 'Il percorso JSON estrae il valore dal payload JSON. Lascia vuoto per valori grezzi.\nUsa moltiplicatore 0.001 per Wh→kWh, 1000 per MWh→kWh.',
     mqttStateHint: 'Il percorso JSON estrae il valore dal payload JSON. Lascia vuoto per valori grezzi.\nValore ON: la stringa esatta che significa "ON". Lascia vuoto per rilevamento auto (ON, true, 1).',
     mqttStateHint: 'Il percorso JSON estrae il valore dal payload JSON. Lascia vuoto per valori grezzi.\nValore ON: la stringa esatta che significa "ON". Lascia vuoto per rilevamento auto (ON, true, 1).',
+    // REST smart plug
+    restControl: 'Control',
+    restOnUrl: 'Turn ON URL',
+    restOffUrl: 'Turn OFF URL',
+    restOnBody: 'ON Request Body',
+    restOffBody: 'OFF Request Body',
+    restMethod: 'HTTP Method',
+    restHeaders: 'Custom Headers (JSON)',
+    restStatusUrl: 'Status URL',
+    restStatusPath: 'State JSON Path',
+    restStatusOnValue: 'ON Value',
+    restPowerPath: 'Power JSON Path',
+    restEnergyPath: 'Energy JSON Path',
+    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
+    restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
+    restBodyHint: 'e.g. ON, {"state": "on"}',
+    restStatusHint: 'URL to poll for current state',
+    restPathHint: 'e.g. state or data.power.status',
+    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    testConnection: 'Test Connection',
+    connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: 'Nessun interruttore nella barra',
     noSwitchesInSwitchbar: 'Nessun interruttore nella barra',
     enableSwitchbarHint: 'Abilita "Mostra nella barra interruttori" in Impostazioni > Smart Plugs',
     enableSwitchbarHint: 'Abilita "Mostra nella barra interruttori" in Impostazioni > Smart Plugs',
   },
   },

+ 21 - 0
frontend/src/i18n/locales/ja.ts

@@ -3841,6 +3841,27 @@ export default {
     mqttPowerHint: 'JSONパスはJSONペイロードから値を抽出します(例: "power_l1")。トピックが生の数値を送信する場合は空のままにしてください。\n乗数: mW→Wは0.001、kW→Wは1000を使用。',
     mqttPowerHint: 'JSONパスはJSONペイロードから値を抽出します(例: "power_l1")。トピックが生の数値を送信する場合は空のままにしてください。\n乗数: mW→Wは0.001、kW→Wは1000を使用。',
     mqttEnergyHint: 'JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。\n乗数: Wh→kWhは0.001、MWh→kWhは1000を使用。',
     mqttEnergyHint: 'JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。\n乗数: Wh→kWhは0.001、MWh→kWhは1000を使用。',
     mqttStateHint: 'JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。\nON値: "ON"を意味する正確な文字列。自動検出(ON、true、1)の場合は空のままにしてください。',
     mqttStateHint: 'JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。\nON値: "ON"を意味する正確な文字列。自動検出(ON、true、1)の場合は空のままにしてください。',
+    // REST smart plug
+    restControl: 'Control',
+    restOnUrl: 'Turn ON URL',
+    restOffUrl: 'Turn OFF URL',
+    restOnBody: 'ON Request Body',
+    restOffBody: 'OFF Request Body',
+    restMethod: 'HTTP Method',
+    restHeaders: 'Custom Headers (JSON)',
+    restStatusUrl: 'Status URL',
+    restStatusPath: 'State JSON Path',
+    restStatusOnValue: 'ON Value',
+    restPowerPath: 'Power JSON Path',
+    restEnergyPath: 'Energy JSON Path',
+    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
+    restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
+    restBodyHint: 'e.g. ON, {"state": "on"}',
+    restStatusHint: 'URL to poll for current state',
+    restPathHint: 'e.g. state or data.power.status',
+    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    testConnection: 'Test Connection',
+    connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: 'スイッチバーにスイッチがありません',
     noSwitchesInSwitchbar: 'スイッチバーにスイッチがありません',
     enableSwitchbarHint: '設定 > スマートプラグで「スイッチバーに表示」を有効にしてください',
     enableSwitchbarHint: '設定 > スマートプラグで「スイッチバーに表示」を有効にしてください',
   },
   },

+ 21 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3828,6 +3828,27 @@ export default {
     mqttPowerHint: 'O caminho JSON extrai o valor do payload JSON (ex: "power_l1"). Deixe vazio se o tópico publica valores numéricos brutos.\nUse multiplicador 0.001 para mW→W, 1000 para kW→W.',
     mqttPowerHint: 'O caminho JSON extrai o valor do payload JSON (ex: "power_l1"). Deixe vazio se o tópico publica valores numéricos brutos.\nUse multiplicador 0.001 para mW→W, 1000 para kW→W.',
     mqttEnergyHint: 'O caminho JSON extrai o valor do payload JSON. Deixe vazio para valores brutos.\nUse multiplicador 0.001 para Wh→kWh, 1000 para MWh→kWh.',
     mqttEnergyHint: 'O caminho JSON extrai o valor do payload JSON. Deixe vazio para valores brutos.\nUse multiplicador 0.001 para Wh→kWh, 1000 para MWh→kWh.',
     mqttStateHint: 'O caminho JSON extrai o valor do payload JSON. Deixe vazio para valores brutos.\nValor ON: a string exata que significa "ON". Deixe vazio para detecção automática (ON, true, 1).',
     mqttStateHint: 'O caminho JSON extrai o valor do payload JSON. Deixe vazio para valores brutos.\nValor ON: a string exata que significa "ON". Deixe vazio para detecção automática (ON, true, 1).',
+    // REST smart plug
+    restControl: 'Control',
+    restOnUrl: 'Turn ON URL',
+    restOffUrl: 'Turn OFF URL',
+    restOnBody: 'ON Request Body',
+    restOffBody: 'OFF Request Body',
+    restMethod: 'HTTP Method',
+    restHeaders: 'Custom Headers (JSON)',
+    restStatusUrl: 'Status URL',
+    restStatusPath: 'State JSON Path',
+    restStatusOnValue: 'ON Value',
+    restPowerPath: 'Power JSON Path',
+    restEnergyPath: 'Energy JSON Path',
+    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
+    restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
+    restBodyHint: 'e.g. ON, {"state": "on"}',
+    restStatusHint: 'URL to poll for current state',
+    restPathHint: 'e.g. state or data.power.status',
+    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    testConnection: 'Test Connection',
+    connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: 'Nenhum interruptor na barra',
     noSwitchesInSwitchbar: 'Nenhum interruptor na barra',
     enableSwitchbarHint: 'Ative "Mostrar na barra de interruptores" em Configurações > Smart Plugs',
     enableSwitchbarHint: 'Ative "Mostrar na barra de interruptores" em Configurações > Smart Plugs',
   },
   },

+ 21 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3828,6 +3828,27 @@ export default {
     mqttPowerHint: 'JSON路径从JSON负载中提取值(例如"power_l1")。如果主题发布原始数值,请留空。\n乘数:mW→W使用0.001,kW→W使用1000。',
     mqttPowerHint: 'JSON路径从JSON负载中提取值(例如"power_l1")。如果主题发布原始数值,请留空。\n乘数:mW→W使用0.001,kW→W使用1000。',
     mqttEnergyHint: 'JSON路径从JSON负载中提取值。原始值请留空。\n乘数:Wh→kWh使用0.001,MWh→kWh使用1000。',
     mqttEnergyHint: 'JSON路径从JSON负载中提取值。原始值请留空。\n乘数:Wh→kWh使用0.001,MWh→kWh使用1000。',
     mqttStateHint: 'JSON路径从JSON负载中提取值。原始值请留空。\nON值:表示"ON"的确切字符串。留空以自动检测(ON、true、1)。',
     mqttStateHint: 'JSON路径从JSON负载中提取值。原始值请留空。\nON值:表示"ON"的确切字符串。留空以自动检测(ON、true、1)。',
+    // REST smart plug
+    restControl: 'Control',
+    restOnUrl: 'Turn ON URL',
+    restOffUrl: 'Turn OFF URL',
+    restOnBody: 'ON Request Body',
+    restOffBody: 'OFF Request Body',
+    restMethod: 'HTTP Method',
+    restHeaders: 'Custom Headers (JSON)',
+    restStatusUrl: 'Status URL',
+    restStatusPath: 'State JSON Path',
+    restStatusOnValue: 'ON Value',
+    restPowerPath: 'Power JSON Path',
+    restEnergyPath: 'Energy JSON Path',
+    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
+    restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
+    restBodyHint: 'e.g. ON, {"state": "on"}',
+    restStatusHint: 'URL to poll for current state',
+    restPathHint: 'e.g. state or data.power.status',
+    restEnergyHint: 'JSON paths to extract power (watts) and energy (kWh) from the status response.',
+    testConnection: 'Test Connection',
+    connectionSuccess: 'Connection successful',
     noSwitchesInSwitchbar: '开关栏中没有开关',
     noSwitchesInSwitchbar: '开关栏中没有开关',
     enableSwitchbarHint: '在设置 > 智能插座中启用"在开关栏显示"',
     enableSwitchbarHint: '在设置 > 智能插座中启用"在开关栏显示"',
   },
   },

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


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-vkI-ayst.js"></script>
+    <script type="module" crossorigin src="/assets/index-CHZDJElS.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Bz3PqAxB.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Bz3PqAxB.css">
   </head>
   </head>
   <body>
   <body>

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