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