Compare commits
2 Commits
6fdc140d99
...
cfc9310fe6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfc9310fe6 | ||
|
|
187c49ef00 |
@@ -49,6 +49,7 @@ body {
|
|||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
font-size: clamp(2.4rem, 4vw, 3.2rem);
|
font-size: clamp(2.4rem, 4vw, 3.2rem);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
|
||||||
.card.device-card {
|
.card.device-card {
|
||||||
padding: 26px;
|
padding: 26px;
|
||||||
}
|
}
|
||||||
@@ -76,6 +77,7 @@ body {
|
|||||||
.card.device-card:hover .device-card-accent {
|
.card.device-card:hover .device-card-accent {
|
||||||
opacity: 0.28;
|
opacity: 0.28;
|
||||||
}
|
}
|
||||||
|
|
||||||
letter-spacing: -1px;
|
letter-spacing: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-header {
|
.device-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -121,6 +124,7 @@ body {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-meta-row {
|
.device-meta-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -158,16 +162,19 @@ body {
|
|||||||
padding: 18px 22px;
|
padding: 18px 22px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
.badge-id {
|
.badge-id {
|
||||||
background: rgba(5, 6, 10, 0.55);
|
background: rgba(5, 6, 10, 0.55);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
backdrop-filter: blur(var(--blur));
|
backdrop-filter: blur(var(--blur));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
||||||
.device-chip {
|
.device-chip {
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -245,6 +252,7 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-color: rgba(255, 255, 255, 0.15);
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.muted {
|
.stat-card.muted {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
@@ -440,7 +448,8 @@ button.icon-btn {
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input,
|
||||||
|
select {
|
||||||
background: rgba(5, 6, 10, 0.6);
|
background: rgba(5, 6, 10, 0.6);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -449,14 +458,37 @@ input {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
font-size: 1rem;
|
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;
|
outline: none;
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
box-shadow: 0 0 0 4px rgba(107, 140, 255, 0.25);
|
box-shadow: 0 0 0 4px rgba(107, 140, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input:disabled,
|
||||||
|
select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -701,3 +733,42 @@ input:focus {
|
|||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
1049
frontend/src/App.tsx
1049
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{
|
discovery := SensorDiscovery{
|
||||||
Platform: "mqtt",
|
Platform: "mqtt",
|
||||||
Name: fmt.Sprintf("%s Temperature", sensor.Name),
|
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,
|
UniqueID: sensor.TemperatureUniqueID,
|
||||||
UnitOfMeasurement: "°C",
|
UnitOfMeasurement: "°C",
|
||||||
DeviceClass: "temperature",
|
DeviceClass: "temperature",
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ func (c *Client) PublishSensorValue(protocol, model string, id int, dataType int
|
|||||||
var topic string
|
var topic string
|
||||||
switch dataType {
|
switch dataType {
|
||||||
case telldus.DataTypeTemperature:
|
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:
|
case telldus.DataTypeHumidity:
|
||||||
topic = fmt.Sprintf("telldus/sensor/%s/%s/%d/humidity", protocol, model, id)
|
topic = fmt.Sprintf("telldus/sensor/%d/humidity", id)
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user