mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
- New APIBrowser component with full OpenAPI schema integration
- Fetches and parses /openapi.json automatically
- Groups endpoints by API tags (printers, archives, settings, etc.)
- Expandable endpoint sections with color-coded method badges
- Path parameter, query parameter, and JSON body editors
- Auto-populates request body with schema examples
- Live API request execution with response display
- Response shows status code, timing, and formatted JSON
- Copy response button with clipboard fallback
- Search to filter endpoints across all categories
- Expand All / Collapse All buttons
- Link to Swagger UI (/docs)
- Two-column layout for API Keys tab
- Left: API key management + webhook documentation
- Right: API Browser with dedicated test key input
- Parameter validation
- Shows warning for missing required parameters
- Validates before sending requests to avoid 422 errors
- UX improvements
- "Use in API Browser" button on newly created keys
- Responsive layout (stacked on mobile, side-by-side on xl+)
216 lines
7.1 KiB
Python
216 lines
7.1 KiB
Python
"""Service for communicating with Tasmota devices via HTTP API."""
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
|
|
if TYPE_CHECKING:
|
|
from backend.app.models.smart_plug import SmartPlug
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TasmotaService:
|
|
"""Service for communicating with Tasmota devices via HTTP API."""
|
|
|
|
def __init__(self, timeout: float = 5.0):
|
|
self.timeout = timeout
|
|
|
|
def _build_url(
|
|
self,
|
|
ip: str,
|
|
command: str,
|
|
username: str | None = None,
|
|
password: str | None = None,
|
|
) -> str:
|
|
"""Build Tasmota command URL."""
|
|
# URL encode the command
|
|
cmd = command.replace(" ", "%20")
|
|
|
|
if username and password:
|
|
return f"http://{username}:{password}@{ip}/cm?cmnd={cmd}"
|
|
return f"http://{ip}/cm?cmnd={cmd}"
|
|
|
|
async def _send_command(
|
|
self,
|
|
ip: str,
|
|
command: str,
|
|
username: str | None = None,
|
|
password: str | None = None,
|
|
) -> dict | None:
|
|
"""Send a command to a Tasmota device and return the response."""
|
|
url = self._build_url(ip, command, username, password)
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
response = await client.get(url)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.TimeoutException:
|
|
logger.warning(f"Tasmota device at {ip} timed out")
|
|
return None
|
|
except httpx.HTTPStatusError as e:
|
|
logger.warning(f"Tasmota device at {ip} returned error: {e}")
|
|
return None
|
|
except httpx.RequestError as e:
|
|
logger.warning(f"Failed to connect to Tasmota device at {ip}: {e}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error communicating with Tasmota at {ip}: {e}")
|
|
return None
|
|
|
|
async def get_status(self, plug: "SmartPlug") -> dict:
|
|
"""Get current power state and device info.
|
|
|
|
Returns dict with:
|
|
- state: "ON" or "OFF" or None if unreachable
|
|
- reachable: bool
|
|
- device_name: str or None
|
|
"""
|
|
result = await self._send_command(plug.ip_address, "Power", plug.username, plug.password)
|
|
|
|
if result is None:
|
|
return {"state": None, "reachable": False, "device_name": None}
|
|
|
|
# Response format: {"POWER":"ON"} or {"POWER":"OFF"}
|
|
# Some devices use {"POWER1":"ON"} for multi-relay
|
|
state = None
|
|
for key in ["POWER", "POWER1"]:
|
|
if key in result:
|
|
state = result[key]
|
|
break
|
|
|
|
return {"state": state, "reachable": True, "device_name": None}
|
|
|
|
async def turn_on(self, plug: "SmartPlug") -> bool:
|
|
"""Turn on the plug. Returns True if successful."""
|
|
result = await self._send_command(plug.ip_address, "Power On", plug.username, plug.password)
|
|
|
|
if result is None:
|
|
return False
|
|
|
|
# Check if the command was successful
|
|
state = result.get("POWER") or result.get("POWER1")
|
|
success = state == "ON"
|
|
|
|
if success:
|
|
logger.info(f"Turned ON smart plug '{plug.name}' at {plug.ip_address}")
|
|
else:
|
|
logger.warning(f"Failed to turn ON smart plug '{plug.name}' at {plug.ip_address}")
|
|
|
|
return success
|
|
|
|
async def turn_off(self, plug: "SmartPlug") -> bool:
|
|
"""Turn off the plug. Returns True if successful."""
|
|
result = await self._send_command(plug.ip_address, "Power Off", plug.username, plug.password)
|
|
|
|
if result is None:
|
|
return False
|
|
|
|
# Check if the command was successful
|
|
state = result.get("POWER") or result.get("POWER1")
|
|
success = state == "OFF"
|
|
|
|
if success:
|
|
logger.info(f"Turned OFF smart plug '{plug.name}' at {plug.ip_address}")
|
|
else:
|
|
logger.warning(f"Failed to turn OFF smart plug '{plug.name}' at {plug.ip_address}")
|
|
|
|
return success
|
|
|
|
async def toggle(self, plug: "SmartPlug") -> bool:
|
|
"""Toggle the plug state. Returns True if successful."""
|
|
result = await self._send_command(plug.ip_address, "Power Toggle", plug.username, plug.password)
|
|
|
|
if result is None:
|
|
return False
|
|
|
|
state = result.get("POWER") or result.get("POWER1")
|
|
success = state in ["ON", "OFF"]
|
|
|
|
if success:
|
|
logger.info(f"Toggled smart plug '{plug.name}' at {plug.ip_address} to {state}")
|
|
|
|
return success
|
|
|
|
async def get_energy(self, plug: "SmartPlug") -> dict | None:
|
|
"""Get energy monitoring data from the plug.
|
|
|
|
Returns dict with energy data or None if not available:
|
|
- power: Current power in watts
|
|
- voltage: Voltage in V
|
|
- current: Current in A
|
|
- today: Energy used today in kWh
|
|
- total: Total energy in kWh
|
|
- factor: Power factor (0-1)
|
|
"""
|
|
result = await self._send_command(plug.ip_address, "Status 8", plug.username, plug.password)
|
|
|
|
if result is None:
|
|
return None
|
|
|
|
# Response format: {"StatusSNS":{"ENERGY":{...}}}
|
|
status_sns = result.get("StatusSNS", {})
|
|
energy = status_sns.get("ENERGY")
|
|
|
|
if not energy:
|
|
# Device doesn't have energy monitoring
|
|
return None
|
|
|
|
return {
|
|
"power": energy.get("Power"), # Current watts
|
|
"voltage": energy.get("Voltage"), # Volts
|
|
"current": energy.get("Current"), # Amps
|
|
"today": energy.get("Today"), # kWh today
|
|
"yesterday": energy.get("Yesterday"), # kWh yesterday
|
|
"total": energy.get("Total"), # Total kWh
|
|
"factor": energy.get("Factor"), # Power factor
|
|
"apparent_power": energy.get("ApparentPower"), # VA
|
|
"reactive_power": energy.get("ReactivePower"), # VAr
|
|
}
|
|
|
|
async def test_connection(
|
|
self,
|
|
ip: str,
|
|
username: str | None = None,
|
|
password: str | None = None,
|
|
) -> dict:
|
|
"""Test connection to a Tasmota device.
|
|
|
|
Returns dict with:
|
|
- success: bool
|
|
- state: current power state or None
|
|
- device_name: device name or None
|
|
- error: error message if failed
|
|
"""
|
|
# Try to get power status
|
|
result = await self._send_command(ip, "Power", username, password)
|
|
|
|
if result is None:
|
|
return {
|
|
"success": False,
|
|
"state": None,
|
|
"device_name": None,
|
|
"error": "Could not connect to device",
|
|
}
|
|
|
|
state = result.get("POWER") or result.get("POWER1")
|
|
|
|
# Try to get device name
|
|
status_result = await self._send_command(ip, "Status 0", username, password)
|
|
device_name = None
|
|
if status_result and "Status" in status_result:
|
|
device_name = status_result["Status"].get("DeviceName")
|
|
|
|
return {
|
|
"success": True,
|
|
"state": state,
|
|
"device_name": device_name,
|
|
"error": None,
|
|
}
|
|
|
|
|
|
# Singleton instance
|
|
tasmota_service = TasmotaService()
|