From cfc9310fe6b33f6fdd06b0ddca454c9553942dcc Mon Sep 17 00:00:00 2001 From: Mats Tornberg Date: Mon, 24 Nov 2025 09:10:03 +0100 Subject: [PATCH] update frontend --- frontend/src/App.css | 155 ++- frontend/src/App.tsx | 1055 ++++------------- frontend/src/components/ConfigForm.tsx | 228 ++++ frontend/src/components/DeviceCard.tsx | 178 +++ frontend/src/components/DevicesSection.tsx | 168 +++ frontend/src/components/Hero.tsx | 29 + .../src/components/PotentialDeviceCard.tsx | 63 + .../components/PotentialDevicesSection.tsx | 47 + frontend/src/components/SensorCard.tsx | 123 ++ frontend/src/components/SensorsSection.tsx | 71 ++ frontend/src/components/StatsBar.tsx | 18 + frontend/src/components/index.ts | 9 + frontend/src/constants/protocols.ts | 174 +++ frontend/src/hooks/useApi.ts | 61 + frontend/src/types/index.ts | 69 ++ frontend/src/utils/device.ts | 69 ++ 16 files changed, 1625 insertions(+), 892 deletions(-) create mode 100644 frontend/src/components/ConfigForm.tsx create mode 100644 frontend/src/components/DeviceCard.tsx create mode 100644 frontend/src/components/DevicesSection.tsx create mode 100644 frontend/src/components/Hero.tsx create mode 100644 frontend/src/components/PotentialDeviceCard.tsx create mode 100644 frontend/src/components/PotentialDevicesSection.tsx create mode 100644 frontend/src/components/SensorCard.tsx create mode 100644 frontend/src/components/SensorsSection.tsx create mode 100644 frontend/src/components/StatsBar.tsx create mode 100644 frontend/src/components/index.ts create mode 100644 frontend/src/constants/protocols.ts create mode 100644 frontend/src/hooks/useApi.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/utils/device.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 2183443..24eee1e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -49,6 +49,7 @@ body { margin: 0 0 8px; font-size: clamp(2.4rem, 4vw, 3.2rem); font-weight: 800; + .card.device-card { padding: 26px; } @@ -59,7 +60,7 @@ body { display: flex; flex-direction: column; gap: 20px; - + } .device-card-accent { @@ -76,6 +77,7 @@ body { .card.device-card:hover .device-card-accent { opacity: 0.28; } + letter-spacing: -1px; } @@ -89,30 +91,31 @@ body { display: flex; align-items: center; } - .device-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 16px; - } - .device-overview { - display: flex; - align-items: center; - gap: 16px; - } +.device-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} - .device-avatar { - width: 56px; - height: 56px; - border-radius: 18px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.8rem; - color: #05060a; - box-shadow: 0 18px 30px rgba(5, 6, 10, 0.45); - } +.device-overview { + display: flex; + align-items: center; + gap: 16px; +} + +.device-avatar { + width: 56px; + height: 56px; + border-radius: 18px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.8rem; + color: #05060a; + box-shadow: 0 18px 30px rgba(5, 6, 10, 0.45); +} .eyebrow { text-transform: uppercase; @@ -121,13 +124,14 @@ body { color: var(--text-secondary); margin: 0 0 8px; } - .device-meta-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; - margin-top: 8px; - } + +.device-meta-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 8px; +} .hero .eyebrow { color: #d0d8ff; @@ -158,16 +162,19 @@ body { padding: 18px 22px; border-radius: 20px; border: 1px solid var(--border-color); + .badge-id { background: rgba(5, 6, 10, 0.55); color: var(--text-secondary); border: 1px solid rgba(255, 255, 255, 0.08); } + background: rgba(255, 255, 255, 0.02); backdrop-filter: blur(var(--blur)); display: flex; flex-direction: column; gap: 4px; + .device-chip { padding: 5px 12px; border-radius: 999px; @@ -235,16 +242,17 @@ body { } - button.subtle { - background: rgba(255, 255, 255, 0.05); - border: 1px solid transparent; - color: var(--text-secondary); - } +button.subtle { + background: rgba(255, 255, 255, 0.05); + border: 1px solid transparent; + color: var(--text-secondary); +} + +button.subtle:hover:not(:disabled) { + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.15); +} - button.subtle:hover:not(:disabled) { - color: var(--text-primary); - border-color: rgba(255, 255, 255, 0.15); - } .stat-card.muted { opacity: 0.85; } @@ -270,7 +278,7 @@ body { .panel { padding: 24px; border-radius: 24px; - margin-top:24px; + margin-top: 24px; background: var(--panel-bg); border: 1px solid var(--border-color); backdrop-filter: blur(var(--blur)); @@ -440,7 +448,8 @@ button.icon-btn { padding: 10px 12px; } -input { +input, +select { background: rgba(5, 6, 10, 0.6); border: 1px solid rgba(255, 255, 255, 0.1); color: var(--text-primary); @@ -449,14 +458,37 @@ input { width: 100%; margin-bottom: 12px; font-size: 1rem; + font-family: inherit; } -input:focus { +select { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='%2397a3c2' d='M8 11L3 6h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 40px; +} + +select option { + background: var(--card-bg); + color: var(--text-primary); + padding: 8px; +} + +input:focus, +select:focus { outline: none; border-color: var(--accent-color); box-shadow: 0 0 0 4px rgba(107, 140, 255, 0.25); } +input:disabled, +select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .badge { padding: 4px 10px; border-radius: 999px; @@ -701,3 +733,42 @@ input:focus { grid-template-columns: 1fr; } } + +/* Add device button */ +.add-device-btn { + background: var(--card-bg); + border: 2px dashed rgba(107, 140, 255, 0.3); + border-radius: 20px; + padding: 32px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--accent-color); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.25s ease; + backdrop-filter: blur(calc(var(--blur) / 2)); + margin-top: 18px; +} + +.add-device-btn:hover { + border-color: var(--accent-color); + background: rgba(107, 140, 255, 0.1); + transform: translateY(-2px); +} + +.add-device-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.add-device-icon { + font-size: 1.5rem; +} + +.add-device-card { + margin-top: 18px; + grid-column: 1 / -1; +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3850fa3..70eb681 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,35 +1,21 @@ -import { useEffect, useMemo, useState, type CSSProperties } from "react"; -import useSWR from "swr"; -import useSWRMutation from "swr/mutation"; +import { useMemo, useState } from "react"; import "./App.css"; - -interface Device { - id: number; - name: string; -} - -interface ConfigDevice { - id: number; - name: string; - protocol: string; - model?: string; - parameters: Record; -} - -type ParameterPair = { - key: string; - value: string; -}; - -interface ConfigDeviceForm { - id: number | null; - name: string; - protocol: string; - model: string; - parameters: ParameterPair[]; -} - -const emptyParameterRow = (): ParameterPair => ({ key: "", value: "" }); +import type { ConfigDeviceForm } from "./types"; +import { PROTOCOL_SPECS } from "./constants/protocols"; +import { emptyParameterRow, parametersToPairs, pairsToParameters } from "./utils/device"; +import { + useDevices, + useSensors, + usePotentialDevices, + useConfigDevices, + useControlAction, + useConfigAction, +} from "./hooks/useApi"; +import { Hero } from "./components/Hero"; +import { StatsBar } from "./components/StatsBar"; +import { DevicesSection } from "./components/DevicesSection"; +import { SensorsSection } from "./components/SensorsSection"; +import { PotentialDevicesSection } from "./components/PotentialDevicesSection"; const createEmptyConfigForm = (): ConfigDeviceForm => ({ id: null, @@ -39,195 +25,33 @@ const createEmptyConfigForm = (): ConfigDeviceForm => ({ parameters: [emptyParameterRow()], }); -const parametersToPairs = ( - parameters?: Record -): ParameterPair[] => { - if (!parameters || Object.keys(parameters).length === 0) { - return [emptyParameterRow()]; - } - return Object.entries(parameters).map(([key, value]) => ({ key, value })); -}; - -const pairsToParameters = (pairs: ParameterPair[]): Record => { - return pairs.reduce((acc, pair) => { - const trimmedKey = pair.key.trim(); - if (trimmedKey) { - acc[trimmedKey] = pair.value.trim(); - } - return acc; - }, {} as Record); -}; - -interface PotentialDevice { - class: string; - protocol: string; - model: string; - device_id: string; - last_data: string; - last_seen: number; -} - -interface Sensor { - sensor_id: number; - protocol: string; - model: string; - id: number; - name: string; - last_temperature?: string; - last_humidity?: string; - last_timestamp?: number; - hidden: boolean; -} - -const deviceVisualMap = [ - { - keywords: ["lamp", "light", "bulb", "led"], - emoji: "💡", - label: "Lighting", - }, - { - keywords: ["switch", "plug", "socket", "outlet"], - emoji: "🔌", - label: "Power Relay", - }, - { keywords: ["fan", "vent", "air"], emoji: "🌀", label: "Airflow" }, - { keywords: ["door", "lock", "gate"], emoji: "🔒", label: "Access" }, - { keywords: ["therm", "heat", "radiator"], emoji: "🔥", label: "Heating" }, - { keywords: ["curtain", "blind", "shade"], emoji: "🪟", label: "Shading" }, - { keywords: ["alarm", "siren"], emoji: "🚨", label: "Alert" }, -]; - -const hashString = (value: string) => { - let hash = 0; - for (let index = 0; index < value.length; index += 1) { - hash = (hash << 5) - hash + value.charCodeAt(index); - hash |= 0; - } - return Math.abs(hash); -}; - -const getDeviceVisuals = (device: Device) => { - const lowerName = device.name.toLowerCase(); - const mapping = deviceVisualMap.find((entry) => - entry.keywords.some((keyword) => lowerName.includes(keyword)) - ); - const gradientSeed = `${device.name}-${device.id}`; - const hue = hashString(gradientSeed) % 360; - const gradient = `linear-gradient(135deg, hsl(${hue}, 82%, 62%), hsl(${ - (hue + 36) % 360 - }, 78%, 56%))`; - - return { - emoji: mapping?.emoji ?? "🎛️", - label: mapping?.label ?? "Accessory", - gradient, - }; -}; - -const fetcher = async (url: string): Promise => { - const res = await fetch(url); - if (!res.ok) { - throw new Error(`Failed to fetch ${url}`); - } - return res.json(); -}; - -type MutationArgs = { - url: string; - method?: string; - data?: Record; -}; - -const mutationFetcher = async (_: string, { arg }: { arg: MutationArgs }) => { - const { url, method = "POST", data } = arg; - const res = await fetch(url, { - method, - headers: data ? { "Content-Type": "application/json" } : undefined, - body: data ? JSON.stringify(data) : undefined, - }); - - if (!res.ok) { - const message = await res.text(); - throw new Error(message || "Request failed"); - } - - const contentType = res.headers.get("content-type"); - if (contentType && contentType.includes("application/json")) { - return res.json(); - } - return null; -}; - function App() { - const [editingDevice, setEditingDevice] = useState(null); + const [deviceMode, setDeviceMode] = useState>({}); const [editingSensor, setEditingSensor] = useState(null); const [newName, setNewName] = useState(""); - const [expandedPotential, setExpandedPotential] = useState( - null - ); + const [expandedPotential, setExpandedPotential] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); - const [configSelection, setConfigSelection] = useState<"new" | number>( - "new" - ); - const [configForm, setConfigForm] = useState( - createEmptyConfigForm - ); + const [showAddDevice, setShowAddDevice] = useState(false); + const [configForm, setConfigForm] = useState(createEmptyConfigForm); const [configFeedback, setConfigFeedback] = useState< { type: "success" | "error"; message: string } | null >(null); - const { - data: devicesData, - isLoading: devicesLoading, - mutate: mutateDevices, - } = useSWR("/api/devices", fetcher); - - const { - data: sensorsData, - isLoading: sensorsLoading, - mutate: mutateSensors, - } = useSWR("/api/sensors", fetcher); - - const { - data: potentialDevicesData, - isLoading: potentialDevicesLoading, - mutate: mutatePotentialDevices, - } = useSWR("/api/potential_devices", fetcher); - - const { - data: configDevicesData, - isLoading: configDevicesLoading, - mutate: mutateConfigDevices, - } = useSWR("/api/config/devices", fetcher); + const { data: devicesData, isLoading: devicesLoading, mutate: mutateDevices } = useDevices(); + const { data: sensorsData, isLoading: sensorsLoading, mutate: mutateSensors } = useSensors(); + const { data: potentialDevicesData, isLoading: potentialDevicesLoading, mutate: mutatePotentialDevices } = usePotentialDevices(); + const { data: configDevicesData, mutate: mutateConfigDevices } = useConfigDevices(); const devices = devicesData ?? []; const sensors = sensorsData ?? []; const potentialDevices = potentialDevicesData ?? []; const configDevices = configDevicesData ?? []; - useEffect(() => { - if (configSelection === "new") { - return; - } - const selected = configDevices.find((device) => device.id === configSelection); - if (selected) { - setConfigForm({ - id: selected.id, - name: selected.name, - protocol: selected.protocol, - model: selected.model ?? "", - parameters: parametersToPairs(selected.parameters), - }); - } - }, [configSelection, configDevices]); - - const { trigger: sendControlAction, isMutating: isControlMutating } = - useSWRMutation("control-action", mutationFetcher); - const { trigger: sendConfigAction, isMutating: isConfigMutating } = - useSWRMutation("config-action", mutationFetcher); + const { trigger: sendControlAction, isMutating: isControlMutating } = useControlAction(); + const { trigger: sendConfigAction, isMutating: isConfigMutating } = useConfigAction(); const groupedPotentialDevices = useMemo(() => { - const groups: Record = {}; + const groups: Record = {}; potentialDevices.forEach((device) => { if (!groups[device.device_id]) { groups[device.device_id] = []; @@ -237,20 +61,11 @@ function App() { return groups; }, [potentialDevices]); - const hiddenSensors = useMemo( - () => sensors.filter((sensor) => sensor.hidden).length, - [sensors] - ); - const visibleSensors = useMemo( - () => sensors.filter((sensor) => !sensor.hidden).length, - [sensors] - ); - const configDeviceCount = configDevices.length; + const hiddenSensors = useMemo(() => sensors.filter((sensor) => sensor.hidden).length, [sensors]); + const visibleSensors = useMemo(() => sensors.filter((sensor) => !sensor.hidden).length, [sensors]); const latestSensorUpdate = useMemo(() => { - const timestamps = sensors - .map((sensor) => sensor.last_timestamp ?? 0) - .filter(Boolean); + const timestamps = sensors.map((sensor) => sensor.last_timestamp ?? 0).filter(Boolean); if (!timestamps.length) { return "No recent updates"; } @@ -261,7 +76,6 @@ function App() { const dashboardStats = useMemo( () => [ { label: "Devices", value: devices.length, accent: "accent" }, - { label: "Config devices", value: configDeviceCount, accent: "accent" }, { label: "Visible sensors", value: visibleSensors, accent: "success" }, { label: "Hidden sensors", value: hiddenSensors, accent: "warning" }, { @@ -270,15 +84,22 @@ function App() { accent: "muted", }, ], - [ - devices.length, - configDeviceCount, - visibleSensors, - hiddenSensors, - groupedPotentialDevices, - ] + [devices.length, visibleSensors, hiddenSensors, groupedPotentialDevices] ); + const currentProtocolSpec = useMemo(() => { + return PROTOCOL_SPECS.find((spec) => spec.name === configForm.protocol); + }, [configForm.protocol]); + + const currentModelSpec = useMemo(() => { + if (!currentProtocolSpec?.models) return null; + return currentProtocolSpec.models.find((m) => m.name === configForm.model); + }, [currentProtocolSpec, configForm.model]); + + const availableParameterSpecs = useMemo(() => { + return currentModelSpec?.parameters || currentProtocolSpec?.parameters || []; + }, [currentModelSpec, currentProtocolSpec]); + const refreshAll = async () => { setIsRefreshing(true); try { @@ -286,6 +107,7 @@ function App() { mutateDevices(), mutateSensors(), mutatePotentialDevices(), + mutateConfigDevices(), ]); } finally { setIsRefreshing(false); @@ -293,90 +115,101 @@ function App() { }; const turnOn = async (id: number) => { - await sendControlAction({ - url: `/api/devices/${id}/turnon`, - method: "POST", - }); + await sendControlAction({ url: `/api/devices/${id}/turnon`, method: "POST" }); await mutateDevices(); }; const learn = async (id: number) => { - await sendControlAction({ - url: `/api/devices/${id}/learn`, - method: "POST", - }); + await sendControlAction({ url: `/api/devices/${id}/learn`, method: "POST" }); await mutateDevices(); }; const turnOff = async (id: number) => { - await sendControlAction({ - url: `/api/devices/${id}/turnoff`, - method: "POST", - }); + await sendControlAction({ url: `/api/devices/${id}/turnoff`, method: "POST" }); await mutateDevices(); }; const renameDevice = async (id: number) => { - await sendControlAction({ - url: `/api/devices/${id}`, - method: "PUT", - data: { name: newName }, - }); - setEditingDevice(null); + await sendControlAction({ url: `/api/devices/${id}`, method: "PUT", data: { name: newName } }); + setDeviceMode((prev) => ({ ...prev, [id]: null })); setNewName(""); await mutateDevices(); }; const renameSensor = async (sensorId: number) => { - await sendControlAction({ - url: `/api/sensors/${sensorId}`, - method: "PUT", - data: { name: newName }, - }); + await sendControlAction({ url: `/api/sensors/${sensorId}`, method: "PUT", data: { name: newName } }); setEditingSensor(null); setNewName(""); await mutateSensors(); }; const hideSensor = async (sensorId: number) => { - await sendControlAction({ - url: `/api/sensors/${sensorId}/hide`, - method: "PUT", - }); + await sendControlAction({ url: `/api/sensors/${sensorId}/hide`, method: "PUT" }); await mutateSensors(); }; const unhideSensor = async (sensorId: number) => { - await sendControlAction({ - url: `/api/sensors/${sensorId}/unhide`, - method: "PUT", - }); + await sendControlAction({ url: `/api/sensors/${sensorId}/unhide`, method: "PUT" }); await mutateSensors(); }; - const startCreateConfigDevice = () => { - setConfigSelection("new"); + const startConfigMode = (deviceId: number) => { + const configDevice = configDevices.find((d) => d.id === deviceId); + if (configDevice) { + setConfigForm({ + id: configDevice.id, + name: configDevice.name, + protocol: configDevice.protocol, + model: configDevice.model ?? "", + parameters: parametersToPairs(configDevice.parameters), + }); + } else { + setConfigForm({ + id: deviceId, + name: devices.find((d) => d.id === deviceId)?.name ?? "", + protocol: "", + model: "", + parameters: [emptyParameterRow()], + }); + } + setDeviceMode((prev) => ({ ...prev, [deviceId]: "config" })); + setConfigFeedback(null); + }; + + const startAddDevice = () => { setConfigForm(createEmptyConfigForm()); + setShowAddDevice(true); setConfigFeedback(null); }; - const startEditingConfigDevice = (deviceId: number) => { - setConfigSelection(deviceId); + const cancelDeviceMode = (deviceId: number) => { + setDeviceMode((prev) => ({ ...prev, [deviceId]: null })); setConfigFeedback(null); }; - const updateConfigField = ( - field: "name" | "protocol" | "model", - value: string - ) => { - setConfigForm((prev) => ({ ...prev, [field]: value })); + const cancelAddDevice = () => { + setShowAddDevice(false); + setConfigFeedback(null); }; - const updateParameterRow = ( - index: number, - field: "key" | "value", - value: string - ) => { + const updateConfigField = (field: "name" | "protocol" | "model", value: string) => { + setConfigForm((prev) => { + const updates: Partial = { [field]: value }; + + if (field === "protocol") { + updates.model = ""; + updates.parameters = [emptyParameterRow()]; + } + + if (field === "model") { + updates.parameters = [emptyParameterRow()]; + } + + return { ...prev, ...updates }; + }); + }; + + const updateParameterRow = (index: number, field: "key" | "value", value: string) => { setConfigForm((prev) => { const nextParameters = prev.parameters.map((param, idx) => idx === index ? { ...param, [field]: value } : param @@ -402,637 +235,159 @@ function App() { }); }; - const resetConfigForm = () => { + const resetConfigForm = (deviceId?: number) => { setConfigFeedback(null); - if (configSelection === "new") { + if (!deviceId) { setConfigForm(createEmptyConfigForm()); return; } - const selected = configDevices.find((device) => device.id === configSelection); - if (selected) { + const configDevice = configDevices.find((d) => d.id === deviceId); + if (configDevice) { setConfigForm({ - id: selected.id, - name: selected.name, - protocol: selected.protocol, - model: selected.model ?? "", - parameters: parametersToPairs(selected.parameters), + id: configDevice.id, + name: configDevice.name, + protocol: configDevice.protocol, + model: configDevice.model ?? "", + parameters: parametersToPairs(configDevice.parameters), }); } }; - const handleSaveConfigDevice = async () => { + const handleSaveConfigDevice = async (deviceId?: number) => { setConfigFeedback(null); - const isNew = configSelection === "new"; + const isNew = !deviceId && !configForm.id; const trimmedName = configForm.name.trim(); const trimmedProtocol = configForm.protocol.trim(); + if (!trimmedName || !trimmedProtocol) { - setConfigFeedback({ - type: "error", - message: "Name and protocol are required.", - }); + setConfigFeedback({ type: "error", message: "Name and protocol are required." }); return; } + + const targetId = deviceId || configForm.id; const basePayload = { name: trimmedName, protocol: trimmedProtocol, model: configForm.model.trim() || undefined, parameters: pairsToParameters(configForm.parameters), }; - const payload = isNew - ? basePayload - : { ...basePayload, id: configSelection }; - const url = isNew - ? "/api/config/devices" - : `/api/config/devices/${configSelection}`; - const method = isNew ? "POST" : "PUT"; + const payload = targetId ? { ...basePayload, id: targetId } : basePayload; + const url = targetId ? `/api/config/devices/${targetId}` : "/api/config/devices"; + const method = targetId ? "PUT" : "POST"; + try { - const result = (await sendConfigAction({ - url, - method, - data: payload, - })) as ConfigDevice | null; + await sendConfigAction({ url, method, data: payload }); await mutateConfigDevices(); - if (isNew) { - if (result?.id) { - setConfigSelection(result.id); - } else { - startCreateConfigDevice(); - } - setConfigFeedback({ type: "success", message: "Device created." }); + await mutateDevices(); + + if (deviceId) { + setDeviceMode((prev) => ({ ...prev, [deviceId]: null })); } else { - setConfigFeedback({ type: "success", message: "Device updated." }); + setShowAddDevice(false); } + + setConfigFeedback({ + type: "success", + message: isNew ? "Device created." : "Device updated.", + }); } catch (error) { setConfigFeedback({ type: "error", - message: - error instanceof Error ? error.message : "Failed to save device.", + message: error instanceof Error ? error.message : "Failed to save device.", }); } }; - const handleDeleteConfigDevice = async () => { - if (configSelection === "new") { - return; - } - const confirmed = window.confirm( - "Delete this config device from tellstick.conf?" - ); - if (!confirmed) { - return; - } + const handleDeleteConfigDevice = async (deviceId: number) => { + const confirmed = window.confirm("Delete this config device from tellstick.conf?"); + if (!confirmed) return; + try { - await sendConfigAction({ - url: `/api/config/devices/${configSelection}`, - method: "DELETE", - }); + await sendConfigAction({ url: `/api/config/devices/${deviceId}`, method: "DELETE" }); await mutateConfigDevices(); - startCreateConfigDevice(); + await mutateDevices(); + setDeviceMode((prev) => ({ ...prev, [deviceId]: null })); setConfigFeedback({ type: "success", message: "Device deleted." }); } catch (error) { setConfigFeedback({ type: "error", - message: - error instanceof Error ? error.message : "Failed to delete device.", + message: error instanceof Error ? error.message : "Failed to delete device.", }); } }; + const togglePotentialExpand = (deviceId: string) => { + setExpandedPotential((prev) => (prev === deviceId ? null : deviceId)); + }; + return (
-
-
-

Telldus

-

Ambient Control Center

-

- Real-time overview of devices, sensors, and incoming signals. Last - update {latestSensorUpdate}. -

-
-
- -
-
+ -
- {dashboardStats.map((stat) => ( -
- {stat.value} - {stat.label} -
- ))} -
+
-
-
-
-

Lighting & switches

-

Devices

-
- {devices.length} active -
- {devicesLoading ? ( -
Loading devices…
- ) : devices.length === 0 ? ( -
No devices found
- ) : ( -
- {devices.map((device) => { - const { emoji, label, gradient } = getDeviceVisuals(device); - const cardStyle = { - "--device-accent": gradient, - } as CSSProperties; - const hexId = device.id.toString(16).toUpperCase(); + - return ( -
- {editingDevice === device.id ? ( -
- setNewName(e.target.value)} - placeholder="Enter new name" - autoFocus - /> -
- - -
-
- ) : ( -
- - )} -
- ); - })} -
- )} -
- -
-
-
-

