Compare commits

...

2 Commits

Author SHA1 Message Date
Mats Tornberg
cfc9310fe6 update frontend 2025-11-24 09:10:03 +01:00
Mats Tornberg
187c49ef00 update 2025-11-23 12:37:39 +00:00
18 changed files with 1628 additions and 895 deletions

View File

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

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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";

View 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" }],
},
];

View 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);
};

View 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;
}

View 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>);
};

View File

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

View File

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