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
+227 -2
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>