add configuration editor
This commit is contained in:
266
CONFIG_API.md
Normal file
266
CONFIG_API.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module app
|
module git.k7n.net/mats/go-telldus
|
||||||
|
|
||||||
go 1.25.4
|
go 1.25.4
|
||||||
|
|
||||||
|
|||||||
135
main.go
135
main.go
@@ -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
289
pkg/config/parser.go
Normal 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", ¤tDevice.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", ¤tController.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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user