Climate & environment

-

Sensors

-
- - {visibleSensors} visible · {hiddenSensors} hidden - -
- {sensorsLoading ? ( -
Loading sensors…
- ) : sensors.length === 0 ? ( -
No sensors found
- ) : ( -
- {sensors.map((sensor) => ( -
- {editingSensor === sensor.sensor_id ? ( -
- setNewName(e.target.value)} - placeholder="Enter new name" - autoFocus - /> -
- - -
-
- ) : ( - <> -
-
-

- 📡 {sensor.name} -

-
- - {sensor.protocol} - {" "} - {sensor.model} -
-
-
- -
-
- {sensor.last_temperature && ( -
- 🌡️ -
-

Temperature

- {sensor.last_temperature}°C -
-
- )} - {sensor.last_humidity && ( -
- 💧 -
-

Humidity

- {sensor.last_humidity}% -
-
- )} -
- {sensor.last_timestamp && ( -
- Updated:{" "} - {new Date( - sensor.last_timestamp * 1000 - ).toLocaleString()} -
- )} -
- -
- - {sensor.hidden ? ( - - ) : ( - - )} -
- - )} -
- ))} -
- )} -
+ { + setEditingSensor(sensorId); + setNewName(name); + }} + onSaveRename={renameSensor} + onCancelEdit={() => setEditingSensor(null)} + onHide={hideSensor} + onUnhide={unhideSensor} + />
-
-
-
-

