Compare commits
2 Commits
6fdc140d99
...
cfc9310fe6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfc9310fe6 | ||
|
|
187c49ef00 |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
1055
frontend/src/App.tsx
1055
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
228
frontend/src/components/ConfigForm.tsx
Normal file
228
frontend/src/components/ConfigForm.tsx
Normal file
@@ -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 (
|
||||
<div className="config-form">
|
||||
<div className="config-form-header">
|
||||
<h3>{deviceId ? `Configure Device #${deviceId}` : "Add New Device"}</h3>
|
||||
{hasExistingConfig && deviceId && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => onDelete(deviceId)}
|
||||
disabled={isConfigMutating}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="input-label" htmlFor={`config-name${idSuffix}`}>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id={`config-name${idSuffix}`}
|
||||
value={configForm.name}
|
||||
onChange={(event) => onUpdateField("name", event.target.value)}
|
||||
placeholder="Living room lamp"
|
||||
/>
|
||||
|
||||
<label className="input-label" htmlFor={`config-protocol${idSuffix}`}>
|
||||
Protocol
|
||||
</label>
|
||||
<select
|
||||
id={`config-protocol${idSuffix}`}
|
||||
value={configForm.protocol}
|
||||
onChange={(event) => onUpdateField("protocol", event.target.value)}
|
||||
>
|
||||
<option value="">Select protocol...</option>
|
||||
{PROTOCOL_SPECS.map((spec) => (
|
||||
<option key={spec.name} value={spec.name}>
|
||||
{spec.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="input-label" htmlFor={`config-model${idSuffix}`}>
|
||||
Model {currentProtocolSpec?.models ? "" : "(optional)"}
|
||||
</label>
|
||||
{currentProtocolSpec?.models ? (
|
||||
<select
|
||||
id={`config-model${idSuffix}`}
|
||||
value={configForm.model}
|
||||
onChange={(event) => onUpdateField("model", event.target.value)}
|
||||
>
|
||||
<option value="">Select model...</option>
|
||||
{currentProtocolSpec.models.map((model) => (
|
||||
<option key={model.name} value={model.name}>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id={`config-model${idSuffix}`}
|
||||
value={configForm.model}
|
||||
onChange={(event) => onUpdateField("model", event.target.value)}
|
||||
placeholder="Optional model name"
|
||||
disabled={!configForm.protocol}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="parameter-header">
|
||||
<h4>Parameters</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="outline"
|
||||
onClick={onAddParameterRow}
|
||||
disabled={isConfigMutating}
|
||||
>
|
||||
Add parameter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="parameter-rows">
|
||||
{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 (
|
||||
<div className="parameter-row" key={`parameter-${index}`}>
|
||||
{availableParameterSpecs.length > 0 ? (
|
||||
<select
|
||||
value={parameter.key}
|
||||
onChange={(event) =>
|
||||
onUpdateParameterRow(index, "key", event.target.value)
|
||||
}
|
||||
disabled={!configForm.protocol}
|
||||
>
|
||||
<option value="">Select parameter...</option>
|
||||
{availableParameterSpecs.map((spec) => (
|
||||
<option key={spec.name} value={spec.name}>
|
||||
{spec.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={parameter.key}
|
||||
placeholder="Parameter key"
|
||||
onChange={(event) =>
|
||||
onUpdateParameterRow(index, "key", event.target.value)
|
||||
}
|
||||
disabled={!configForm.protocol}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
value={parameter.value}
|
||||
placeholder={paramSpec ? paramSpec.description : "Value"}
|
||||
onChange={(event) =>
|
||||
onUpdateParameterRow(index, "value", event.target.value)
|
||||
}
|
||||
disabled={!parameter.key}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn subtle"
|
||||
onClick={() => onRemoveParameterRow(index)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{configFeedback && (
|
||||
<div className={`config-feedback ${configFeedback.type}`}>
|
||||
{configFeedback.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="actions config-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={() => onSave(deviceId)}
|
||||
disabled={isConfigMutating}
|
||||
>
|
||||
{hasExistingConfig
|
||||
? "Save changes"
|
||||
: deviceId
|
||||
? "Create config"
|
||||
: "Create device"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => onReset(deviceId)}
|
||||
disabled={isConfigMutating}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={onCancel}
|
||||
disabled={isConfigMutating}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
178
frontend/src/components/DeviceCard.tsx
Normal file
178
frontend/src/components/DeviceCard.tsx
Normal file
@@ -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 (
|
||||
<div key={device.id} className="card device-card" style={cardStyle}>
|
||||
{mode === "rename" ? (
|
||||
<div className="edit-form">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => onSetNewName(e.target.value)}
|
||||
placeholder="Enter new name"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="actions">
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => onRenameDevice(device.id)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button onClick={() => onCancelMode(device.id)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : mode === "config" ? (
|
||||
<ConfigForm
|
||||
deviceId={device.id}
|
||||
configForm={configForm}
|
||||
configFeedback={configFeedback}
|
||||
isConfigMutating={isConfigMutating}
|
||||
hasExistingConfig={!!configDevice}
|
||||
currentProtocolSpec={currentProtocolSpec}
|
||||
availableParameterSpecs={availableParameterSpecs}
|
||||
onUpdateField={onUpdateConfigField}
|
||||
onUpdateParameterRow={onUpdateParameterRow}
|
||||
onAddParameterRow={onAddParameterRow}
|
||||
onRemoveParameterRow={onRemoveParameterRow}
|
||||
onSave={onSaveConfig}
|
||||
onReset={onResetConfig}
|
||||
onCancel={() => onCancelMode(device.id)}
|
||||
onDelete={onDeleteConfig}
|
||||
/>
|
||||
) : (
|
||||
<div className="device-card-main">
|
||||
<div className="device-card-accent" aria-hidden="true" />
|
||||
<div className="device-header">
|
||||
<div className="device-overview">
|
||||
<div className="device-avatar" style={{ background: gradient }}>
|
||||
<span>{emoji}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="device-name">{device.name}</h3>
|
||||
<div className="device-meta-row">
|
||||
<span className="badge badge-id">#{device.id}</span>
|
||||
<span className="device-chip">{label}</span>
|
||||
<span className="device-meta-inline">0x{hexId}</span>
|
||||
{configDevice && (
|
||||
<span className="badge badge-protocol">
|
||||
{configDevice.protocol}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="icon-btn subtle"
|
||||
title={configDevice ? "Edit config" : "Add config"}
|
||||
onClick={() => onStartConfigMode(device.id)}
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="actions device-actions">
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => onTurnOn(device.id)}
|
||||
disabled={isControlMutating}
|
||||
>
|
||||
Turn on
|
||||
</button>
|
||||
<button
|
||||
className="danger"
|
||||
onClick={() => onTurnOff(device.id)}
|
||||
disabled={isControlMutating}
|
||||
>
|
||||
Turn off
|
||||
</button>
|
||||
<button
|
||||
className="outline"
|
||||
onClick={() => onLearn(device.id)}
|
||||
disabled={isControlMutating}
|
||||
>
|
||||
Learn
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
168
frontend/src/components/DevicesSection.tsx
Normal file
168
frontend/src/components/DevicesSection.tsx
Normal file
@@ -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<number, "rename" | "config" | null>;
|
||||
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 (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Lighting & switches</p>
|
||||
<h2>Devices</h2>
|
||||
</div>
|
||||
<span className="pill">{devices.length} active</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="empty-state shimmer">Loading devices…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid">
|
||||
{devices.map((device) => {
|
||||
const mode = deviceMode[device.id];
|
||||
const configDevice = configDevices.find(
|
||||
(d) => d.id === device.id
|
||||
);
|
||||
const { emoji, label, gradient } = getDeviceVisuals(device);
|
||||
|
||||
return (
|
||||
<DeviceCard
|
||||
key={device.id}
|
||||
device={device}
|
||||
configDevice={configDevice}
|
||||
mode={mode}
|
||||
newName={newName}
|
||||
configForm={configForm}
|
||||
configFeedback={configFeedback}
|
||||
emoji={emoji}
|
||||
label={label}
|
||||
gradient={gradient}
|
||||
isControlMutating={isControlMutating}
|
||||
isConfigMutating={isConfigMutating}
|
||||
currentProtocolSpec={currentProtocolSpec}
|
||||
availableParameterSpecs={availableParameterSpecs}
|
||||
onSetNewName={onSetNewName}
|
||||
onRenameDevice={onRenameDevice}
|
||||
onCancelMode={onCancelMode}
|
||||
onStartConfigMode={onStartConfigMode}
|
||||
onTurnOn={onTurnOn}
|
||||
onTurnOff={onTurnOff}
|
||||
onLearn={onLearn}
|
||||
onUpdateConfigField={onUpdateConfigField}
|
||||
onUpdateParameterRow={onUpdateParameterRow}
|
||||
onAddParameterRow={onAddParameterRow}
|
||||
onRemoveParameterRow={onRemoveParameterRow}
|
||||
onSaveConfig={onSaveConfig}
|
||||
onResetConfig={onResetConfig}
|
||||
onDeleteConfig={onDeleteConfig}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showAddDevice && (
|
||||
<div className="card device-card add-device-card">
|
||||
<ConfigForm
|
||||
configForm={configForm}
|
||||
configFeedback={configFeedback}
|
||||
isConfigMutating={isConfigMutating}
|
||||
hasExistingConfig={false}
|
||||
currentProtocolSpec={currentProtocolSpec}
|
||||
availableParameterSpecs={availableParameterSpecs}
|
||||
onUpdateField={onUpdateConfigField}
|
||||
onUpdateParameterRow={onUpdateParameterRow}
|
||||
onAddParameterRow={onAddParameterRow}
|
||||
onRemoveParameterRow={onRemoveParameterRow}
|
||||
onSave={onSaveConfig}
|
||||
onReset={onResetConfig}
|
||||
onCancel={onCancelAddDevice}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!showAddDevice && (
|
||||
<button
|
||||
className="add-device-btn"
|
||||
onClick={onStartAddDevice}
|
||||
disabled={isConfigMutating}
|
||||
>
|
||||
<span className="add-device-icon">➕</span>
|
||||
<span>Add new device</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
29
frontend/src/components/Hero.tsx
Normal file
29
frontend/src/components/Hero.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
interface HeroProps {
|
||||
latestSensorUpdate: string;
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const Hero = ({
|
||||
latestSensorUpdate,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
}: HeroProps) => {
|
||||
return (
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Telldus</p>
|
||||
<h1>Ambient Control Center</h1>
|
||||
<p className="hero-subtitle">
|
||||
Real-time overview of devices, sensors, and incoming signals. Last
|
||||
update {latestSensorUpdate}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="hero-actions">
|
||||
<button className="ghost" onClick={onRefresh} disabled={isRefreshing}>
|
||||
{isRefreshing ? "Refreshing…" : "Refresh data"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
63
frontend/src/components/PotentialDeviceCard.tsx
Normal file
63
frontend/src/components/PotentialDeviceCard.tsx
Normal file
@@ -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 (
|
||||
<div key={deviceId} className="card">
|
||||
<div className="card-header">
|
||||
<div>
|
||||
<h3 className="device-name">
|
||||
<span className="emoji">❓</span> {latest.class}
|
||||
</h3>
|
||||
<div className="device-meta">
|
||||
<span className="badge badge-protocol">{latest.protocol}</span>{" "}
|
||||
{latest.model}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<div className="device-meta">ID: {deviceId}</div>
|
||||
<div className="device-meta">
|
||||
Last seen: {new Date(latest.last_seen * 1000).toLocaleString()}
|
||||
</div>
|
||||
<div className="device-meta">Events: {group.length}</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="events-list">
|
||||
<h4>History</h4>
|
||||
{group
|
||||
.sort((a, b) => b.last_seen - a.last_seen)
|
||||
.map((event, idx) => (
|
||||
<div key={idx} className="event-item">
|
||||
<div className="event-time">
|
||||
{new Date(event.last_seen * 1000).toLocaleTimeString()}
|
||||
</div>
|
||||
<div className="event-data">{event.last_data}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button onClick={() => onToggleExpand(deviceId)}>
|
||||
{isExpanded ? "Collapse" : "Expand"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
47
frontend/src/components/PotentialDevicesSection.tsx
Normal file
47
frontend/src/components/PotentialDevicesSection.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { PotentialDevice } from "../types";
|
||||
import { PotentialDeviceCard } from "./PotentialDeviceCard";
|
||||
|
||||
interface PotentialDevicesSectionProps {
|
||||
groupedPotentialDevices: Record<string, PotentialDevice[]>;
|
||||
isLoading: boolean;
|
||||
expandedPotential: string | null;
|
||||
onToggleExpand: (deviceId: string) => void;
|
||||
}
|
||||
|
||||
export const PotentialDevicesSection = ({
|
||||
groupedPotentialDevices,
|
||||
isLoading,
|
||||
expandedPotential,
|
||||
onToggleExpand,
|
||||
}: PotentialDevicesSectionProps) => {
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Discovery stream</p>
|
||||
<h2>Potential Devices</h2>
|
||||
</div>
|
||||
<span className="pill">
|
||||
{Object.keys(groupedPotentialDevices).length} signals
|
||||
</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="empty-state shimmer">Listening for signals…</div>
|
||||
) : Object.keys(groupedPotentialDevices).length === 0 ? (
|
||||
<div className="empty-state">No potential devices detected</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{Object.entries(groupedPotentialDevices).map(([deviceId, group]) => (
|
||||
<PotentialDeviceCard
|
||||
key={deviceId}
|
||||
deviceId={deviceId}
|
||||
group={group}
|
||||
isExpanded={expandedPotential === deviceId}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
123
frontend/src/components/SensorCard.tsx
Normal file
123
frontend/src/components/SensorCard.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
key={sensor.sensor_id}
|
||||
className={`card sensor ${sensor.hidden ? "muted" : ""}`}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div className="edit-form">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => onSetNewName(e.target.value)}
|
||||
placeholder="Enter new name"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="actions">
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => onSaveRename(sensor.sensor_id)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button onClick={onCancelEdit}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card-header">
|
||||
<div>
|
||||
<h3 className="device-name">
|
||||
<span className="emoji">📡</span> {sensor.name}
|
||||
</h3>
|
||||
<div className="device-meta">
|
||||
<span className="badge badge-protocol">{sensor.protocol}</span>{" "}
|
||||
{sensor.model}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-content">
|
||||
<div className="sensor-values">
|
||||
{sensor.last_temperature && (
|
||||
<div className="sensor-value">
|
||||
<span className="sensor-icon">🌡️</span>
|
||||
<div>
|
||||
<p className="sensor-label">Temperature</p>
|
||||
<span>{sensor.last_temperature}°C</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sensor.last_humidity && (
|
||||
<div className="sensor-value">
|
||||
<span className="sensor-icon">💧</span>
|
||||
<div>
|
||||
<p className="sensor-label">Humidity</p>
|
||||
<span>{sensor.last_humidity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{sensor.last_timestamp && (
|
||||
<div className="device-meta" style={{ marginTop: "10px" }}>
|
||||
Updated:{" "}
|
||||
{new Date(sensor.last_timestamp * 1000).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="Rename"
|
||||
onClick={() => onStartEdit(sensor.sensor_id, sensor.name)}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
{sensor.hidden ? (
|
||||
<button
|
||||
className="success"
|
||||
onClick={() => onUnhide(sensor.sensor_id)}
|
||||
disabled={isControlMutating}
|
||||
>
|
||||
Unhide
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onHide(sensor.sensor_id)}
|
||||
disabled={isControlMutating}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
frontend/src/components/SensorsSection.tsx
Normal file
71
frontend/src/components/SensorsSection.tsx
Normal file
@@ -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 (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Climate & environment</p>
|
||||
<h2>Sensors</h2>
|
||||
</div>
|
||||
<span className="pill">
|
||||
{visibleSensors} visible · {hiddenSensors} hidden
|
||||
</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="empty-state shimmer">Loading sensors…</div>
|
||||
) : sensors.length === 0 ? (
|
||||
<div className="empty-state">No sensors found</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{sensors.map((sensor) => (
|
||||
<SensorCard
|
||||
key={sensor.sensor_id}
|
||||
sensor={sensor}
|
||||
isEditing={editingSensor === sensor.sensor_id}
|
||||
newName={newName}
|
||||
isControlMutating={isControlMutating}
|
||||
onSetNewName={onSetNewName}
|
||||
onStartEdit={onStartEdit}
|
||||
onSaveRename={onSaveRename}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onHide={onHide}
|
||||
onUnhide={onUnhide}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
18
frontend/src/components/StatsBar.tsx
Normal file
18
frontend/src/components/StatsBar.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { DashboardStat } from "../types";
|
||||
|
||||
interface StatsBarProps {
|
||||
stats: DashboardStat[];
|
||||
}
|
||||
|
||||
export const StatsBar = ({ stats }: StatsBarProps) => {
|
||||
return (
|
||||
<section className="stats-bar">
|
||||
{stats.map((stat) => (
|
||||
<article key={stat.label} className={`stat-card ${stat.accent}`}>
|
||||
<span className="stat-value">{stat.value}</span>
|
||||
<span className="stat-label">{stat.label}</span>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
9
frontend/src/components/index.ts
Normal file
9
frontend/src/components/index.ts
Normal file
@@ -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";
|
||||
174
frontend/src/constants/protocols.ts
Normal file
174
frontend/src/constants/protocols.ts
Normal file
@@ -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" }],
|
||||
},
|
||||
];
|
||||
61
frontend/src/hooks/useApi.ts
Normal file
61
frontend/src/hooks/useApi.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import useSWR from "swr";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import type { Device, Sensor, PotentialDevice, ConfigDevice } from "../types";
|
||||
|
||||
const fetcher = async <T>(url: string): Promise<T> => {
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
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<Device[]>("/api/devices", fetcher);
|
||||
};
|
||||
|
||||
export const useSensors = () => {
|
||||
return useSWR<Sensor[]>("/api/sensors", fetcher);
|
||||
};
|
||||
|
||||
export const usePotentialDevices = () => {
|
||||
return useSWR<PotentialDevice[]>("/api/potential_devices", fetcher);
|
||||
};
|
||||
|
||||
export const useConfigDevices = () => {
|
||||
return useSWR<ConfigDevice[]>("/api/config/devices", fetcher);
|
||||
};
|
||||
|
||||
export const useControlAction = () => {
|
||||
return useSWRMutation("control-action", mutationFetcher);
|
||||
};
|
||||
|
||||
export const useConfigAction = () => {
|
||||
return useSWRMutation("config-action", mutationFetcher);
|
||||
};
|
||||
69
frontend/src/types/index.ts
Normal file
69
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export interface Device {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ConfigDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: string;
|
||||
model?: string;
|
||||
parameters: Record<string, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
69
frontend/src/utils/device.ts
Normal file
69
frontend/src/utils/device.ts
Normal file
@@ -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<string, string>
|
||||
): 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<string, string> => {
|
||||
return pairs.reduce((acc, pair) => {
|
||||
const trimmedKey = pair.key.trim();
|
||||
if (trimmedKey) {
|
||||
acc[trimmedKey] = pair.value.trim();
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
};
|
||||
@@ -143,7 +143,7 @@ func (c *Client) publishSensorDiscoveryForSensor(sensor *datastore.Sensor) error
|
||||
discovery := SensorDiscovery{
|
||||
Platform: "mqtt",
|
||||
Name: fmt.Sprintf("%s Temperature", sensor.Name),
|
||||
StateTopic: fmt.Sprintf("telldus/sensor/%s/%s/%d/temperature", sensor.Protocol, sensor.Model, sensor.ID),
|
||||
StateTopic: fmt.Sprintf("telldus/sensor/%d/temperature", sensor.ID),
|
||||
UniqueID: sensor.TemperatureUniqueID,
|
||||
UnitOfMeasurement: "°C",
|
||||
DeviceClass: "temperature",
|
||||
|
||||
@@ -56,9 +56,9 @@ func (c *Client) PublishSensorValue(protocol, model string, id int, dataType int
|
||||
var topic string
|
||||
switch dataType {
|
||||
case telldus.DataTypeTemperature:
|
||||
topic = fmt.Sprintf("telldus/sensor/%s/%s/%d/temperature", protocol, model, id)
|
||||
topic = fmt.Sprintf("telldus/sensor/%d/temperature", id)
|
||||
case telldus.DataTypeHumidity:
|
||||
topic = fmt.Sprintf("telldus/sensor/%s/%s/%d/humidity", protocol, model, id)
|
||||
topic = fmt.Sprintf("telldus/sensor/%d/humidity", id)
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user