From b6343149c80fa835a873bd12835a278d3a0f8762 Mon Sep 17 00:00:00 2001 From: Mats Tornberg Date: Sat, 22 Nov 2025 21:19:38 +0100 Subject: [PATCH] slask --- pkg/datastore/datastore.go | 150 +++++++++++++++++++++++++++---------- pkg/datastore/types.go | 2 + pkg/devices/sync.go | 4 + pkg/mqtt/discovery.go | 148 +++++++++++++++++++++++------------- 4 files changed, 213 insertions(+), 91 deletions(-) diff --git a/pkg/datastore/datastore.go b/pkg/datastore/datastore.go index 0c474b9..180ab57 100644 --- a/pkg/datastore/datastore.go +++ b/pkg/datastore/datastore.go @@ -35,52 +35,124 @@ func (ds *DataStore) Close() error { return ds.db.Close() } -// initTables creates the necessary database tables +// initTables creates the necessary database tables and applies migrations func (ds *DataStore) initTables() error { - tables := []string{ - `CREATE TABLE IF NOT EXISTS devices ( - id INTEGER PRIMARY KEY, - name TEXT, - unique_id TEXT UNIQUE - )`, - `CREATE TABLE IF NOT EXISTS sensors ( - sensor_id INTEGER PRIMARY KEY AUTOINCREMENT, - protocol TEXT, - model TEXT, - id INTEGER, - name TEXT, - temperature_unique_id TEXT, - humidity_unique_id TEXT, - last_temperature TEXT, - last_humidity TEXT, - last_timestamp INTEGER, - hidden INTEGER DEFAULT 0, - UNIQUE(protocol, model, id) - )`, - `CREATE TABLE IF NOT EXISTS potential_devices ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - class TEXT, - protocol TEXT, - model TEXT, - device_id TEXT, - last_data TEXT, - last_seen INTEGER - )`, + // Create schema version table + if _, err := ds.db.Exec(` + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + ) + `); err != nil { + return err } - for _, table := range tables { - if _, err := ds.db.Exec(table); err != nil { - return err + // Get current schema version + currentVersion := ds.getCurrentSchemaVersion() + + // Apply migrations + migrations := []struct { + version int + sql []string + }{ + { + version: 1, + sql: []string{ + `CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY, + name TEXT, + unique_id TEXT UNIQUE + )`, + `CREATE TABLE IF NOT EXISTS sensors ( + sensor_id INTEGER PRIMARY KEY AUTOINCREMENT, + protocol TEXT, + model TEXT, + id INTEGER, + name TEXT, + temperature_unique_id TEXT, + humidity_unique_id TEXT, + last_temperature TEXT, + last_humidity TEXT, + last_timestamp INTEGER, + hidden INTEGER DEFAULT 0, + UNIQUE(protocol, model, id) + )`, + `CREATE TABLE IF NOT EXISTS potential_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class TEXT, + protocol TEXT, + model TEXT, + device_id TEXT, + last_data TEXT, + last_seen INTEGER + )`, + }, + }, + { + version: 2, + sql: []string{ + `ALTER TABLE devices ADD COLUMN protocol TEXT`, + `ALTER TABLE devices ADD COLUMN model TEXT`, + }, + }, + } + + // Apply pending migrations + for _, migration := range migrations { + if migration.version > currentVersion { + for _, sql := range migration.sql { + if _, err := ds.db.Exec(sql); err != nil { + // Ignore errors for ALTER TABLE ADD COLUMN if column already exists + if !ds.isColumnExistsError(err) { + return fmt.Errorf("migration v%d failed: %w", migration.version, err) + } + } + } + + // Record migration + if err := ds.recordMigration(migration.version); err != nil { + return err + } } } + return nil } +// getCurrentSchemaVersion returns the current schema version +func (ds *DataStore) getCurrentSchemaVersion() int { + var version int + err := ds.db.QueryRow("SELECT MAX(version) FROM schema_version").Scan(&version) + if err != nil { + return 0 + } + return version +} + +// recordMigration records that a migration has been applied +func (ds *DataStore) recordMigration(version int) error { + _, err := ds.db.Exec( + "INSERT INTO schema_version (version, applied_at) VALUES (?, ?)", + version, time.Now().Unix(), + ) + return err +} + +// isColumnExistsError checks if the error is due to a column already existing +func (ds *DataStore) isColumnExistsError(err error) bool { + if err == nil { + return false + } + return sql.ErrNoRows != err && + (err.Error() == "duplicate column name: protocol" || + err.Error() == "duplicate column name: model") +} + // UpsertDevice inserts or updates a device func (ds *DataStore) UpsertDevice(device *Device) error { _, err := ds.db.Exec( - "INSERT OR REPLACE INTO devices (id, name, unique_id) VALUES (?, ?, ?)", - device.ID, device.Name, device.UniqueID, + "INSERT OR REPLACE INTO devices (id, name, unique_id, protocol, model) VALUES (?, ?, ?, ?, ?)", + device.ID, device.Name, device.UniqueID, device.Protocol, device.Model, ) return err } @@ -89,9 +161,9 @@ func (ds *DataStore) UpsertDevice(device *Device) error { func (ds *DataStore) GetDevice(id int) (*Device, error) { device := &Device{} err := ds.db.QueryRow( - "SELECT id, name, unique_id FROM devices WHERE id = ?", + "SELECT id, name, unique_id, protocol, model FROM devices WHERE id = ?", id, - ).Scan(&device.ID, &device.Name, &device.UniqueID) + ).Scan(&device.ID, &device.Name, &device.UniqueID, &device.Protocol, &device.Model) if err != nil { return nil, err } @@ -101,7 +173,7 @@ func (ds *DataStore) GetDevice(id int) (*Device, error) { // ListDevices returns an iterator over all devices func (ds *DataStore) ListDevices() iter.Seq[*Device] { return func(yield func(*Device) bool) { - rows, err := ds.db.Query("SELECT id, name, unique_id FROM devices ORDER BY id") + rows, err := ds.db.Query("SELECT id, name, unique_id, protocol, model FROM devices ORDER BY id") if err != nil { return } @@ -109,7 +181,7 @@ func (ds *DataStore) ListDevices() iter.Seq[*Device] { for rows.Next() { device := &Device{} - if err := rows.Scan(&device.ID, &device.Name, &device.UniqueID); err != nil { + if err := rows.Scan(&device.ID, &device.Name, &device.UniqueID, &device.Protocol, &device.Model); err != nil { continue } if !yield(device) { diff --git a/pkg/datastore/types.go b/pkg/datastore/types.go index 3b860ef..ae9e72a 100644 --- a/pkg/datastore/types.go +++ b/pkg/datastore/types.go @@ -7,6 +7,8 @@ type Device struct { ID int `json:"id"` Name string `json:"name"` UniqueID string `json:"unique_id"` + Protocol string `json:"protocol"` + Model string `json:"model"` } // Sensor represents a Telldus sensor diff --git a/pkg/devices/sync.go b/pkg/devices/sync.go index 55b3071..e96d1a3 100644 --- a/pkg/devices/sync.go +++ b/pkg/devices/sync.go @@ -24,10 +24,14 @@ func (s *Syncer) SyncDevices() error { for i := 0; i < numDevices; i++ { deviceID := telldus.GetDeviceId(i) name := telldus.GetName(deviceID) + protocol := telldus.GetProtocol(deviceID) + model := telldus.GetModel(deviceID) device := &datastore.Device{ ID: deviceID, Name: name, UniqueID: fmt.Sprintf("telldus_device_%d", deviceID), + Protocol: protocol, + Model: model, } if err := s.store.UpsertDevice(device); err != nil { log.Printf("Error upserting device %d: %v", deviceID, err) diff --git a/pkg/mqtt/discovery.go b/pkg/mqtt/discovery.go index e3c516c..d617a6e 100644 --- a/pkg/mqtt/discovery.go +++ b/pkg/mqtt/discovery.go @@ -1,6 +1,7 @@ package mqtt import ( + "encoding/json" "fmt" "log" @@ -8,23 +9,45 @@ import ( "git.k7n.net/mats/go-telldus/pkg/telldus" ) -// DeviceDiscovery represents Home Assistant device discovery payload -type DeviceDiscovery struct { - Name string `json:"name"` - CommandTopic string `json:"command_topic"` - StateTopic string `json:"state_topic"` - UniqueID string `json:"unique_id"` - Device map[string]interface{} `json:"device"` +// HADevice represents a device in Home Assistant MQTT discovery +type HADevice struct { + Identifiers []string `json:"identifiers"` + Name string `json:"name"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model,omitempty"` } -// SensorDiscovery represents Home Assistant sensor discovery payload +// SwitchDiscovery represents Home Assistant MQTT switch discovery config +// Reference: https://www.home-assistant.io/integrations/switch.mqtt/ +type SwitchDiscovery struct { + Name string `json:"name"` + CommandTopic string `json:"command_topic"` + StateTopic string `json:"state_topic"` + UniqueID string `json:"unique_id"` + Device HADevice `json:"device"` + PayloadOn string `json:"payload_on,omitempty"` + PayloadOff string `json:"payload_off,omitempty"` + StateOn string `json:"state_on,omitempty"` + StateOff string `json:"state_off,omitempty"` + OptimisticMode bool `json:"optimistic,omitempty"` + Qos int `json:"qos,omitempty"` + Retain bool `json:"retain,omitempty"` + AvailabilityTopic string `json:"availability_topic,omitempty"` +} + +// SensorDiscovery represents Home Assistant MQTT sensor discovery config +// Reference: https://www.home-assistant.io/integrations/sensor.mqtt/ type SensorDiscovery struct { - Name string `json:"name"` - StateTopic string `json:"state_topic"` - UnitOfMeasurement string `json:"unit_of_measurement,omitempty"` - DeviceClass string `json:"device_class,omitempty"` - UniqueID string `json:"unique_id"` - Device map[string]interface{} `json:"device"` + Name string `json:"name"` + StateTopic string `json:"state_topic"` + UniqueID string `json:"unique_id"` + Device HADevice `json:"device"` + UnitOfMeasurement string `json:"unit_of_measurement,omitempty"` + DeviceClass string `json:"device_class,omitempty"` + StateClass string `json:"state_class,omitempty"` + ValueTemplate string `json:"value_template,omitempty"` + Qos int `json:"qos,omitempty"` + AvailabilityTopic string `json:"availability_topic,omitempty"` } // PublishAllDiscovery publishes Home Assistant discovery messages for all devices and sensors @@ -55,19 +78,29 @@ func (c *Client) PublishDeviceDiscovery(deviceID int) error { return fmt.Errorf("device %d not found: %w", deviceID, err) } - topic := fmt.Sprintf("homeassistant/switch/%s/config", device.UniqueID) - payload := fmt.Sprintf(`{ - "name": "%s", - "command_topic": "telldus/device/%d/set", - "state_topic": "telldus/device/%d/state", - "unique_id": "%s", - "device": { - "identifiers": ["telldus_%d"], - "name": "%s", - "manufacturer": "Telldus" - } - }`, device.Name, deviceID, deviceID, device.UniqueID, deviceID, device.Name) + discovery := SwitchDiscovery{ + Name: device.Name, + CommandTopic: fmt.Sprintf("telldus/device/%d/set", deviceID), + StateTopic: fmt.Sprintf("telldus/device/%d/state", deviceID), + UniqueID: device.UniqueID, + PayloadOn: "ON", + PayloadOff: "OFF", + StateOn: "ON", + StateOff: "OFF", + Device: HADevice{ + Identifiers: []string{fmt.Sprintf("telldus_%d", deviceID)}, + Name: device.Name, + Manufacturer: "Telldus", + Model: fmt.Sprintf("%s %s", device.Protocol, device.Model), + }, + } + payload, err := json.Marshal(discovery) + if err != nil { + return fmt.Errorf("failed to marshal discovery: %w", err) + } + + topic := fmt.Sprintf("homeassistant/switch/%s/config", device.UniqueID) c.client.Publish(topic, 0, true, payload) return nil } @@ -120,39 +153,50 @@ func (c *Client) PublishSensorDiscovery(protocol, model string, id int) error { // publishSensorDiscoveryForSensor publishes discovery messages for a sensor's data types func (c *Client) publishSensorDiscoveryForSensor(sensor *datastore.Sensor, dataTypes int) error { + sensorDevice := HADevice{ + Identifiers: []string{fmt.Sprintf("telldus_sensor_%s_%s_%d", sensor.Protocol, sensor.Model, sensor.ID)}, + Name: sensor.Name, + Manufacturer: "Telldus", + Model: fmt.Sprintf("%s %s", sensor.Protocol, sensor.Model), + } + if dataTypes&telldus.DataTypeTemperature != 0 && sensor.TemperatureUniqueID != "" { + discovery := SensorDiscovery{ + Name: fmt.Sprintf("%s Temperature", sensor.Name), + StateTopic: fmt.Sprintf("telldus/sensor/%s/%s/%d/temperature", sensor.Protocol, sensor.Model, sensor.ID), + UniqueID: sensor.TemperatureUniqueID, + UnitOfMeasurement: "°C", + DeviceClass: "temperature", + StateClass: "measurement", + Device: sensorDevice, + } + + payload, err := json.Marshal(discovery) + if err != nil { + return fmt.Errorf("failed to marshal temperature discovery: %w", err) + } + topic := fmt.Sprintf("homeassistant/sensor/%s/config", sensor.TemperatureUniqueID) - payload := fmt.Sprintf(`{ - "name": "%s Temperature", - "state_topic": "telldus/sensor/%s/%s/%d/temperature", - "unit_of_measurement": "°C", - "device_class": "temperature", - "unique_id": "%s", - "device": { - "identifiers": ["telldus_sensor_%s_%s_%d"], - "name": "%s", - "manufacturer": "Telldus" - } - }`, sensor.Name, sensor.Protocol, sensor.Model, sensor.ID, sensor.TemperatureUniqueID, - sensor.Protocol, sensor.Model, sensor.ID, sensor.Name) c.client.Publish(topic, 0, true, payload) } if dataTypes&telldus.DataTypeHumidity != 0 && sensor.HumidityUniqueID != "" { + discovery := SensorDiscovery{ + Name: fmt.Sprintf("%s Humidity", sensor.Name), + StateTopic: fmt.Sprintf("telldus/sensor/%s/%s/%d/humidity", sensor.Protocol, sensor.Model, sensor.ID), + UniqueID: sensor.HumidityUniqueID, + UnitOfMeasurement: "%", + DeviceClass: "humidity", + StateClass: "measurement", + Device: sensorDevice, + } + + payload, err := json.Marshal(discovery) + if err != nil { + return fmt.Errorf("failed to marshal humidity discovery: %w", err) + } + topic := fmt.Sprintf("homeassistant/sensor/%s/config", sensor.HumidityUniqueID) - payload := fmt.Sprintf(`{ - "name": "%s Humidity", - "state_topic": "telldus/sensor/%s/%s/%d/humidity", - "unit_of_measurement": "%%", - "device_class": "humidity", - "unique_id": "%s", - "device": { - "identifiers": ["telldus_sensor_%s_%s_%d"], - "name": "%s", - "manufacturer": "Telldus" - } - }`, sensor.Name, sensor.Protocol, sensor.Model, sensor.ID, sensor.HumidityUniqueID, - sensor.Protocol, sensor.Model, sensor.ID, sensor.Name) c.client.Publish(topic, 0, true, payload) }