tellstick.conf

-

Configuration Editor

-
- {configDeviceCount} defined -
- {configDevicesLoading ? ( -
Loading configuration…
- ) : ( -
-
- - {configDevices.length === 0 ? ( -
- No devices in config yet. -
- ) : ( - configDevices.map((device) => ( - - )) - )} -
-
-
-

- {configSelection === "new" - ? "Create new config device" - : `Editing device #${configSelection}`} -

- {configSelection !== "new" && ( - - )} -
- - - updateConfigField("name", event.target.value) - } - placeholder="Living room lamp" - /> - - - updateConfigField("protocol", event.target.value) - } - placeholder="arctech" - /> - - - updateConfigField("model", event.target.value) - } - placeholder="selflearning-switch" - /> -
-

Parameters

- -
-
- {configForm.parameters.map((parameter, index) => ( -
- - updateParameterRow(index, "key", event.target.value) - } - /> - - updateParameterRow(index, "value", event.target.value) - } - /> - -
- ))} -
- {configFeedback && ( -
- {configFeedback.message} -
- )} -
- - -
-
-
- )} -
- -
-
-
-

Discovery stream

-

Potential Devices

-
- - {Object.keys(groupedPotentialDevices).length} signals - -
- {potentialDevicesLoading ? ( -
Listening for signals…
- ) : Object.keys(groupedPotentialDevices).length === 0 ? ( -
No potential devices detected
- ) : ( -
- {Object.entries(groupedPotentialDevices).map( - ([deviceId, group]) => { - const latest = group.reduce((prev, current) => - prev.last_seen > current.last_seen ? prev : current - ); - const isExpanded = expandedPotential === deviceId; - - return ( -
-
-
-

- {latest.class} -

-
- - {latest.protocol} - {" "} - {latest.model} -
-
-
-
-
ID: {deviceId}
-
- Last seen:{" "} - {new Date(latest.last_seen * 1000).toLocaleString()} -
-
Events: {group.length}
- - {isExpanded && ( -
-

History

- {group - .sort((a, b) => b.last_seen - a.last_seen) - .map((event, idx) => ( -
-
- {new Date( - event.last_seen * 1000 - ).toLocaleTimeString()} -
-
- {event.last_data} -
-
- ))} -
- )} -
-
- -
-
- ); - } - )} -
- )} -
+
); } diff --git a/frontend/src/components/ConfigForm.tsx b/frontend/src/components/ConfigForm.tsx new file mode 100644 index 0000000..f0fe23e --- /dev/null +++ b/frontend/src/components/ConfigForm.tsx @@ -0,0 +1,228 @@ +import type { ConfigDeviceForm, ParameterSpec } from "../types"; +import { PROTOCOL_SPECS } from "../constants/protocols"; + +interface ConfigFormProps { + deviceId?: number; + configForm: ConfigDeviceForm; + configFeedback: { type: "success" | "error"; message: string } | null; + isConfigMutating: boolean; + hasExistingConfig: boolean; + currentProtocolSpec: (typeof PROTOCOL_SPECS)[number] | undefined; + availableParameterSpecs: ParameterSpec[]; + onUpdateField: (field: "name" | "protocol" | "model", value: string) => void; + onUpdateParameterRow: ( + index: number, + field: "key" | "value", + value: string + ) => void; + onAddParameterRow: () => void; + onRemoveParameterRow: (index: number) => void; + onSave: (deviceId?: number) => void; + onReset: (deviceId?: number) => void; + onCancel: () => void; + onDelete?: (deviceId: number) => void; +} + +const isEmpty = (value: string) => + value.trim() === "" || value === "null" || value === "0"; + +export const ConfigForm = ({ + deviceId, + configForm, + configFeedback, + isConfigMutating, + hasExistingConfig, + currentProtocolSpec, + availableParameterSpecs, + onUpdateField, + onUpdateParameterRow, + onAddParameterRow, + onRemoveParameterRow, + onSave, + onReset, + onCancel, + onDelete, +}: ConfigFormProps) => { + const idSuffix = deviceId ? `-${deviceId}` : "-new"; + + return ( +
+
+

{deviceId ? `Configure Device #${deviceId}` : "Add New Device"}

+ {hasExistingConfig && deviceId && onDelete && ( + + )} +
+ + + onUpdateField("name", event.target.value)} + placeholder="Living room lamp" + /> + + + + + + {currentProtocolSpec?.models ? ( + + ) : ( + onUpdateField("model", event.target.value)} + placeholder="Optional model name" + disabled={!configForm.protocol} + /> + )} + +
+

