diff --git a/CONFIG_API.md b/CONFIG_API.md new file mode 100644 index 0000000..536bc80 --- /dev/null +++ b/CONFIG_API.md @@ -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 +``` diff --git a/frontend/src/App.css b/frontend/src/App.css index fe6bc77..2183443 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -500,6 +500,149 @@ input:focus { 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 { margin-top: 20px; border-top: 1px solid rgba(255, 255, 255, 0.06); @@ -553,4 +696,8 @@ input:focus { width: 100%; justify-content: flex-start; } + + .config-editor-grid { + grid-template-columns: 1fr; + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 63f04b8..3850fa3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type CSSProperties } from "react"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; import useSWR from "swr"; import useSWRMutation from "swr/mutation"; import "./App.css"; @@ -8,6 +8,56 @@ interface Device { name: string; } +interface ConfigDevice { + id: number; + name: string; + protocol: string; + model?: string; + parameters: Record; +} + +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 +): ParameterPair[] => { + if (!parameters || Object.keys(parameters).length === 0) { + return [emptyParameterRow()]; + } + return Object.entries(parameters).map(([key, value]) => ({ key, value })); +}; + +const pairsToParameters = (pairs: ParameterPair[]): Record => { + return pairs.reduce((acc, pair) => { + const trimmedKey = pair.key.trim(); + if (trimmedKey) { + acc[trimmedKey] = pair.value.trim(); + } + return acc; + }, {} as Record); +}; + interface PotentialDevice { class: string; protocol: string; @@ -116,6 +166,15 @@ function App() { null ); const [isRefreshing, setIsRefreshing] = useState(false); + const [configSelection, setConfigSelection] = useState<"new" | number>( + "new" + ); + const [configForm, setConfigForm] = useState( + createEmptyConfigForm + ); + const [configFeedback, setConfigFeedback] = useState< + { type: "success" | "error"; message: string } | null + >(null); const { data: devicesData, @@ -135,12 +194,37 @@ function App() { mutate: mutatePotentialDevices, } = useSWR("/api/potential_devices", fetcher); + const { + data: configDevicesData, + isLoading: configDevicesLoading, + mutate: mutateConfigDevices, + } = useSWR("/api/config/devices", fetcher); + const devices = devicesData ?? []; const sensors = sensorsData ?? []; 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 } = useSWRMutation("control-action", mutationFetcher); + const { trigger: sendConfigAction, isMutating: isConfigMutating } = + useSWRMutation("config-action", mutationFetcher); const groupedPotentialDevices = useMemo(() => { const groups: Record = {}; @@ -161,6 +245,7 @@ function App() { () => sensors.filter((sensor) => !sensor.hidden).length, [sensors] ); + const configDeviceCount = configDevices.length; const latestSensorUpdate = useMemo(() => { const timestamps = sensors @@ -176,6 +261,7 @@ function App() { const dashboardStats = useMemo( () => [ { label: "Devices", value: devices.length, accent: "accent" }, + { label: "Config devices", value: configDeviceCount, accent: "accent" }, { label: "Visible sensors", value: visibleSensors, accent: "success" }, { label: "Hidden sensors", value: hiddenSensors, accent: "warning" }, { @@ -184,7 +270,13 @@ function App() { accent: "muted", }, ], - [devices.length, visibleSensors, hiddenSensors, groupedPotentialDevices] + [ + devices.length, + configDeviceCount, + visibleSensors, + hiddenSensors, + groupedPotentialDevices, + ] ); const refreshAll = async () => { @@ -262,6 +354,150 @@ function App() { 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 (
@@ -542,6 +778,179 @@ function App() {
+
+
+
+

tellstick.conf

+

Configuration Editor

+
+ {configDeviceCount} defined +
+ {configDevicesLoading ? ( +
Loading configuration…
+ ) : ( +
+
+ + {configDevices.length === 0 ? ( +
+ No devices in config yet. +
+ ) : ( + configDevices.map((device) => ( + + )) + )} +
+
+
+

+ {configSelection === "new" + ? "Create new config device" + : `Editing device #${configSelection}`} +

+ {configSelection !== "new" && ( + + )} +
+ + + updateConfigField("name", event.target.value) + } + placeholder="Living room lamp" + /> + + + updateConfigField("protocol", event.target.value) + } + placeholder="arctech" + /> + + + updateConfigField("model", event.target.value) + } + placeholder="selflearning-switch" + /> +
+

Parameters

+ +
+
+ {configForm.parameters.map((parameter, index) => ( +
+ + updateParameterRow(index, "key", event.target.value) + } + /> + + updateParameterRow(index, "value", event.target.value) + } + /> + +
+ ))} +
+ {configFeedback && ( +
+ {configFeedback.message} +
+ )} +
+ + +
+
+
+ )} +
+
diff --git a/go.mod b/go.mod index 97a3549..8b3443b 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module app +module git.k7n.net/mats/go-telldus go 1.25.4 diff --git a/main.go b/main.go index 8adf12a..e583db1 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "os/signal" "strconv" + "git.k7n.net/mats/go-telldus/pkg/config" "git.k7n.net/mats/go-telldus/pkg/datastore" "git.k7n.net/mats/go-telldus/pkg/devices" "git.k7n.net/mats/go-telldus/pkg/mqtt" @@ -23,6 +24,8 @@ var mqttClient *mqtt.Client var store *datastore.DataStore var daemonMgr *daemon.Manager var eventMgr *devices.EventManager +var configParser *config.Parser +var configPath string const maxEvents = 1000 @@ -83,8 +86,11 @@ func main() { return nil } + // Initialize config parser + configPath = "/etc/tellstick.conf" + configParser = config.NewParser(configPath) + // Start watching config file - configPath := "/etc/tellstick.conf" watcher := daemon.NewWatcher(configPath, reloadDevices) go func() { if err := watcher.Watch(); err != nil { @@ -257,6 +263,13 @@ func setupRoutes() *http.ServeMux { mux.HandleFunc("/api/events/raw", getRawEvents) mux.HandleFunc("/api/events/sensor", getSensorEvents) 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 mux.Handle("/", http.FileServer(http.Dir("./dist"))) return mux @@ -352,3 +365,123 @@ func getSensor(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") 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) +} diff --git a/pkg/config/parser.go b/pkg/config/parser.go new file mode 100644 index 0000000..6e10169 --- /dev/null +++ b/pkg/config/parser.go @@ -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 +}