add configuration editor

This commit is contained in:
Mats Tornberg
2025-11-22 22:31:22 +00:00
parent b6343149c8
commit 83f77049e8
6 changed files with 1248 additions and 4 deletions

266
CONFIG_API.md Normal file
View File

@@ -0,0 +1,266 @@
# Configuration API Documentation
The configuration API allows you to read and modify the `tellstick.conf` file through REST endpoints.
## Endpoints
### Get Full Configuration
```
GET /api/config
```
Returns the complete configuration including global settings, controllers, and devices.
**Response:**
```json
{
"user": "root",
"group": "plugdev",
"devicePath": "/dev/tellstick",
"ignoreControllerConfirmation": 0,
"controllers": [
{
"id": 0,
"name": "TellStick",
"type": "tellstick"
}
],
"devices": [...]
}
```
### List All Config Devices
```
GET /api/config/devices
```
Returns an array of all devices defined in the configuration file.
**Response:**
```json
[
{
"id": 1,
"name": "Device 1",
"protocol": "arctech",
"model": "selflearning-switch",
"parameters": {
"house": "12345678",
"unit": "1"
}
}
]
```
### Get Single Config Device
```
GET /api/config/devices/{id}
```
Returns a specific device by ID from the configuration file.
**Response:**
```json
{
"id": 1,
"name": "Device 1",
"protocol": "arctech",
"model": "selflearning-switch",
"parameters": {
"house": "12345678",
"unit": "1"
}
}
```
### Create Config Device
```
POST /api/config/devices
```
Creates a new device in the configuration file. If `id` is 0 or not provided, it will be auto-assigned.
**Request Body:**
```json
{
"name": "New Device",
"protocol": "arctech",
"model": "selflearning-switch",
"parameters": {
"house": "12345678",
"unit": "2"
}
}
```
**Response:** (201 Created)
```json
{
"id": 2,
"name": "New Device",
"protocol": "arctech",
"model": "selflearning-switch",
"parameters": {
"house": "12345678",
"unit": "2"
}
}
```
### Update Config Device
```
PUT /api/config/devices/{id}
```
Updates an existing device in the configuration file.
**Request Body:**
```json
{
"name": "Updated Device",
"protocol": "arctech",
"model": "selflearning-switch",
"parameters": {
"house": "12345678",
"unit": "1"
}
}
```
**Response:**
```json
{
"id": 1,
"name": "Updated Device",
"protocol": "arctech",
"model": "selflearning-switch",
"parameters": {
"house": "12345678",
"unit": "1"
}
}
```
### Delete Config Device
```
DELETE /api/config/devices/{id}
```
Removes a device from the configuration file.
**Response:** 204 No Content
## Device Parameters
Common parameters for different protocols:
### Arctech (selflearning-switch, selflearning-dimmer)
```json
{
"house": "12345678",
"unit": "1"
}
```
### Nexa (codeswitch, bell)
```json
{
"house": "A",
"unit": "1"
}
```
### Sartano
```json
{
"code": "0000000001"
}
```
### Ikea
```json
{
"system": "1",
"units": "1",
"fade": "true"
}
```
## Notes
- Changes to the configuration file will trigger an automatic daemon restart and device reload
- The config file watcher will detect changes and sync devices/sensors automatically
- Device IDs in the config file are separate from runtime device IDs
- All modifications are written immediately to `/etc/tellstick.conf`
## Example Usage
### JavaScript/Fetch
```javascript
// Get all config devices
const devices = await fetch('/api/config/devices').then(r => r.json());
// Create a new device
const newDevice = await fetch('/api/config/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Living Room Light',
protocol: 'arctech',
model: 'selflearning-switch',
parameters: {
house: '12345678',
unit: '3'
}
})
}).then(r => r.json());
// Update device
await fetch(`/api/config/devices/${newDevice.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Living Room Lamp',
protocol: 'arctech',
model: 'selflearning-switch',
parameters: {
house: '12345678',
unit: '3'
}
})
});
// Delete device
await fetch(`/api/config/devices/${newDevice.id}`, {
method: 'DELETE'
});
```
### cURL
```bash
# Get all devices
curl http://localhost:8080/api/config/devices
# Create device
curl -X POST http://localhost:8080/api/config/devices \
-H "Content-Type: application/json" \
-d '{
"name": "Test Device",
"protocol": "arctech",
"model": "selflearning-switch",
"parameters": {
"house": "12345678",
"unit": "5"
}
}'
# Update device
curl -X PUT http://localhost:8080/api/config/devices/1 \
-H "Content-Type: application/json" \
-d '{
"name": "Updated Name",
"protocol": "arctech",
"model": "selflearning-switch",
"parameters": {
"house": "12345678",
"unit": "1"
}
}'
# Delete device
curl -X DELETE http://localhost:8080/api/config/devices/1
```

View File

@@ -500,6 +500,149 @@ input:focus {
animation: shimmer 1.8s linear infinite; animation: shimmer 1.8s linear infinite;
} }
.config-panel {
margin-top: 32px;
}
.config-editor-grid {
display: grid;
grid-template-columns: minmax(260px, 320px) 1fr;
gap: 24px;
}
.config-device-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.config-device-row {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 14px 16px;
background: rgba(5, 6, 10, 0.35);
}
.config-device-row.active {
border-color: var(--accent-color);
background: rgba(107, 140, 255, 0.12);
}
.config-device-name {
margin: 0;
font-weight: 600;
}
.config-device-row-top {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.config-device-empty {
padding: 18px;
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.18);
text-align: center;
color: var(--text-secondary);
font-size: 0.9rem;
}
.config-form {
background: rgba(5, 6, 10, 0.35);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 22px 24px;
display: flex;
flex-direction: column;
}
.config-form-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.config-form h3 {
margin: 0;
}
.input-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.12rem;
color: var(--text-secondary);
margin: 14px 0 6px;
}
.parameter-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 6px;
}
.parameter-header h4 {
margin: 0;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 0.08rem;
color: var(--text-secondary);
}
.parameter-rows {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.parameter-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 10px;
align-items: center;
}
.parameter-row button {
height: 46px;
}
.config-feedback {
margin-top: 16px;
padding: 12px 16px;
border-radius: 14px;
font-size: 0.9rem;
border: 1px solid transparent;
}
.config-feedback.success {
background: rgba(77, 211, 156, 0.12);
border-color: rgba(77, 211, 156, 0.4);
color: var(--success-color);
}
.config-feedback.error {
background: rgba(255, 123, 123, 0.12);
border-color: rgba(255, 123, 123, 0.4);
color: var(--danger-color);
}
.config-form-actions {
margin-top: 20px;
justify-content: flex-start;
}
.events-list { .events-list {
margin-top: 20px; margin-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.06); border-top: 1px solid rgba(255, 255, 255, 0.06);
@@ -553,4 +696,8 @@ input:focus {
width: 100%; width: 100%;
justify-content: flex-start; justify-content: flex-start;
} }
.config-editor-grid {
grid-template-columns: 1fr;
}
} }

View File

@@ -1,4 +1,4 @@
import { useMemo, useState, type CSSProperties } from "react"; import { useEffect, useMemo, useState, type CSSProperties } from "react";
import useSWR from "swr"; import useSWR from "swr";
import useSWRMutation from "swr/mutation"; import useSWRMutation from "swr/mutation";
import "./App.css"; import "./App.css";
@@ -8,6 +8,56 @@ interface Device {
name: string; name: string;
} }
interface ConfigDevice {
id: number;
name: string;
protocol: string;
model?: string;
parameters: Record<string, string>;
}
type ParameterPair = {
key: string;
value: string;
};
interface ConfigDeviceForm {
id: number | null;
name: string;
protocol: string;
model: string;
parameters: ParameterPair[];
}
const emptyParameterRow = (): ParameterPair => ({ key: "", value: "" });
const createEmptyConfigForm = (): ConfigDeviceForm => ({
id: null,
name: "",
protocol: "",
model: "",
parameters: [emptyParameterRow()],
});
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 }));
};
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>);
};
interface PotentialDevice { interface PotentialDevice {
class: string; class: string;
protocol: string; protocol: string;
@@ -116,6 +166,15 @@ function App() {
null null
); );
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [configSelection, setConfigSelection] = useState<"new" | number>(
"new"
);
const [configForm, setConfigForm] = useState<ConfigDeviceForm>(
createEmptyConfigForm
);
const [configFeedback, setConfigFeedback] = useState<
{ type: "success" | "error"; message: string } | null
>(null);
const { const {
data: devicesData, data: devicesData,
@@ -135,12 +194,37 @@ function App() {
mutate: mutatePotentialDevices, mutate: mutatePotentialDevices,
} = useSWR<PotentialDevice[]>("/api/potential_devices", fetcher); } = useSWR<PotentialDevice[]>("/api/potential_devices", fetcher);
const {
data: configDevicesData,
isLoading: configDevicesLoading,
mutate: mutateConfigDevices,
} = useSWR<ConfigDevice[]>("/api/config/devices", fetcher);
const devices = devicesData ?? []; const devices = devicesData ?? [];
const sensors = sensorsData ?? []; const sensors = sensorsData ?? [];
const potentialDevices = potentialDevicesData ?? []; const potentialDevices = potentialDevicesData ?? [];
const configDevices = configDevicesData ?? [];
useEffect(() => {
if (configSelection === "new") {
return;
}
const selected = configDevices.find((device) => device.id === configSelection);
if (selected) {
setConfigForm({
id: selected.id,
name: selected.name,
protocol: selected.protocol,
model: selected.model ?? "",
parameters: parametersToPairs(selected.parameters),
});
}
}, [configSelection, configDevices]);
const { trigger: sendControlAction, isMutating: isControlMutating } = const { trigger: sendControlAction, isMutating: isControlMutating } =
useSWRMutation("control-action", mutationFetcher); useSWRMutation("control-action", mutationFetcher);
const { trigger: sendConfigAction, isMutating: isConfigMutating } =
useSWRMutation("config-action", mutationFetcher);
const groupedPotentialDevices = useMemo(() => { const groupedPotentialDevices = useMemo(() => {
const groups: Record<string, PotentialDevice[]> = {}; const groups: Record<string, PotentialDevice[]> = {};
@@ -161,6 +245,7 @@ function App() {
() => sensors.filter((sensor) => !sensor.hidden).length, () => sensors.filter((sensor) => !sensor.hidden).length,
[sensors] [sensors]
); );
const configDeviceCount = configDevices.length;
const latestSensorUpdate = useMemo(() => { const latestSensorUpdate = useMemo(() => {
const timestamps = sensors const timestamps = sensors
@@ -176,6 +261,7 @@ function App() {
const dashboardStats = useMemo( const dashboardStats = useMemo(
() => [ () => [
{ label: "Devices", value: devices.length, accent: "accent" }, { label: "Devices", value: devices.length, accent: "accent" },
{ label: "Config devices", value: configDeviceCount, accent: "accent" },
{ label: "Visible sensors", value: visibleSensors, accent: "success" }, { label: "Visible sensors", value: visibleSensors, accent: "success" },
{ label: "Hidden sensors", value: hiddenSensors, accent: "warning" }, { label: "Hidden sensors", value: hiddenSensors, accent: "warning" },
{ {
@@ -184,7 +270,13 @@ function App() {
accent: "muted", accent: "muted",
}, },
], ],
[devices.length, visibleSensors, hiddenSensors, groupedPotentialDevices] [
devices.length,
configDeviceCount,
visibleSensors,
hiddenSensors,
groupedPotentialDevices,
]
); );
const refreshAll = async () => { const refreshAll = async () => {
@@ -262,6 +354,150 @@ function App() {
await mutateSensors(); await mutateSensors();
}; };
const startCreateConfigDevice = () => {
setConfigSelection("new");
setConfigForm(createEmptyConfigForm());
setConfigFeedback(null);
};
const startEditingConfigDevice = (deviceId: number) => {
setConfigSelection(deviceId);
setConfigFeedback(null);
};
const updateConfigField = (
field: "name" | "protocol" | "model",
value: string
) => {
setConfigForm((prev) => ({ ...prev, [field]: value }));
};
const updateParameterRow = (
index: number,
field: "key" | "value",
value: string
) => {
setConfigForm((prev) => {
const nextParameters = prev.parameters.map((param, idx) =>
idx === index ? { ...param, [field]: value } : param
);
return { ...prev, parameters: nextParameters };
});
};
const addParameterRow = () => {
setConfigForm((prev) => ({
...prev,
parameters: [...prev.parameters, emptyParameterRow()],
}));
};
const removeParameterRow = (index: number) => {
setConfigForm((prev) => {
const nextParameters = prev.parameters.filter((_, idx) => idx !== index);
return {
...prev,
parameters: nextParameters.length ? nextParameters : [emptyParameterRow()],
};
});
};
const resetConfigForm = () => {
setConfigFeedback(null);
if (configSelection === "new") {
setConfigForm(createEmptyConfigForm());
return;
}
const selected = configDevices.find((device) => device.id === configSelection);
if (selected) {
setConfigForm({
id: selected.id,
name: selected.name,
protocol: selected.protocol,
model: selected.model ?? "",
parameters: parametersToPairs(selected.parameters),
});
}
};
const handleSaveConfigDevice = async () => {
setConfigFeedback(null);
const isNew = configSelection === "new";
const trimmedName = configForm.name.trim();
const trimmedProtocol = configForm.protocol.trim();
if (!trimmedName || !trimmedProtocol) {
setConfigFeedback({
type: "error",
message: "Name and protocol are required.",
});
return;
}
const basePayload = {
name: trimmedName,
protocol: trimmedProtocol,
model: configForm.model.trim() || undefined,
parameters: pairsToParameters(configForm.parameters),
};
const payload = isNew
? basePayload
: { ...basePayload, id: configSelection };
const url = isNew
? "/api/config/devices"
: `/api/config/devices/${configSelection}`;
const method = isNew ? "POST" : "PUT";
try {
const result = (await sendConfigAction({
url,
method,
data: payload,
})) as ConfigDevice | null;
await mutateConfigDevices();
if (isNew) {
if (result?.id) {
setConfigSelection(result.id);
} else {
startCreateConfigDevice();
}
setConfigFeedback({ type: "success", message: "Device created." });
} else {
setConfigFeedback({ type: "success", message: "Device updated." });
}
} catch (error) {
setConfigFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Failed to save device.",
});
}
};
const handleDeleteConfigDevice = async () => {
if (configSelection === "new") {
return;
}
const confirmed = window.confirm(
"Delete this config device from tellstick.conf?"
);
if (!confirmed) {
return;
}
try {
await sendConfigAction({
url: `/api/config/devices/${configSelection}`,
method: "DELETE",
});
await mutateConfigDevices();
startCreateConfigDevice();
setConfigFeedback({ type: "success", message: "Device deleted." });
} catch (error) {
setConfigFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Failed to delete device.",
});
}
};
return ( return (
<div className="app"> <div className="app">
<header className="hero"> <header className="hero">
@@ -542,6 +778,179 @@ function App() {
</section> </section>
</div> </div>
<section className="panel config-panel">
<div className="panel-head">
<div>
<p className="eyebrow">tellstick.conf</p>
<h2>Configuration Editor</h2>
</div>
<span className="pill">{configDeviceCount} defined</span>
</div>
{configDevicesLoading ? (
<div className="empty-state shimmer">Loading configuration</div>
) : (
<div className="config-editor-grid">
<div className="config-device-list">
<button
type="button"
className={`config-device-row ${
configSelection === "new" ? "active" : ""
}`}
onClick={startCreateConfigDevice}
disabled={isConfigMutating}
>
<div>
<p className="config-device-name"> New device</p>
<p className="device-meta">Start from scratch</p>
</div>
</button>
{configDevices.length === 0 ? (
<div className="config-device-empty">
No devices in config yet.
</div>
) : (
configDevices.map((device) => (
<button
type="button"
key={device.id}
className={`config-device-row ${
configSelection === device.id ? "active" : ""
}`}
onClick={() => startEditingConfigDevice(device.id)}
disabled={isConfigMutating}
>
<div className="config-device-row-top">
<p className="config-device-name">
{device.name || `Device #${device.id}`}
</p>
<span className="badge badge-protocol">
{device.protocol}
</span>
</div>
<p className="device-meta">
#{device.id} · {device.model || "unknown model"}
</p>
</button>
))
)}
</div>
<div className="config-form">
<div className="config-form-header">
<h3>
{configSelection === "new"
? "Create new config device"
: `Editing device #${configSelection}`}
</h3>
{configSelection !== "new" && (
<button
type="button"
className="danger"
onClick={handleDeleteConfigDevice}
disabled={isConfigMutating}
>
Delete
</button>
)}
</div>
<label className="input-label" htmlFor="config-name">
Name
</label>
<input
id="config-name"
value={configForm.name}
onChange={(event) =>
updateConfigField("name", event.target.value)
}
placeholder="Living room lamp"
/>
<label className="input-label" htmlFor="config-protocol">
Protocol
</label>
<input
id="config-protocol"
value={configForm.protocol}
onChange={(event) =>
updateConfigField("protocol", event.target.value)
}
placeholder="arctech"
/>
<label className="input-label" htmlFor="config-model">
Model (optional)
</label>
<input
id="config-model"
value={configForm.model}
onChange={(event) =>
updateConfigField("model", event.target.value)
}
placeholder="selflearning-switch"
/>
<div className="parameter-header">
<h4>Parameters</h4>
<button
type="button"
className="outline"
onClick={addParameterRow}
disabled={isConfigMutating}
>
Add parameter
</button>
</div>
<div className="parameter-rows">
{configForm.parameters.map((parameter, index) => (
<div className="parameter-row" key={`parameter-${index}`}>
<input
value={parameter.key}
placeholder="Key (house, unit, code…)"
onChange={(event) =>
updateParameterRow(index, "key", event.target.value)
}
/>
<input
value={parameter.value}
placeholder="Value"
onChange={(event) =>
updateParameterRow(index, "value", event.target.value)
}
/>
<button
type="button"
className="icon-btn subtle"
onClick={() => removeParameterRow(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={handleSaveConfigDevice}
disabled={isConfigMutating}
>
{configSelection === "new" ? "Create device" : "Save changes"}
</button>
<button
type="button"
className="ghost"
onClick={resetConfigForm}
disabled={isConfigMutating}
>
Reset
</button>
</div>
</div>
</div>
)}
</section>
<section className="panel"> <section className="panel">
<div className="panel-head"> <div className="panel-head">
<div> <div>

2
go.mod
View File

@@ -1,4 +1,4 @@
module app module git.k7n.net/mats/go-telldus
go 1.25.4 go 1.25.4

135
main.go
View File

@@ -8,6 +8,7 @@ import (
"os/signal" "os/signal"
"strconv" "strconv"
"git.k7n.net/mats/go-telldus/pkg/config"
"git.k7n.net/mats/go-telldus/pkg/datastore" "git.k7n.net/mats/go-telldus/pkg/datastore"
"git.k7n.net/mats/go-telldus/pkg/devices" "git.k7n.net/mats/go-telldus/pkg/devices"
"git.k7n.net/mats/go-telldus/pkg/mqtt" "git.k7n.net/mats/go-telldus/pkg/mqtt"
@@ -23,6 +24,8 @@ var mqttClient *mqtt.Client
var store *datastore.DataStore var store *datastore.DataStore
var daemonMgr *daemon.Manager var daemonMgr *daemon.Manager
var eventMgr *devices.EventManager var eventMgr *devices.EventManager
var configParser *config.Parser
var configPath string
const maxEvents = 1000 const maxEvents = 1000
@@ -83,8 +86,11 @@ func main() {
return nil return nil
} }
// Initialize config parser
configPath = "/etc/tellstick.conf"
configParser = config.NewParser(configPath)
// Start watching config file // Start watching config file
configPath := "/etc/tellstick.conf"
watcher := daemon.NewWatcher(configPath, reloadDevices) watcher := daemon.NewWatcher(configPath, reloadDevices)
go func() { go func() {
if err := watcher.Watch(); err != nil { if err := watcher.Watch(); err != nil {
@@ -257,6 +263,13 @@ func setupRoutes() *http.ServeMux {
mux.HandleFunc("/api/events/raw", getRawEvents) mux.HandleFunc("/api/events/raw", getRawEvents)
mux.HandleFunc("/api/events/sensor", getSensorEvents) mux.HandleFunc("/api/events/sensor", getSensorEvents)
mux.HandleFunc("/api/potential_devices", getPotentialDevices) mux.HandleFunc("/api/potential_devices", getPotentialDevices)
// Config endpoints
mux.HandleFunc("GET /api/config", getConfig)
mux.HandleFunc("GET /api/config/devices", getConfigDevices)
mux.HandleFunc("GET /api/config/devices/{id}", getConfigDevice)
mux.HandleFunc("POST /api/config/devices", createConfigDevice)
mux.HandleFunc("PUT /api/config/devices/{id}", updateConfigDevice)
mux.HandleFunc("DELETE /api/config/devices/{id}", deleteConfigDevice)
// Serve static files for the frontend // Serve static files for the frontend
mux.Handle("/", http.FileServer(http.Dir("./dist"))) mux.Handle("/", http.FileServer(http.Dir("./dist")))
return mux return mux
@@ -352,3 +365,123 @@ func getSensor(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(sensor) json.NewEncoder(w).Encode(sensor)
} }
// Config CRUD handlers
func getConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := configParser.Parse()
if err != nil {
http.Error(w, "Failed to parse config", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cfg)
}
func getConfigDevices(w http.ResponseWriter, r *http.Request) {
cfg, err := configParser.Parse()
if err != nil {
http.Error(w, "Failed to parse config", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cfg.Devices)
}
func getConfigDevice(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid device ID", http.StatusBadRequest)
return
}
cfg, err := configParser.Parse()
if err != nil {
http.Error(w, "Failed to parse config", http.StatusInternalServerError)
return
}
device := cfg.GetDevice(id)
if device == nil {
http.Error(w, "Device not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(device)
}
func createConfigDevice(w http.ResponseWriter, r *http.Request) {
var device config.Device
if err := json.NewDecoder(r.Body).Decode(&device); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
cfg, err := configParser.Parse()
if err != nil {
http.Error(w, "Failed to parse config", http.StatusInternalServerError)
return
}
// Auto-assign ID if not provided or if ID is 0
if device.ID == 0 {
device.ID = cfg.GetNextDeviceID()
}
cfg.AddDevice(device)
if err := configParser.Write(cfg); err != nil {
http.Error(w, "Failed to write config", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(device)
}
func updateConfigDevice(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid device ID", http.StatusBadRequest)
return
}
var device config.Device
if err := json.NewDecoder(r.Body).Decode(&device); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
device.ID = id // Ensure ID matches path parameter
cfg, err := configParser.Parse()
if err != nil {
http.Error(w, "Failed to parse config", http.StatusInternalServerError)
return
}
if !cfg.UpdateDevice(device) {
http.Error(w, "Device not found", http.StatusNotFound)
return
}
if err := configParser.Write(cfg); err != nil {
http.Error(w, "Failed to write config", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(device)
}
func deleteConfigDevice(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid device ID", http.StatusBadRequest)
return
}
cfg, err := configParser.Parse()
if err != nil {
http.Error(w, "Failed to parse config", http.StatusInternalServerError)
return
}
if !cfg.DeleteDevice(id) {
http.Error(w, "Device not found", http.StatusNotFound)
return
}
if err := configParser.Write(cfg); err != nil {
http.Error(w, "Failed to write config", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

289
pkg/config/parser.go Normal file
View File

@@ -0,0 +1,289 @@
package config
import (
"bufio"
"fmt"
"os"
"strings"
)
// Device represents a device configuration in tellstick.conf
type Device struct {
ID int `json:"id"`
Name string `json:"name"`
Protocol string `json:"protocol"`
Model string `json:"model"`
Parameters map[string]string `json:"parameters"`
}
// Controller represents a controller configuration
type Controller struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Serial string `json:"serial,omitempty"`
}
// Config represents the entire tellstick.conf structure
type Config struct {
User string `json:"user,omitempty"`
Group string `json:"group,omitempty"`
DevicePath string `json:"devicePath,omitempty"`
IgnoreControllerConfirmation int `json:"ignoreControllerConfirmation,omitempty"`
Controllers []Controller `json:"controllers"`
Devices []Device `json:"devices"`
}
// Parser handles parsing and writing tellstick.conf files
type Parser struct {
filePath string
}
// NewParser creates a new config parser
func NewParser(filePath string) *Parser {
return &Parser{filePath: filePath}
}
// Parse reads and parses the tellstick.conf file
func (p *Parser) Parse() (*Config, error) {
file, err := os.Open(p.filePath)
if err != nil {
return nil, err
}
defer file.Close()
config := &Config{
Controllers: []Controller{},
Devices: []Device{},
}
scanner := bufio.NewScanner(file)
var currentSection string
var currentDevice *Device
var currentController *Controller
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Check for section headers
if strings.HasPrefix(line, "user") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
config.User = strings.Trim(strings.TrimSpace(parts[1]), "\"")
}
continue
}
if strings.HasPrefix(line, "group") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
config.Group = strings.Trim(strings.TrimSpace(parts[1]), "\"")
}
continue
}
if strings.HasPrefix(line, "deviceNode") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
config.DevicePath = strings.Trim(strings.TrimSpace(parts[1]), "\"")
}
continue
}
if strings.HasPrefix(line, "ignoreControllerConfirmation") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
fmt.Sscanf(strings.TrimSpace(parts[1]), "%d", &config.IgnoreControllerConfirmation)
}
continue
}
// Section detection
if strings.HasPrefix(line, "controller") {
currentSection = "controller"
currentController = &Controller{}
continue
}
if strings.HasPrefix(line, "device") {
currentSection = "device"
currentDevice = &Device{
Parameters: make(map[string]string),
}
continue
}
// End of section
if line == "}" {
if currentSection == "device" && currentDevice != nil {
config.Devices = append(config.Devices, *currentDevice)
currentDevice = nil
}
if currentSection == "controller" && currentController != nil {
config.Controllers = append(config.Controllers, *currentController)
currentController = nil
}
currentSection = ""
continue
}
// Parse device/controller properties
if currentSection == "device" && currentDevice != nil {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), "\"")
switch key {
case "id":
fmt.Sscanf(value, "%d", &currentDevice.ID)
case "name":
currentDevice.Name = value
case "protocol":
currentDevice.Protocol = value
case "model":
currentDevice.Model = value
default:
currentDevice.Parameters[key] = value
}
}
}
if currentSection == "controller" && currentController != nil {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), "\"")
switch key {
case "id":
fmt.Sscanf(value, "%d", &currentController.ID)
case "name":
currentController.Name = value
case "type":
currentController.Type = value
case "serial":
currentController.Serial = value
}
}
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return config, nil
}
// Write writes the configuration to the tellstick.conf file
func (p *Parser) Write(config *Config) error {
file, err := os.Create(p.filePath)
if err != nil {
return err
}
defer file.Close()
w := bufio.NewWriter(file)
// Write global settings
if config.User != "" {
fmt.Fprintf(w, "user = \"%s\"\n", config.User)
}
if config.Group != "" {
fmt.Fprintf(w, "group = \"%s\"\n", config.Group)
}
if config.DevicePath != "" {
fmt.Fprintf(w, "deviceNode = \"%s\"\n", config.DevicePath)
}
if config.IgnoreControllerConfirmation > 0 {
fmt.Fprintf(w, "ignoreControllerConfirmation = %d\n", config.IgnoreControllerConfirmation)
}
w.WriteString("\n")
// Write controllers
for _, ctrl := range config.Controllers {
w.WriteString("controller {\n")
fmt.Fprintf(w, " id = %d\n", ctrl.ID)
if ctrl.Name != "" {
fmt.Fprintf(w, " name = \"%s\"\n", ctrl.Name)
}
if ctrl.Type != "" {
fmt.Fprintf(w, " type = \"%s\"\n", ctrl.Type)
}
if ctrl.Serial != "" {
fmt.Fprintf(w, " serial = \"%s\"\n", ctrl.Serial)
}
w.WriteString("}\n\n")
}
// Write devices
for _, dev := range config.Devices {
w.WriteString("device {\n")
fmt.Fprintf(w, " id = %d\n", dev.ID)
fmt.Fprintf(w, " name = \"%s\"\n", dev.Name)
fmt.Fprintf(w, " protocol = \"%s\"\n", dev.Protocol)
if dev.Model != "" {
fmt.Fprintf(w, " model = \"%s\"\n", dev.Model)
}
// Write parameters
for key, value := range dev.Parameters {
fmt.Fprintf(w, " %s = \"%s\"\n", key, value)
}
w.WriteString("}\n\n")
}
return w.Flush()
}
// GetDevice returns a device by ID
func (c *Config) GetDevice(id int) *Device {
for i := range c.Devices {
if c.Devices[i].ID == id {
return &c.Devices[i]
}
}
return nil
}
// AddDevice adds a new device to the configuration
func (c *Config) AddDevice(device Device) {
c.Devices = append(c.Devices, device)
}
// UpdateDevice updates an existing device
func (c *Config) UpdateDevice(device Device) bool {
for i := range c.Devices {
if c.Devices[i].ID == device.ID {
c.Devices[i] = device
return true
}
}
return false
}
// DeleteDevice removes a device by ID
func (c *Config) DeleteDevice(id int) bool {
for i := range c.Devices {
if c.Devices[i].ID == id {
c.Devices = append(c.Devices[:i], c.Devices[i+1:]...)
return true
}
}
return false
}
// GetNextDeviceID returns the next available device ID
func (c *Config) GetNextDeviceID() int {
maxID := 0
for _, dev := range c.Devices {
if dev.ID > maxID {
maxID = dev.ID
}
}
return maxID + 1
}