Add REST/Webhook smart plug type (#472)

This commit is contained in:
maziggy 2026-04-01 10:06:01 +02:00
parent f228a8b549
commit 914adde5aa
25 changed files with 1430 additions and 213 deletions

View file

@ -12,6 +12,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Per-User Statistics Filtering** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — Admins can now filter the Statistics page by user. A user dropdown appears in the stats header for users with the new `stats:filter_by_user` permission (Administrators only by default). Filter by a specific user to see their prints, filament usage, and costs, or select "No User (System)" to view prints without user attribution (e.g. slicer-initiated or pre-auth prints). The filter applies to all stats widgets and exports. Requested by @3823u44238.
- **Bulk Printer Actions** ([#825](https://github.com/maziggy/bambuddy/issues/825)) — Select multiple printer cards and apply bulk actions from a floating toolbar. Toggle selection mode from the header, then click cards to select. Use "Select All", "Select by State" (printing, paused, finished, idle, error, offline), or "Select by Location" to quickly pick printers. Available actions: Stop, Pause, Resume, Clear Notifications, and Clear Bed — each button is smart-enabled based on the selected printers' current states. Confirmation modals for destructive actions (Stop, Pause, Clear Bed). The status summary bar now shows all printer states (printing, paused, finished, idle, error, offline). Requested by @therevoman.
- **Prefer Lowest Remaining Filament** ([#805](https://github.com/maziggy/bambuddy/issues/805)) — New optional setting in Settings → Filament that prefers AMS spools with the lowest remaining filament during auto-matching. When multiple spools match the same type and color, the one with the least filament remaining is selected first. Helps consume partial spools before starting new ones. Applies to queue scheduling, print modal, and multi-printer mapping. Unknown remain values (e.g. external spools without sensors) are treated as full. Disabled by default. Requested by @Mofoss.
- **REST/Webhook Smart Plug Type** ([#472](https://github.com/maziggy/bambuddy/issues/472)) — New "REST" smart plug type for controlling power via generic HTTP APIs. Works with any home automation platform that has an HTTP endpoint (openHAB, ioBroker, FHEM, Node-RED, etc.). Configure separate ON/OFF URLs with custom HTTP methods (GET/POST/PUT/PATCH), request bodies, and headers. Optional status polling via a GET endpoint with JSON path extraction for state, power, and energy monitoring. Fully controllable — supports auto on/off with prints, daily scheduling, sidebar quick-toggle, and power alerts. Requested by @Percy2Live.
### Improved
- **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.

View file

@ -117,7 +117,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- Scheduled prints (date/time)
- Queue Only mode (stage without auto-start)
- Clear plate confirmation between queued prints (can be disabled in settings for farm workflows)
- Smart plug integration (Tasmota, Home Assistant, MQTT)
- Smart plug integration (Tasmota, Home Assistant, MQTT, REST/Webhook)
- REST smart plugs: Control any device with an HTTP API (openHAB, ioBroker, FHEM, Node-RED)
- MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
- Energy consumption tracking (per-print kWh and cost)
- HA energy sensor support (for plugs with separate power/energy sensors)

View file

@ -797,6 +797,12 @@ async def get_archive_stats(
mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
if mqtt_data and mqtt_data.energy is not None:
total_energy_kwh += mqtt_data.energy
elif plug.plug_type == "rest":
from backend.app.services.rest_smart_plug import rest_smart_plug_service
energy = await rest_smart_plug_service.get_energy(plug)
if energy and energy.get("today") is not None:
total_energy_kwh += energy["today"]
total_energy_kwh = round(total_energy_kwh, 3)
total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3)

View file

@ -20,6 +20,8 @@ from backend.app.schemas.smart_plug import (
HASensorEntity,
HATestConnectionRequest,
HATestConnectionResponse,
RESTTestConnectionRequest,
RESTTestConnectionResponse,
SmartPlugControl,
SmartPlugCreate,
SmartPlugEnergy,
@ -33,6 +35,7 @@ from backend.app.services.homeassistant import homeassistant_service
from backend.app.services.mqtt_relay import mqtt_relay
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import printer_manager
from backend.app.services.rest_smart_plug import rest_smart_plug_service
from backend.app.services.tasmota import tasmota_service
logger = logging.getLogger(__name__)
@ -341,6 +344,16 @@ async def test_ha_connection(
return HATestConnectionResponse(**result)
@router.post("/rest/test-connection", response_model=RESTTestConnectionResponse)
async def test_rest_connection(
request: RESTTestConnectionRequest,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
):
"""Test connection to a REST/HTTP endpoint."""
result = await rest_smart_plug_service.test_connection(request.url, request.method, request.headers)
return RESTTestConnectionResponse(**result)
@router.get("/ha/entities", response_model=list[HAEntity])
async def list_ha_entities(
db: AsyncSession = Depends(get_db),
@ -557,6 +570,8 @@ async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):
ha_settings = await get_homeassistant_settings(db)
homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
return homeassistant_service
if plug.plug_type == "rest":
return rest_smart_plug_service
return tasmota_service

View file

@ -1563,6 +1563,52 @@ async def run_migrations(conn):
except OperationalError:
pass # Already applied
# Migration: Add REST/Webhook smart plug fields
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_on_url VARCHAR(500)"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_on_body TEXT"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_off_url VARCHAR(500)"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_off_body TEXT"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_method VARCHAR(10)"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_headers TEXT"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_status_url VARCHAR(500)"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_status_path VARCHAR(200)"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_status_on_value VARCHAR(50)"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_power_path VARCHAR(200)"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN rest_energy_path VARCHAR(200)"))
except OperationalError:
pass # Already applied
# Seed default settings keys that must exist on fresh install
default_settings = [
("advanced_auth_enabled", "false"),

View file

@ -308,10 +308,11 @@ _expected_prints_cleanup_task: asyncio.Task | None = None
async def _get_plug_energy(plug, db) -> dict | None:
"""Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
"""Get energy from plug regardless of type (Tasmota, Home Assistant, MQTT, or REST).
For HA plugs, configures the service with current settings from DB.
For MQTT plugs, returns data from the subscription service.
For REST plugs, polls the status URL with JSON path extraction.
"""
if plug.plug_type == "homeassistant":
from backend.app.api.routes.settings import get_homeassistant_settings
@ -330,6 +331,10 @@ async def _get_plug_energy(plug, db) -> dict | None:
"total": mqtt_data.energy, # Use today as total for per-print calculations
}
return None
elif plug.plug_type == "rest":
from backend.app.services.rest_smart_plug import rest_smart_plug_service
return await rest_smart_plug_service.get_energy(plug)
else:
return await tasmota_service.get_energy(plug)

View file

@ -1,13 +1,13 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class SmartPlug(Base):
"""Smart plug for printer power control (Tasmota, Home Assistant, or MQTT)."""
"""Smart plug for printer power control (Tasmota, Home Assistant, MQTT, or REST)."""
__tablename__ = "smart_plugs"
@ -15,7 +15,7 @@ class SmartPlug(Base):
name: Mapped[str] = mapped_column(String(100))
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) # IPv4/IPv6 (required for Tasmota)
# Plug type: "tasmota" (default), "homeassistant", or "mqtt"
# Plug type: "tasmota" (default), "homeassistant", "mqtt", or "rest"
plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
# Home Assistant entity ID (e.g., "switch.printer_plug")
ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
@ -50,6 +50,22 @@ class SmartPlug(Base):
# Legacy multiplier - kept for backward compatibility
mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Deprecated, use mqtt_power_multiplier
# REST/Webhook plug fields (required when plug_type="rest")
# Control URLs
rest_on_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # Full URL to turn ON
rest_on_body: Mapped[str | None] = mapped_column(Text, nullable=True) # Request body for ON
rest_off_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # Full URL to turn OFF
rest_off_body: Mapped[str | None] = mapped_column(Text, nullable=True) # Request body for OFF
rest_method: Mapped[str | None] = mapped_column(String(10), nullable=True) # HTTP method: POST, PUT, GET
rest_headers: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string of custom headers
# Status polling (optional)
rest_status_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # GET endpoint for state
rest_status_path: Mapped[str | None] = mapped_column(String(200), nullable=True) # JSON path to state value
rest_status_on_value: Mapped[str | None] = mapped_column(String(50), nullable=True) # What value means ON
# Energy monitoring (optional, extracted from status response)
rest_power_path: Mapped[str | None] = mapped_column(String(200), nullable=True) # JSON path for power (watts)
rest_energy_path: Mapped[str | None] = mapped_column(String(200), nullable=True) # JSON path for energy (kWh)
# Link to printer (multiple plugs/scripts can be linked to one printer)
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)

View file

@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, model_validator
class SmartPlugBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
plug_type: Literal["tasmota", "homeassistant", "mqtt"] = "tasmota"
plug_type: Literal["tasmota", "homeassistant", "mqtt", "rest"] = "tasmota"
# Tasmota fields (required when plug_type="tasmota")
ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
@ -44,6 +44,19 @@ class SmartPlugBase(BaseModel):
# Legacy multiplier - kept for backward compatibility
mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Deprecated, use mqtt_power_multiplier
# REST/Webhook fields (required when plug_type="rest")
rest_on_url: str | None = Field(default=None, max_length=500)
rest_on_body: str | None = None
rest_off_url: str | None = Field(default=None, max_length=500)
rest_off_body: str | None = None
rest_method: Literal["GET", "POST", "PUT", "PATCH"] | None = None
rest_headers: str | None = None # JSON string of custom headers
rest_status_url: str | None = Field(default=None, max_length=500)
rest_status_path: str | None = Field(default=None, max_length=200)
rest_status_on_value: str | None = Field(default=None, max_length=50)
rest_power_path: str | None = Field(default=None, max_length=200)
rest_energy_path: str | None = Field(default=None, max_length=200)
printer_id: int | None = None
enabled: bool = True
auto_on: bool = True
@ -81,6 +94,9 @@ class SmartPlugBase(BaseModel):
# At least one data source must be configured (path is optional)
if not has_power and not has_energy and not has_state:
raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring")
if self.plug_type == "rest":
if not self.rest_on_url and not self.rest_off_url:
raise ValueError("At least one of ON URL or OFF URL is required for REST plugs")
return self
@ -90,7 +106,7 @@ class SmartPlugCreate(SmartPlugBase):
class SmartPlugUpdate(BaseModel):
name: str | None = None
plug_type: Literal["tasmota", "homeassistant", "mqtt"] | None = None
plug_type: Literal["tasmota", "homeassistant", "mqtt", "rest"] | None = None
ip_address: str | None = None
ha_entity_id: str | None = None
# Home Assistant energy sensor entities (optional)
@ -112,6 +128,18 @@ class SmartPlugUpdate(BaseModel):
mqtt_state_topic: str | None = None
mqtt_state_path: str | None = None
mqtt_state_on_value: str | None = None
# REST fields
rest_on_url: str | None = None
rest_on_body: str | None = None
rest_off_url: str | None = None
rest_off_body: str | None = None
rest_method: Literal["GET", "POST", "PUT", "PATCH"] | None = None
rest_headers: str | None = None
rest_status_url: str | None = None
rest_status_path: str | None = None
rest_status_on_value: str | None = None
rest_power_path: str | None = None
rest_energy_path: str | None = None
printer_id: int | None = None
enabled: bool | None = None
auto_on: bool | None = None
@ -211,3 +239,18 @@ class HASensorEntity(BaseModel):
friendly_name: str
state: str | None = None
unit_of_measurement: str | None = None # "W", "kW", "kWh", "Wh"
class RESTTestConnectionRequest(BaseModel):
"""Request to test a REST smart plug connection."""
url: str = Field(..., min_length=1)
method: str = Field(default="GET")
headers: str | None = None # JSON string of custom headers
class RESTTestConnectionResponse(BaseModel):
"""Response from REST connection test."""
success: bool
error: str | None = None

View file

@ -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()

View file

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

View file

@ -360,6 +360,12 @@ def smart_plug_factory(db_session):
defaults["mqtt_state_on_value"] = kwargs.get("mqtt_state_on_value")
defaults["ip_address"] = None
defaults["ha_entity_id"] = None
elif plug_type == "rest":
defaults["rest_on_url"] = kwargs.get("rest_on_url", "http://192.168.1.100/api/plug/on")
defaults["rest_off_url"] = kwargs.get("rest_off_url", "http://192.168.1.100/api/plug/off")
defaults["rest_method"] = kwargs.get("rest_method", "POST")
defaults["ip_address"] = None
defaults["ha_entity_id"] = None
else:
defaults["ip_address"] = "192.168.1.100"
defaults["ha_entity_id"] = None

View file

@ -825,3 +825,135 @@ class TestSmartPlugsAPI:
result = response.json()
assert result["mqtt_power_multiplier"] == 0.001
assert result["mqtt_energy_multiplier"] == 0.001
# ========================================================================
# REST Smart Plug Integration tests
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_rest_plug(self, async_client: AsyncClient):
"""Verify REST plug can be created with ON/OFF URLs."""
data = {
"name": "REST Plug",
"plug_type": "rest",
"rest_on_url": "http://openhab:8080/rest/items/MyPlug",
"rest_on_body": "ON",
"rest_off_url": "http://openhab:8080/rest/items/MyPlug",
"rest_off_body": "OFF",
"rest_method": "POST",
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["name"] == "REST Plug"
assert result["plug_type"] == "rest"
assert result["rest_on_url"] == "http://openhab:8080/rest/items/MyPlug"
assert result["rest_on_body"] == "ON"
assert result["rest_off_url"] == "http://openhab:8080/rest/items/MyPlug"
assert result["rest_off_body"] == "OFF"
assert result["rest_method"] == "POST"
assert result["ip_address"] is None
assert result["ha_entity_id"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_rest_plug_on_url_only(self, async_client: AsyncClient):
"""Verify REST plug can be created with only ON URL."""
data = {
"name": "REST ON Only",
"plug_type": "rest",
"rest_on_url": "http://iobroker:8087/set/plug?value=true",
"rest_method": "GET",
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["rest_on_url"] == "http://iobroker:8087/set/plug?value=true"
assert result["rest_off_url"] is None
assert result["rest_method"] == "GET"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_rest_plug_missing_urls_fails(self, async_client: AsyncClient):
"""Verify creating REST plug without any URL fails."""
data = {
"name": "REST Plug",
"plug_type": "rest",
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 422 # Validation error
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_rest_plug_with_status_and_energy(self, async_client: AsyncClient):
"""Verify REST plug with status polling and energy monitoring."""
data = {
"name": "REST Full",
"plug_type": "rest",
"rest_on_url": "http://ha:8080/api/plug/on",
"rest_off_url": "http://ha:8080/api/plug/off",
"rest_method": "POST",
"rest_headers": '{"Authorization": "Bearer test-token"}',
"rest_status_url": "http://ha:8080/api/plug/status",
"rest_status_path": "state",
"rest_status_on_value": "ON",
"rest_power_path": "power.current",
"rest_energy_path": "energy.today",
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["rest_headers"] == '{"Authorization": "Bearer test-token"}'
assert result["rest_status_url"] == "http://ha:8080/api/plug/status"
assert result["rest_status_path"] == "state"
assert result["rest_status_on_value"] == "ON"
assert result["rest_power_path"] == "power.current"
assert result["rest_energy_path"] == "energy.today"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_rest_plug(self, async_client: AsyncClient, smart_plug_factory, db_session):
"""Verify REST plug fields can be updated."""
plug = await smart_plug_factory(plug_type="rest")
response = await async_client.patch(
f"/api/v1/smart-plugs/{plug.id}",
json={
"rest_on_url": "http://new-host:8080/on",
"rest_method": "PUT",
},
)
assert response.status_code == 200
result = response.json()
assert result["rest_on_url"] == "http://new-host:8080/on"
assert result["rest_method"] == "PUT"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rest_plug_is_controllable(self, async_client: AsyncClient, smart_plug_factory, db_session):
"""Verify REST plugs can be controlled (not monitor-only like MQTT)."""
plug = await smart_plug_factory(plug_type="rest")
# REST plugs should NOT return 400 like MQTT plugs
response = await async_client.post(
f"/api/v1/smart-plugs/{plug.id}/control",
json={"action": "on"},
)
# Should attempt to send the request (may fail with 503 since URL is not real,
# but should NOT return 400 "monitor-only")
assert response.status_code != 400

View file

@ -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()

View file

@ -1072,7 +1072,7 @@ export interface CloudDevice {
export interface SmartPlug {
id: number;
name: string;
plug_type: 'tasmota' | 'homeassistant' | 'mqtt';
plug_type: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest';
ip_address: string | null; // Required for Tasmota
ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer")
// Home Assistant energy sensor entities (optional)
@ -1095,6 +1095,18 @@ export interface SmartPlug {
mqtt_state_topic: string | null; // Topic for state data
mqtt_state_path: string | null; // e.g., "state_l1" for ON/OFF
mqtt_state_on_value: string | null; // What value means "ON" (e.g., "ON", "true", "1")
// REST/Webhook fields (required when plug_type="rest")
rest_on_url: string | null;
rest_on_body: string | null;
rest_off_url: string | null;
rest_off_body: string | null;
rest_method: string | null;
rest_headers: string | null;
rest_status_url: string | null;
rest_status_path: string | null;
rest_status_on_value: string | null;
rest_power_path: string | null;
rest_energy_path: string | null;
printer_id: number | null;
enabled: boolean;
auto_on: boolean;
@ -1127,7 +1139,7 @@ export interface SmartPlug {
export interface SmartPlugCreate {
name: string;
plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
plug_type?: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest';
ip_address?: string | null; // Required for Tasmota
ha_entity_id?: string | null; // Required for Home Assistant
// Home Assistant energy sensor entities (optional)
@ -1150,6 +1162,18 @@ export interface SmartPlugCreate {
mqtt_state_topic?: string | null;
mqtt_state_path?: string | null;
mqtt_state_on_value?: string | null;
// REST fields
rest_on_url?: string | null;
rest_on_body?: string | null;
rest_off_url?: string | null;
rest_off_body?: string | null;
rest_method?: string | null;
rest_headers?: string | null;
rest_status_url?: string | null;
rest_status_path?: string | null;
rest_status_on_value?: string | null;
rest_power_path?: string | null;
rest_energy_path?: string | null;
printer_id?: number | null;
enabled?: boolean;
auto_on?: boolean;
@ -1175,7 +1199,7 @@ export interface SmartPlugCreate {
export interface SmartPlugUpdate {
name?: string;
plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
plug_type?: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest';
ip_address?: string | null;
ha_entity_id?: string | null;
// Home Assistant energy sensor entities (optional)
@ -1197,6 +1221,18 @@ export interface SmartPlugUpdate {
mqtt_state_topic?: string | null;
mqtt_state_path?: string | null;
mqtt_state_on_value?: string | null;
// REST fields
rest_on_url?: string | null;
rest_on_body?: string | null;
rest_off_url?: string | null;
rest_off_body?: string | null;
rest_method?: string | null;
rest_headers?: string | null;
rest_status_url?: string | null;
rest_status_path?: string | null;
rest_status_on_value?: string | null;
rest_power_path?: string | null;
rest_energy_path?: string | null;
printer_id?: number | null;
enabled?: boolean;
auto_on?: boolean;
@ -3362,6 +3398,13 @@ export const api = {
getHASensorEntities: () =>
request<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
getQueue: (printerId?: number, status?: string, targetModel?: string) => {
const params = new URLSearchParams();

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio, Eye } from 'lucide-react';
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio, Eye, Globe } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
@ -17,7 +17,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
const isEditing = !!plug;
// Plug type selection
const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt'>(plug?.plug_type || 'tasmota');
const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt' | 'rest'>(plug?.plug_type || 'tasmota');
const [name, setName] = useState(plug?.name || '');
// Tasmota fields
@ -42,6 +42,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || '');
const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || '');
const [mqttStateOnValue, setMqttStateOnValue] = useState(plug?.mqtt_state_on_value || '');
// REST fields
const [restOnUrl, setRestOnUrl] = useState(plug?.rest_on_url || '');
const [restOnBody, setRestOnBody] = useState(plug?.rest_on_body || '');
const [restOffUrl, setRestOffUrl] = useState(plug?.rest_off_url || '');
const [restOffBody, setRestOffBody] = useState(plug?.rest_off_body || '');
const [restMethod, setRestMethod] = useState(plug?.rest_method || 'POST');
const [restHeaders, setRestHeaders] = useState(plug?.rest_headers || '');
const [restStatusUrl, setRestStatusUrl] = useState(plug?.rest_status_url || '');
const [restStatusPath, setRestStatusPath] = useState(plug?.rest_status_path || '');
const [restStatusOnValue, setRestStatusOnValue] = useState(plug?.rest_status_on_value || '');
const [restPowerPath, setRestPowerPath] = useState(plug?.rest_power_path || '');
const [restEnergyPath, setRestEnergyPath] = useState(plug?.rest_energy_path || '');
// HA energy sensor entities (optional)
const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
@ -321,6 +333,13 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
}
}
if (plugType === 'rest') {
if (!restOnUrl.trim() && !restOffUrl.trim()) {
setError(t('smartPlugs.restUrlRequired'));
return;
}
}
const data = {
name: name.trim(),
plug_type: plugType,
@ -342,6 +361,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null,
mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null,
mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.trim() || null) : null,
// REST fields
rest_on_url: plugType === 'rest' ? (restOnUrl.trim() || null) : null,
rest_on_body: plugType === 'rest' ? (restOnBody.trim() || null) : null,
rest_off_url: plugType === 'rest' ? (restOffUrl.trim() || null) : null,
rest_off_body: plugType === 'rest' ? (restOffBody.trim() || null) : null,
rest_method: plugType === 'rest' ? restMethod : null,
rest_headers: plugType === 'rest' ? (restHeaders.trim() || null) : null,
rest_status_url: plugType === 'rest' ? (restStatusUrl.trim() || null) : null,
rest_status_path: plugType === 'rest' ? (restStatusPath.trim() || null) : null,
rest_status_on_value: plugType === 'rest' ? (restStatusOnValue.trim() || null) : null,
rest_power_path: plugType === 'rest' ? (restPowerPath.trim() || null) : null,
rest_energy_path: plugType === 'rest' ? (restEnergyPath.trim() || null) : null,
username: plugType === 'tasmota' ? (username.trim() || null) : null,
password: plugType === 'tasmota' ? (password.trim() || null) : null,
printer_id: printerId,
@ -448,6 +479,22 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
<Radio className="w-4 h-4" />
MQTT
</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>
)}
@ -1074,6 +1121,184 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</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 */}
{plugType === 'tasmota' && (
<div>

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, 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 { api } 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'}`} />
) : plug.plug_type === 'homeassistant' ? (
<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'}`} />
)}
@ -141,9 +143,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
<h3 className="font-medium text-white truncate">{plug.name}</h3>
<p
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>
</div>
</div>
@ -165,6 +167,13 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
{isReachable ? (status?.state || '?') : t('smartPlugs.offline')}
</span>
</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 ? (
<div className="flex items-center gap-1 text-sm">
<Wifi className="w-4 h-4 text-status-ok" />

View file

@ -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.',
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).',
// 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',
enableSwitchbarHint: '"In Schaltleiste anzeigen" unter Einstellungen > Smart Plugs aktivieren',
},

View file

@ -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.',
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).',
// 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',
enableSwitchbarHint: 'Enable "Show in Switchbar" in Settings > Smart Plugs',
},

View file

@ -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.',
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).',
// 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',
enableSwitchbarHint: 'Activez "Afficher dans la barre de commutateurs" dans Paramètres > Smart Plugs',
},

View file

@ -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.',
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).',
// 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',
enableSwitchbarHint: 'Abilita "Mostra nella barra interruttori" in Impostazioni > Smart Plugs',
},

View file

@ -3841,6 +3841,27 @@ export default {
mqttPowerHint: 'JSONパスはJSONペイロードから値を抽出します例: "power_l1")。トピックが生の数値を送信する場合は空のままにしてください。\n乗数: mW→Wは0.001、kW→Wは1000を使用。',
mqttEnergyHint: 'JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。\n乗数: Wh→kWhは0.001、MWh→kWhは1000を使用。',
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: 'スイッチバーにスイッチがありません',
enableSwitchbarHint: '設定 > スマートプラグで「スイッチバーに表示」を有効にしてください',
},

View file

@ -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.',
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).',
// 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',
enableSwitchbarHint: 'Ative "Mostrar na barra de interruptores" em Configurações > Smart Plugs',
},

View file

@ -3828,6 +3828,27 @@ export default {
mqttPowerHint: 'JSON路径从JSON负载中提取值例如"power_l1")。如果主题发布原始数值,请留空。\n乘数mW→W使用0.001kW→W使用1000。',
mqttEnergyHint: 'JSON路径从JSON负载中提取值。原始值请留空。\n乘数Wh→kWh使用0.001MWh→kWh使用1000。',
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: '开关栏中没有开关',
enableSwitchbarHint: '在设置 > 智能插座中启用"在开关栏显示"',
},

File diff suppressed because one or more lines are too long

View file

@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<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">
</head>
<body>