248 lines
7.7 KiB
TypeScript
248 lines
7.7 KiB
TypeScript
import { useEffect, useState, type FormEvent } from "react";
|
||
import { api, type ControlKind, type DeviceConfig } from "./api";
|
||
|
||
const emptyForm = {
|
||
entityId: "",
|
||
name: "",
|
||
kind: "switch" as ControlKind,
|
||
priority: 1,
|
||
minValue: 0,
|
||
maxValue: 1,
|
||
dwellSeconds: 300,
|
||
manualOverride: false,
|
||
onlyWhenSun: false,
|
||
fromTime: null as string | null,
|
||
toTime: null as string | null,
|
||
enableUrl: null as string | null,
|
||
disableUrl: null as string | null,
|
||
};
|
||
|
||
export function Devices() {
|
||
const [devices, setDevices] = useState<DeviceConfig[]>([]);
|
||
const [form, setForm] = useState(emptyForm);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
async function refresh() {
|
||
setDevices(await api.getDevices());
|
||
}
|
||
|
||
useEffect(() => {
|
||
refresh();
|
||
}, []);
|
||
|
||
async function handleCreate(e: FormEvent) {
|
||
e.preventDefault();
|
||
try {
|
||
await api.createDevice(form);
|
||
setForm(emptyForm);
|
||
await refresh();
|
||
setError(null);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Failed to create device");
|
||
}
|
||
}
|
||
|
||
async function toggleOverride(d: DeviceConfig) {
|
||
await api.updateDevice(d.id, { manualOverride: !d.manualOverride });
|
||
await refresh();
|
||
}
|
||
|
||
async function remove(d: DeviceConfig) {
|
||
if (!confirm(`Remove ${d.name}?`)) return;
|
||
await api.deleteDevice(d.id);
|
||
await refresh();
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="card">
|
||
<h2>Add device</h2>
|
||
<form onSubmit={handleCreate}>
|
||
<div className="form-grid">
|
||
<label>
|
||
Name
|
||
<input
|
||
value={form.name}
|
||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||
required
|
||
/>
|
||
</label>
|
||
<label>
|
||
Home Assistant entity ID
|
||
<input
|
||
value={form.entityId}
|
||
onChange={(e) => setForm({ ...form, entityId: e.target.value })}
|
||
placeholder="switch.ev_charger"
|
||
required
|
||
/>
|
||
</label>
|
||
<label>
|
||
Control type
|
||
<select
|
||
value={form.kind}
|
||
onChange={(e) =>
|
||
setForm({ ...form, kind: e.target.value as ControlKind })
|
||
}
|
||
>
|
||
<option value="switch">Switch (on/off)</option>
|
||
<option value="number">Number (set-point)</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Priority (lower = shed first)
|
||
<input
|
||
type="number"
|
||
value={form.priority}
|
||
onChange={(e) =>
|
||
setForm({ ...form, priority: Number(e.target.value) })
|
||
}
|
||
/>
|
||
</label>
|
||
{form.kind === "number" && (
|
||
<>
|
||
<label>
|
||
Min value (shed target)
|
||
<input
|
||
type="number"
|
||
value={form.minValue}
|
||
onChange={(e) =>
|
||
setForm({ ...form, minValue: Number(e.target.value) })
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Max value (normal)
|
||
<input
|
||
type="number"
|
||
value={form.maxValue}
|
||
onChange={(e) =>
|
||
setForm({ ...form, maxValue: Number(e.target.value) })
|
||
}
|
||
/>
|
||
</label>
|
||
</>
|
||
)}
|
||
<label>
|
||
Min dwell time (seconds)
|
||
<input
|
||
type="number"
|
||
value={form.dwellSeconds}
|
||
onChange={(e) =>
|
||
setForm({ ...form, dwellSeconds: Number(e.target.value) })
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Only enable if sun
|
||
<input
|
||
type="checkbox"
|
||
checked={form.onlyWhenSun}
|
||
onChange={(e) =>
|
||
setForm({ ...form, onlyWhenSun: e.target.checked })
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
From (allowed window start)
|
||
<input
|
||
type="time"
|
||
value={form.fromTime ?? ""}
|
||
onChange={(e) =>
|
||
setForm({ ...form, fromTime: e.target.value || null })
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
To (allowed window end)
|
||
<input
|
||
type="time"
|
||
value={form.toTime ?? ""}
|
||
onChange={(e) =>
|
||
setForm({ ...form, toTime: e.target.value || null })
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Custom enable endpoint (optional)
|
||
<input
|
||
type="url"
|
||
value={form.enableUrl ?? ""}
|
||
onChange={(e) =>
|
||
setForm({ ...form, enableUrl: e.target.value || null })
|
||
}
|
||
placeholder="https://example.local/device/on"
|
||
/>
|
||
</label>
|
||
<label>
|
||
Custom disable endpoint (optional)
|
||
<input
|
||
type="url"
|
||
value={form.disableUrl ?? ""}
|
||
onChange={(e) =>
|
||
setForm({ ...form, disableUrl: e.target.value || null })
|
||
}
|
||
placeholder="https://example.local/device/off"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
||
Set both custom endpoints to control this device over plain HTTP
|
||
(POST) instead of Home Assistant. The entity ID above is still used
|
||
as the device's unique key.
|
||
</p>
|
||
{error && (
|
||
<p style={{ color: "var(--status-critical)", fontSize: 13 }}>
|
||
{error}
|
||
</p>
|
||
)}
|
||
<button className="primary" type="submit">
|
||
Add device
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<h2>Configured devices</h2>
|
||
{devices.length === 0 ? (
|
||
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
||
No devices configured yet.
|
||
</p>
|
||
) : (
|
||
devices
|
||
.slice()
|
||
.sort((a, b) => a.priority - b.priority)
|
||
.map((d) => (
|
||
<div className="device-row" key={d.id}>
|
||
<div>
|
||
<div className="device-name">{d.name}</div>
|
||
<div className="device-meta">
|
||
{d.entityId} · {d.kind} · priority {d.priority} · dwell{" "}
|
||
{d.dwellSeconds}s{d.onlyWhenSun && " · sun only"}
|
||
{d.fromTime && d.toTime && ` · ${d.fromTime}–${d.toTime}`}
|
||
{d.enableUrl && d.disableUrl && " · custom endpoints"}
|
||
<div>{d.enableUrl}</div>
|
||
<div>{d.disableUrl}</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||
{d.manualOverride && (
|
||
<span className="badge override">Manual</span>
|
||
)}
|
||
<button
|
||
className="secondary"
|
||
onClick={() => toggleOverride(d)}
|
||
>
|
||
{d.manualOverride ? "Resume auto" : "Manual override"}
|
||
</button>
|
||
<button className="secondary" onClick={() => remove(d)}>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|