Parameters

+ +
+ +
+ {configForm.parameters.map((parameter, index) => { + const paramSpec = availableParameterSpecs.find( + (spec) => spec.name === parameter.key + ); + console.log( + "parameter:", + parameter, + "paramSpec:", + paramSpec, + !isEmpty(parameter.value) + ); + if (!paramSpec && isEmpty(parameter.value)) { + return null; + } + return ( +
+ {availableParameterSpecs.length > 0 ? ( + + ) : ( + + onUpdateParameterRow(index, "key", event.target.value) + } + disabled={!configForm.protocol} + /> + )} + + onUpdateParameterRow(index, "value", event.target.value) + } + disabled={!parameter.key} + /> + +
+ ); + })} +
+ + {configFeedback && ( +
+ {configFeedback.message} +
+ )} + +
+ + + +
+
+ ); +}; diff --git a/frontend/src/components/DeviceCard.tsx b/frontend/src/components/DeviceCard.tsx new file mode 100644 index 0000000..7900d26 --- /dev/null +++ b/frontend/src/components/DeviceCard.tsx @@ -0,0 +1,178 @@ +import type { CSSProperties } from "react"; +import type { + Device, + ConfigDevice, + ConfigDeviceForm, + ParameterSpec, +} from "../types"; +import { PROTOCOL_SPECS } from "../constants/protocols"; +import { ConfigForm } from "./ConfigForm"; + +interface DeviceCardProps { + device: Device; + configDevice: ConfigDevice | undefined; + mode: "rename" | "config" | null; + newName: string; + configForm: ConfigDeviceForm; + configFeedback: { type: "success" | "error"; message: string } | null; + emoji: string; + label: string; + gradient: string; + isControlMutating: boolean; + isConfigMutating: boolean; + currentProtocolSpec: (typeof PROTOCOL_SPECS)[number] | undefined; + availableParameterSpecs: ParameterSpec[]; + onSetNewName: (name: string) => void; + onRenameDevice: (id: number) => void; + onCancelMode: (id: number) => void; + onStartConfigMode: (id: number) => void; + onTurnOn: (id: number) => void; + onTurnOff: (id: number) => void; + onLearn: (id: number) => void; + onUpdateConfigField: ( + field: "name" | "protocol" | "model", + value: string + ) => void; + onUpdateParameterRow: ( + index: number, + field: "key" | "value", + value: string + ) => void; + onAddParameterRow: () => void; + onRemoveParameterRow: (index: number) => void; + onSaveConfig: (deviceId?: number) => void; + onResetConfig: (deviceId?: number) => void; + onDeleteConfig: (deviceId: number) => void; +} + +export const DeviceCard = ({ + device, + configDevice, + mode, + newName, + configForm, + configFeedback, + emoji, + label, + gradient, + isControlMutating, + isConfigMutating, + currentProtocolSpec, + availableParameterSpecs, + onSetNewName, + onRenameDevice, + onCancelMode, + onStartConfigMode, + onTurnOn, + onTurnOff, + onLearn, + onUpdateConfigField, + onUpdateParameterRow, + onAddParameterRow, + onRemoveParameterRow, + onSaveConfig, + onResetConfig, + onDeleteConfig, +}: DeviceCardProps) => { + const cardStyle = { + "--device-accent": gradient, + } as CSSProperties; + const hexId = device.id.toString(16).toUpperCase(); + + return ( +
+ {mode === "rename" ? ( +
+ onSetNewName(e.target.value)} + placeholder="Enter new name" + autoFocus + /> +
+ + +
+
+ ) : mode === "config" ? ( + onCancelMode(device.id)} + onDelete={onDeleteConfig} + /> + ) : ( +
+ + )} +
+ ); +}; diff --git a/frontend/src/components/DevicesSection.tsx b/frontend/src/components/DevicesSection.tsx new file mode 100644 index 0000000..7aa19b5 --- /dev/null +++ b/frontend/src/components/DevicesSection.tsx @@ -0,0 +1,168 @@ +import type { + Device, + ConfigDevice, + ConfigDeviceForm, + ParameterSpec, +} from "../types"; +import { DeviceCard } from "./DeviceCard"; +import { ConfigForm } from "./ConfigForm"; +import { getDeviceVisuals } from "../utils/device"; +import { PROTOCOL_SPECS } from "../constants/protocols"; + +interface DevicesSectionProps { + devices: Device[]; + configDevices: ConfigDevice[]; + isLoading: boolean; + deviceMode: Record; + newName: string; + configForm: ConfigDeviceForm; + configFeedback: { type: "success" | "error"; message: string } | null; + showAddDevice: boolean; + isControlMutating: boolean; + isConfigMutating: boolean; + currentProtocolSpec: (typeof PROTOCOL_SPECS)[number] | undefined; + availableParameterSpecs: ParameterSpec[]; + onSetNewName: (name: string) => void; + onRenameDevice: (id: number) => void; + onCancelMode: (id: number) => void; + onStartConfigMode: (id: number) => void; + onTurnOn: (id: number) => void; + onTurnOff: (id: number) => void; + onLearn: (id: number) => void; + onUpdateConfigField: ( + field: "name" | "protocol" | "model", + value: string + ) => void; + onUpdateParameterRow: ( + index: number, + field: "key" | "value", + value: string + ) => void; + onAddParameterRow: () => void; + onRemoveParameterRow: (index: number) => void; + onSaveConfig: (deviceId?: number) => void; + onResetConfig: (deviceId?: number) => void; + onDeleteConfig: (deviceId: number) => void; + onStartAddDevice: () => void; + onCancelAddDevice: () => void; +} + +export const DevicesSection = ({ + devices, + configDevices, + isLoading, + deviceMode, + newName, + configForm, + configFeedback, + showAddDevice, + isControlMutating, + isConfigMutating, + currentProtocolSpec, + availableParameterSpecs, + onSetNewName, + onRenameDevice, + onCancelMode, + onStartConfigMode, + onTurnOn, + onTurnOff, + onLearn, + onUpdateConfigField, + onUpdateParameterRow, + onAddParameterRow, + onRemoveParameterRow, + onSaveConfig, + onResetConfig, + onDeleteConfig, + onStartAddDevice, + onCancelAddDevice, +}: DevicesSectionProps) => { + return ( +
+
+
+

Lighting & switches

+

Devices

+
+ {devices.length} active +
+ {isLoading ? ( +
Loading devices…
+ ) : ( + <> +
+ {devices.map((device) => { + const mode = deviceMode[device.id]; + const configDevice = configDevices.find( + (d) => d.id === device.id + ); + const { emoji, label, gradient } = getDeviceVisuals(device); + + return ( + + ); + })} +
+ {showAddDevice && ( +
+ +
+ )} + {!showAddDevice && ( + + )} + + )} +
+ ); +}; diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx new file mode 100644 index 0000000..b60833b --- /dev/null +++ b/frontend/src/components/Hero.tsx @@ -0,0 +1,29 @@ +interface HeroProps { + latestSensorUpdate: string; + isRefreshing: boolean; + onRefresh: () => void; +} + +export const Hero = ({ + latestSensorUpdate, + isRefreshing, + onRefresh, +}: HeroProps) => { + return ( +
+
+

Telldus

+

Ambient Control Center

+

+ Real-time overview of devices, sensors, and incoming signals. Last + update {latestSensorUpdate}. +

+
+
+ +
+
+ ); +}; diff --git a/frontend/src/components/PotentialDeviceCard.tsx b/frontend/src/components/PotentialDeviceCard.tsx new file mode 100644 index 0000000..56f9bc6 --- /dev/null +++ b/frontend/src/components/PotentialDeviceCard.tsx @@ -0,0 +1,63 @@ +import type { PotentialDevice } from "../types"; + +interface PotentialDeviceCardProps { + deviceId: string; + group: PotentialDevice[]; + isExpanded: boolean; + onToggleExpand: (deviceId: string) => void; +} + +export const PotentialDeviceCard = ({ + deviceId, + group, + isExpanded, + onToggleExpand, +}: PotentialDeviceCardProps) => { + const latest = group.reduce((prev, current) => + prev.last_seen > current.last_seen ? prev : current + ); + + return ( +
+
+
+

+ {latest.class} +

+
+ {latest.protocol}{" "} + {latest.model} +
+
+
+
+
ID: {deviceId}
+
+ Last seen: {new Date(latest.last_seen * 1000).toLocaleString()} +
+
Events: {group.length}
+ + {isExpanded && ( +
+

History

+ {group + .sort((a, b) => b.last_seen - a.last_seen) + .map((event, idx) => ( +
+
+ {new Date(event.last_seen * 1000).toLocaleTimeString()} +
+
{event.last_data}
+
+ ))} +
+ )} +
+
+ +
+
+ ); +}; diff --git a/frontend/src/components/PotentialDevicesSection.tsx b/frontend/src/components/PotentialDevicesSection.tsx new file mode 100644 index 0000000..3ae8be5 --- /dev/null +++ b/frontend/src/components/PotentialDevicesSection.tsx @@ -0,0 +1,47 @@ +import type { PotentialDevice } from "../types"; +import { PotentialDeviceCard } from "./PotentialDeviceCard"; + +interface PotentialDevicesSectionProps { + groupedPotentialDevices: Record; + isLoading: boolean; + expandedPotential: string | null; + onToggleExpand: (deviceId: string) => void; +} + +export const PotentialDevicesSection = ({ + groupedPotentialDevices, + isLoading, + expandedPotential, + onToggleExpand, +}: PotentialDevicesSectionProps) => { + return ( +
+
+
+

Discovery stream

+

Potential Devices

+
+ + {Object.keys(groupedPotentialDevices).length} signals + +
+ {isLoading ? ( +
Listening for signals…
+ ) : Object.keys(groupedPotentialDevices).length === 0 ? ( +
No potential devices detected
+ ) : ( +
+ {Object.entries(groupedPotentialDevices).map(([deviceId, group]) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/SensorCard.tsx b/frontend/src/components/SensorCard.tsx new file mode 100644 index 0000000..7d4013e --- /dev/null +++ b/frontend/src/components/SensorCard.tsx @@ -0,0 +1,123 @@ +import type { Sensor } from "../types"; + +interface SensorCardProps { + sensor: Sensor; + isEditing: boolean; + newName: string; + isControlMutating: boolean; + onSetNewName: (name: string) => void; + onStartEdit: (sensorId: number, name: string) => void; + onSaveRename: (sensorId: number) => void; + onCancelEdit: () => void; + onHide: (sensorId: number) => void; + onUnhide: (sensorId: number) => void; +} + +export const SensorCard = ({ + sensor, + isEditing, + newName, + isControlMutating, + onSetNewName, + onStartEdit, + onSaveRename, + onCancelEdit, + onHide, + onUnhide, +}: SensorCardProps) => { + return ( +
+ {isEditing ? ( +
+ onSetNewName(e.target.value)} + placeholder="Enter new name" + autoFocus + /> +
+ + +
+
+ ) : ( + <> +
+
+

+ 📡 {sensor.name} +

+
+ {sensor.protocol}{" "} + {sensor.model} +
+
+
+ +
+
+ {sensor.last_temperature && ( +
+ 🌡️ +
+

Temperature

+ {sensor.last_temperature}°C +
+
+ )} + {sensor.last_humidity && ( +
+ 💧 +
+

Humidity

+ {sensor.last_humidity}% +
+
+ )} +
+ {sensor.last_timestamp && ( +
+ Updated:{" "} + {new Date(sensor.last_timestamp * 1000).toLocaleString()} +
+ )} +
+ +
+ + {sensor.hidden ? ( + + ) : ( + + )} +
+ + )} +
+ ); +}; diff --git a/frontend/src/components/SensorsSection.tsx b/frontend/src/components/SensorsSection.tsx new file mode 100644 index 0000000..74f17cd --- /dev/null +++ b/frontend/src/components/SensorsSection.tsx @@ -0,0 +1,71 @@ +import type { Sensor } from "../types"; +import { SensorCard } from "./SensorCard"; + +interface SensorsSectionProps { + sensors: Sensor[]; + isLoading: boolean; + editingSensor: number | null; + newName: string; + isControlMutating: boolean; + visibleSensors: number; + hiddenSensors: number; + onSetNewName: (name: string) => void; + onStartEdit: (sensorId: number, name: string) => void; + onSaveRename: (sensorId: number) => void; + onCancelEdit: () => void; + onHide: (sensorId: number) => void; + onUnhide: (sensorId: number) => void; +} + +export const SensorsSection = ({ + sensors, + isLoading, + editingSensor, + newName, + isControlMutating, + visibleSensors, + hiddenSensors, + onSetNewName, + onStartEdit, + onSaveRename, + onCancelEdit, + onHide, + onUnhide, +}: SensorsSectionProps) => { + return ( +
+
+
+

Climate & environment

+

Sensors

+
+ + {visibleSensors} visible · {hiddenSensors} hidden + +
+ {isLoading ? ( +
Loading sensors…
+ ) : sensors.length === 0 ? ( +
No sensors found
+ ) : ( +
+ {sensors.map((sensor) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/StatsBar.tsx b/frontend/src/components/StatsBar.tsx new file mode 100644 index 0000000..54b473f --- /dev/null +++ b/frontend/src/components/StatsBar.tsx @@ -0,0 +1,18 @@ +import type { DashboardStat } from "../types"; + +interface StatsBarProps { + stats: DashboardStat[]; +} + +export const StatsBar = ({ stats }: StatsBarProps) => { + return ( +
+ {stats.map((stat) => ( +
+ {stat.value} + {stat.label} +
+ ))} +
+ ); +}; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts new file mode 100644 index 0000000..8604dd8 --- /dev/null +++ b/frontend/src/components/index.ts @@ -0,0 +1,9 @@ +export { Hero } from "./Hero"; +export { StatsBar } from "./StatsBar"; +export { DeviceCard } from "./DeviceCard"; +export { SensorCard } from "./SensorCard"; +export { PotentialDeviceCard } from "./PotentialDeviceCard"; +export { ConfigForm } from "./ConfigForm"; +export { DevicesSection } from "./DevicesSection"; +export { SensorsSection } from "./SensorsSection"; +export { PotentialDevicesSection } from "./PotentialDevicesSection"; diff --git a/frontend/src/constants/protocols.ts b/frontend/src/constants/protocols.ts new file mode 100644 index 0000000..731e9af --- /dev/null +++ b/frontend/src/constants/protocols.ts @@ -0,0 +1,174 @@ +import type { ProtocolSpec } from "../types"; + +export const PROTOCOL_SPECS: ProtocolSpec[] = [ + { + name: "arctech", + models: [ + { + name: "codeswitch", + parameters: [ + { name: "house", description: "A to P", validation: "A-P" }, + { name: "unit", description: "1 to 16", validation: "1-16" }, + ], + }, + { + name: "bell", + parameters: [ + { name: "house", description: "A to P", validation: "A-P" }, + ], + }, + { + name: "selflearning-switch", + parameters: [ + { + name: "house", + description: "1 to 67108863", + validation: "1-67108863", + }, + { name: "unit", description: "1 to 16", validation: "1-16" }, + ], + }, + { + name: "selflearning-dimmer", + parameters: [ + { + name: "house", + description: "1 to 67108863", + validation: "1-67108863", + }, + { name: "unit", description: "1 to 16", validation: "1-16" }, + ], + }, + ], + }, + { + name: "brateck", + parameters: [ + { + name: "house", + description: "8 tri-state values (0, 1, -)", + validation: "tri-state", + }, + ], + }, + { + name: "everflourish", + parameters: [ + { name: "house", description: "0 to 16383", validation: "0-16383" }, + { name: "unit", description: "1 to 4", validation: "1-4" }, + ], + }, + { + name: "fuhaote", + parameters: [ + { + name: "code", + description: "10 ones and zeros", + validation: "binary-10", + }, + ], + }, + { + name: "hasta", + parameters: [ + { name: "house", description: "1 to 65536", validation: "1-65536" }, + { name: "unit", description: "1 to 15", validation: "1-15" }, + ], + }, + { + name: "ikea", + parameters: [ + { name: "system", description: "1 to 16", validation: "1-16" }, + { + name: "units", + description: "Comma-separated list (1-10)", + validation: "csv-1-10", + }, + { name: "fade", description: "true or false", validation: "boolean" }, + ], + }, + { + name: "risingsun", + models: [ + { + name: "codeswitch", + parameters: [ + { name: "house", description: "1 to 4", validation: "1-4" }, + { name: "unit", description: "1 to 4", validation: "1-4" }, + ], + }, + { + name: "selflearning", + parameters: [ + { + name: "house", + description: "1 to 33554432", + validation: "1-33554432", + }, + { name: "unit", description: "1 to 16", validation: "1-16" }, + ], + }, + ], + }, + { + name: "sartano", + parameters: [ + { + name: "code", + description: "10 ones and zeros", + validation: "binary-10", + }, + ], + }, + { + name: "silvanchip", + models: [ + { + name: "ecosavers", + parameters: [ + { + name: "house", + description: "1 to 1048575", + validation: "1-1048575", + }, + { name: "unit", description: "1 to 4", validation: "1-4" }, + ], + }, + { + name: "kp100", + parameters: [ + { + name: "house", + description: "1 to 1048575", + validation: "1-1048575", + }, + ], + }, + ], + }, + { + name: "upm", + parameters: [ + { name: "house", description: "0 to 4095", validation: "0-4095" }, + { name: "unit", description: "1 to 4", validation: "1-4" }, + ], + }, + { + name: "waveman", + parameters: [ + { name: "house", description: "A to P", validation: "A-P" }, + { name: "unit", description: "1 to 16", validation: "1-16" }, + ], + }, + { + name: "x10", + parameters: [ + { name: "house", description: "A to P", validation: "A-P" }, + { name: "unit", description: "1 to 16", validation: "1-16" }, + ], + }, + { + name: "yidong", + parameters: [{ name: "unit", description: "1 to 4", validation: "1-4" }], + }, +]; diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts new file mode 100644 index 0000000..d3dba9f --- /dev/null +++ b/frontend/src/hooks/useApi.ts @@ -0,0 +1,61 @@ +import useSWR from "swr"; +import useSWRMutation from "swr/mutation"; +import type { Device, Sensor, PotentialDevice, ConfigDevice } from "../types"; + +const fetcher = async (url: string): Promise => { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to fetch ${url}`); + } + return res.json(); +}; + +type MutationArgs = { + url: string; + method?: string; + data?: Record; +}; + +const mutationFetcher = async (_: string, { arg }: { arg: MutationArgs }) => { + const { url, method = "POST", data } = arg; + const res = await fetch(url, { + method, + headers: data ? { "Content-Type": "application/json" } : undefined, + body: data ? JSON.stringify(data) : undefined, + }); + + if (!res.ok) { + const message = await res.text(); + throw new Error(message || "Request failed"); + } + + const contentType = res.headers.get("content-type"); + if (contentType && contentType.includes("application/json")) { + return res.json(); + } + return null; +}; + +export const useDevices = () => { + return useSWR("/api/devices", fetcher); +}; + +export const useSensors = () => { + return useSWR("/api/sensors", fetcher); +}; + +export const usePotentialDevices = () => { + return useSWR("/api/potential_devices", fetcher); +}; + +export const useConfigDevices = () => { + return useSWR("/api/config/devices", fetcher); +}; + +export const useControlAction = () => { + return useSWRMutation("control-action", mutationFetcher); +}; + +export const useConfigAction = () => { + return useSWRMutation("config-action", mutationFetcher); +}; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..d51c9ae --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,69 @@ +export interface Device { + id: number; + name: string; +} + +export interface ConfigDevice { + id: number; + name: string; + protocol: string; + model?: string; + parameters: Record; +} + +export type ParameterPair = { + key: string; + value: string; +}; + +export interface ConfigDeviceForm { + id: number | null; + name: string; + protocol: string; + model: string; + parameters: ParameterPair[]; +} + +export interface ParameterSpec { + name: string; + description: string; + validation?: string; +} + +export interface ModelSpec { + name: string; + parameters: ParameterSpec[]; +} + +export interface ProtocolSpec { + name: string; + models?: ModelSpec[]; + parameters?: ParameterSpec[]; +} + +export interface PotentialDevice { + class: string; + protocol: string; + model: string; + device_id: string; + last_data: string; + last_seen: number; +} + +export interface Sensor { + sensor_id: number; + protocol: string; + model: string; + id: number; + name: string; + last_temperature?: string; + last_humidity?: string; + last_timestamp?: number; + hidden: boolean; +} + +export interface DashboardStat { + label: string; + value: number; + accent: string; +} diff --git a/frontend/src/utils/device.ts b/frontend/src/utils/device.ts new file mode 100644 index 0000000..0c59486 --- /dev/null +++ b/frontend/src/utils/device.ts @@ -0,0 +1,69 @@ +import type { Device, ParameterPair } from "../types"; + +export const deviceVisualMap = [ + { + keywords: ["lamp", "light", "bulb", "led"], + emoji: "💡", + label: "Lighting", + }, + { + keywords: ["switch", "plug", "socket", "outlet"], + emoji: "🔌", + label: "Power Relay", + }, + { keywords: ["fan", "vent", "air"], emoji: "🌀", label: "Airflow" }, + { keywords: ["door", "lock", "gate"], emoji: "🔒", label: "Access" }, + { keywords: ["therm", "heat", "radiator"], emoji: "🔥", label: "Heating" }, + { keywords: ["curtain", "blind", "shade"], emoji: "🪟", label: "Shading" }, + { keywords: ["alarm", "siren"], emoji: "🚨", label: "Alert" }, +]; + +const hashString = (value: string) => { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash << 5) - hash + value.charCodeAt(index); + hash |= 0; + } + return Math.abs(hash); +}; + +export const getDeviceVisuals = (device: Device) => { + const lowerName = device.name.toLowerCase(); + const mapping = deviceVisualMap.find((entry) => + entry.keywords.some((keyword) => lowerName.includes(keyword)) + ); + const gradientSeed = `${device.name}-${device.id}`; + const hue = hashString(gradientSeed) % 360; + const gradient = `linear-gradient(135deg, hsl(${hue}, 82%, 62%), hsl(${ + (hue + 36) % 360 + }, 78%, 56%))`; + + return { + emoji: mapping?.emoji ?? "🎛️", + label: mapping?.label ?? "Accessory", + gradient, + }; +}; + +export const emptyParameterRow = (): ParameterPair => ({ key: "", value: "" }); + +export const parametersToPairs = ( + parameters?: Record +): ParameterPair[] => { + if (!parameters || Object.keys(parameters).length === 0) { + return [emptyParameterRow()]; + } + return Object.entries(parameters).map(([key, value]) => ({ key, value })); +}; + +export const pairsToParameters = ( + pairs: ParameterPair[] +): Record => { + return pairs.reduce((acc, pair) => { + const trimmedKey = pair.key.trim(); + if (trimmedKey) { + acc[trimmedKey] = pair.value.trim(); + } + return acc; + }, {} as Record); +};