Gisle Aune
3 years ago
19 changed files with 708 additions and 50 deletions
-
25app/api/bridges.go
-
123app/api/devices.go
-
9app/config/driver.go
-
2app/config/repo.go
-
4app/server.go
-
34app/services/bridges.go
-
12app/services/events.go
-
60app/services/publish.go
-
95app/services/synclights.go
-
8cmd/goose/main.go
-
7go.mod
-
17go.sum
-
8internal/mysql/bridgerepo.go
-
313internal/mysql/devicerepo.go
-
17models/device.go
-
1models/shared.go
-
5scripts/20210522140146_device.sql
-
2scripts/20210522140148_device_state.sql
-
16scripts/20210918105052_device_tag.sql
@ -0,0 +1,123 @@ |
|||||
|
package api |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"git.aiterp.net/lucifer/new-server/app/config" |
||||
|
"git.aiterp.net/lucifer/new-server/models" |
||||
|
"github.com/gin-gonic/gin" |
||||
|
"log" |
||||
|
"strings" |
||||
|
) |
||||
|
|
||||
|
func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) { |
||||
|
if strings.HasPrefix(fetchStr, "tag:") { |
||||
|
return config.DeviceRepository().FetchByReference(ctx, models.RKTag, fetchStr[4:]) |
||||
|
} else if strings.HasPrefix(fetchStr, "bridge:") { |
||||
|
return config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, fetchStr[7:]) |
||||
|
} else if fetchStr == "all" { |
||||
|
return config.DeviceRepository().FetchByReference(ctx, models.RKAll, "") |
||||
|
} else { |
||||
|
return config.DeviceRepository().FetchByReference(ctx, models.RKDeviceID, fetchStr) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func Devices(r gin.IRoutes) { |
||||
|
r.GET("", handler(func(c *gin.Context) (interface{}, error) { |
||||
|
return config.DeviceRepository().FetchByReference(ctxOf(c), models.RKAll, "") |
||||
|
})) |
||||
|
|
||||
|
r.GET("/:fetch", handler(func(c *gin.Context) (interface{}, error) { |
||||
|
return fetchDevices(ctxOf(c), c.Param("fetch")) |
||||
|
})) |
||||
|
|
||||
|
r.PUT("/:fetch/state", handler(func(c *gin.Context) (interface{}, error) { |
||||
|
state := models.NewDeviceState{} |
||||
|
err := parseBody(c, &state) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
devices, err := fetchDevices(ctxOf(c), c.Param("fetch")) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
if len(devices) == 0 { |
||||
|
return []models.Device{}, nil |
||||
|
} |
||||
|
|
||||
|
for i := range devices { |
||||
|
err := devices[i].SetState(state) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
config.PublishChannel <- devices |
||||
|
|
||||
|
go func() { |
||||
|
for _, device := range devices { |
||||
|
err := config.DeviceRepository().Save(context.Background(), &device) |
||||
|
if err != nil { |
||||
|
log.Println("Failed to save device for state:", err) |
||||
|
continue |
||||
|
} |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
return devices, nil |
||||
|
})) |
||||
|
|
||||
|
r.PUT("/:fetch/tags", handler(func(c *gin.Context) (interface{}, error) { |
||||
|
var body struct { |
||||
|
Add []string `json:"add"` |
||||
|
Remove []string `json:"remove"` |
||||
|
} |
||||
|
err := parseBody(c, &body) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
devices, err := fetchDevices(ctxOf(c), c.Param("fetch")) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
if len(devices) == 0 { |
||||
|
return []models.Device{}, nil |
||||
|
} |
||||
|
|
||||
|
for i := range devices { |
||||
|
device := &devices[i] |
||||
|
|
||||
|
for _, tag := range body.Add { |
||||
|
found := false |
||||
|
for _, tag2 := range device.Tags { |
||||
|
if tag == tag2 { |
||||
|
found = true |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if !found { |
||||
|
device.Tags = append(device.Tags, tag) |
||||
|
} |
||||
|
} |
||||
|
for _, tag := range body.Remove { |
||||
|
index := -1 |
||||
|
for i, tag2 := range device.Tags { |
||||
|
if tag == tag2 { |
||||
|
index = i |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
device.Tags = append(device.Tags[:index], device.Tags[index+1:]...) |
||||
|
} |
||||
|
|
||||
|
err = config.DeviceRepository().Save(ctxOf(c), device) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return devices, nil |
||||
|
})) |
||||
|
} |
@ -0,0 +1,95 @@ |
|||||
|
package services |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"git.aiterp.net/lucifer/new-server/app/config" |
||||
|
"git.aiterp.net/lucifer/new-server/models" |
||||
|
"log" |
||||
|
"strconv" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
func CheckNewDevices() { |
||||
|
go func() { |
||||
|
// Wait a bit before the first to let bridges connect.
|
||||
|
time.Sleep(time.Second * 5) |
||||
|
err := checkNewDevices() |
||||
|
if err != nil { |
||||
|
log.Println("Failed to sync lights:", err) |
||||
|
} |
||||
|
|
||||
|
for range time.NewTicker(time.Second * 30).C { |
||||
|
err := checkNewDevices() |
||||
|
if err != nil { |
||||
|
log.Println("Failed to sync lights:", err) |
||||
|
} |
||||
|
} |
||||
|
}() |
||||
|
} |
||||
|
|
||||
|
func checkNewDevices() error { |
||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*27) |
||||
|
defer cancel() |
||||
|
|
||||
|
bridges, err := config.BridgeRepository().FetchAll(ctx) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
for _, bridge := range bridges { |
||||
|
driver, err := config.DriverProvider().Provide(bridge.Driver) |
||||
|
if err != nil { |
||||
|
log.Println("Unknown/unsupported driver:", bridge.Driver) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID)) |
||||
|
if err != nil { |
||||
|
log.Println("Failed to list devices from db:", err) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
driverDevices, err := driver.ListDevices(ctx, bridge) |
||||
|
if err != nil { |
||||
|
log.Println("Failed to list devices from driver:", err) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
foundNewDevices := false |
||||
|
SaveLoop: |
||||
|
for _, driverDevice := range driverDevices { |
||||
|
for _, savedDevice := range savedDevices { |
||||
|
if savedDevice.InternalID == driverDevice.InternalID { |
||||
|
continue SaveLoop |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
log.Println("Saving new device", driverDevice.InternalID) |
||||
|
|
||||
|
err := config.DeviceRepository().Save(ctx, &driverDevice) |
||||
|
if err != nil { |
||||
|
log.Println("Failed to save device:", err) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
foundNewDevices = true |
||||
|
} |
||||
|
|
||||
|
// If new devices were found, publish them so that the driver can be set up.
|
||||
|
if foundNewDevices { |
||||
|
savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID)) |
||||
|
if err != nil { |
||||
|
log.Println("Failed to fetch devices from db second time:", err) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
err = driver.Publish(ctx, bridge, savedDevices) |
||||
|
if err != nil { |
||||
|
log.Println("Failed to list devices from db:", err) |
||||
|
continue |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
@ -0,0 +1,313 @@ |
|||||
|
package mysql |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"encoding/json" |
||||
|
"git.aiterp.net/lucifer/new-server/models" |
||||
|
sq "github.com/Masterminds/squirrel" |
||||
|
"github.com/jmoiron/sqlx" |
||||
|
"log" |
||||
|
"strings" |
||||
|
) |
||||
|
|
||||
|
type deviceRecord struct { |
||||
|
ID int `db:"id"` |
||||
|
BridgeID int `db:"bridge_id"` |
||||
|
InternalID string `db:"internal_id"` |
||||
|
Icon string `db:"icon"` |
||||
|
Name string `db:"name"` |
||||
|
Capabilities string `db:"capabilities"` |
||||
|
ButtonNames string `db:"button_names"` |
||||
|
} |
||||
|
|
||||
|
type deviceStateRecord struct { |
||||
|
DeviceID int `db:"device_id"` |
||||
|
Hue float64 `db:"hue"` |
||||
|
Saturation float64 `db:"saturation"` |
||||
|
Kelvin int `db:"kelvin"` |
||||
|
Power bool `db:"power"` |
||||
|
Intensity float64 `db:"intensity"` |
||||
|
} |
||||
|
|
||||
|
type devicePropertyRecord struct { |
||||
|
DeviceID int `db:"device_id"` |
||||
|
Key string `db:"prop_key"` |
||||
|
Value string `db:"prop_value"` |
||||
|
IsUser bool `db:"is_user"` |
||||
|
} |
||||
|
|
||||
|
type deviceTagRecord struct { |
||||
|
DeviceID int `db:"device_id"` |
||||
|
TagName string `db:"tag_name"` |
||||
|
} |
||||
|
|
||||
|
type DeviceRepo struct { |
||||
|
DBX *sqlx.DB |
||||
|
} |
||||
|
|
||||
|
func (r *DeviceRepo) Find(ctx context.Context, id int) (*models.Device, error) { |
||||
|
var device deviceRecord |
||||
|
err := r.DBX.GetContext(ctx, &device, "SELECT * FROM device WHERE id = ?", id) |
||||
|
if err != nil { |
||||
|
return nil, dbErr(err) |
||||
|
} |
||||
|
|
||||
|
return r.populateOne(ctx, device) |
||||
|
} |
||||
|
|
||||
|
func (r *DeviceRepo) FetchByReference(ctx context.Context, kind models.ReferenceKind, value string) ([]models.Device, error) { |
||||
|
var err error |
||||
|
records := make([]deviceRecord, 0, 8) |
||||
|
|
||||
|
switch kind { |
||||
|
case models.RKDeviceID: |
||||
|
err = r.DBX.SelectContext(ctx, &records, "SELECT * FROM device WHERE id=?", value) |
||||
|
case models.RKBridgeID: |
||||
|
err = r.DBX.SelectContext(ctx, &records, "SELECT * FROM device WHERE bridge_id=?", value) |
||||
|
case models.RKTag: |
||||
|
err = r.DBX.SelectContext(ctx, &records, "SELECT device.* FROM device JOIN device_tag dt ON device.id = dt.device_id WHERE dt.tag_name=?", value) |
||||
|
case models.RKAll: |
||||
|
err = r.DBX.SelectContext(ctx, &records, "SELECT device.* FROM device") |
||||
|
default: |
||||
|
log.Println("Unknown reference kind used for device fetch:", kind) |
||||
|
return []models.Device{}, nil |
||||
|
} |
||||
|
if err != nil { |
||||
|
return nil, dbErr(err) |
||||
|
} |
||||
|
|
||||
|
return r.populate(ctx, records) |
||||
|
} |
||||
|
|
||||
|
func (r *DeviceRepo) Save(ctx context.Context, device *models.Device) error { |
||||
|
tx, err := r.DBX.Beginx() |
||||
|
if err != nil { |
||||
|
return dbErr(err) |
||||
|
} |
||||
|
defer tx.Rollback() |
||||
|
|
||||
|
record := deviceRecord{ |
||||
|
ID: device.ID, |
||||
|
BridgeID: device.BridgeID, |
||||
|
InternalID: device.InternalID, |
||||
|
Icon: device.Icon, |
||||
|
Name: device.Name, |
||||
|
Capabilities: strings.Join(models.DeviceCapabilitiesToStrings(device.Capabilities), ","), |
||||
|
ButtonNames: strings.Join(device.ButtonNames, ","), |
||||
|
} |
||||
|
|
||||
|
if device.ID > 0 { |
||||
|
_, err := tx.NamedExecContext(ctx, ` |
||||
|
UPDATE device SET |
||||
|
internal_id = :internal_id, |
||||
|
icon = :icon, |
||||
|
name = :name, |
||||
|
capabilities = :capabilities, |
||||
|
button_names = :button_names |
||||
|
WHERE id=:id |
||||
|
`, record) |
||||
|
if err != nil { |
||||
|
return dbErr(err) |
||||
|
} |
||||
|
|
||||
|
// Let's just be lazy for now, optimize later if need be.
|
||||
|
_, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID) |
||||
|
if err != nil { |
||||
|
return dbErr(err) |
||||
|
} |
||||
|
_, err = tx.ExecContext(ctx, "DELETE FROM device_property WHERE device_id=?", record.ID) |
||||
|
if err != nil { |
||||
|
return dbErr(err) |
||||
|
} |
||||
|
} else { |
||||
|
res, err := tx.NamedExecContext(ctx, ` |
||||
|
INSERT INTO device (bridge_id, internal_id, icon, name, capabilities, button_names) |
||||
|
VALUES (:bridge_id, :internal_id, :icon, :name, :capabilities, :button_names) |
||||
|
`, record) |
||||
|
if err != nil { |
||||
|
return dbErr(err) |
||||
|
} |
||||
|
|
||||
|
lastID, err := res.LastInsertId() |
||||
|
if err != nil { |
||||
|
return dbErr(err) |
||||
|
} |
||||
|
|
||||
|
record.ID = int(lastID) |
||||
|
device.ID = int(lastID) |
||||
|
} |
||||
|
|
||||
|
for _, tag := range device.Tags { |
||||
|
_, err := tx.ExecContext(ctx, "INSERT INTO device_tag (device_id, tag_name) VALUES (?, ?)", record.ID, tag) |
||||
|
if err != nil { |
||||
|
return dbErr(err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
for key, value := range device.UserProperties { |
||||
|
_, err := tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 1)", |
||||
|
record.ID, key, value, |
||||
|
) |
||||
|
if err != nil { |
||||
|
return dbErr(err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
for key, value := range device.DriverProperties { |
||||
|
j, err := json.Marshal(value) |
||||
|
if err != nil { |
||||
|
// Eh, it'll get filled by the driver anyway
|
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
_, err = tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 0)", |
||||
|
record.ID, key, string(j), |
||||
|
) |
||||
|
if err != nil { |
||||
|
// Return err here anyway, it might put the tx in a bad state to ignore it.
|
||||
|
return dbErr(err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
_, err = tx.NamedExecContext(ctx, ` |
||||
|
REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity) |
||||
|
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity) |
||||
|
`, deviceStateRecord{ |
||||
|
DeviceID: record.ID, |
||||
|
Hue: device.State.Color.Hue, |
||||
|
Saturation: device.State.Color.Saturation, |
||||
|
Kelvin: device.State.Color.Kelvin, |
||||
|
Power: device.State.Power, |
||||
|
Intensity: device.State.Intensity, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return dbErr(err) |
||||
|
} |
||||
|
|
||||
|
return tx.Commit() |
||||
|
} |
||||
|
|
||||
|
func (r *DeviceRepo) Delete(ctx context.Context, device *models.Device) error { |
||||
|
_, err := r.DBX.ExecContext(ctx, "DELETE FROM device WHERE Id=?", device.ID) |
||||
|
if err != nil { |
||||
|
return dbErr(err) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (r *DeviceRepo) populateOne(ctx context.Context, record deviceRecord) (*models.Device, error) { |
||||
|
records, err := r.populate(ctx, []deviceRecord{record}) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &records[0], nil |
||||
|
} |
||||
|
|
||||
|
func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]models.Device, error) { |
||||
|
if len(records) == 0 { |
||||
|
return []models.Device{}, nil |
||||
|
} |
||||
|
|
||||
|
ids := make([]int, 0, len(records)) |
||||
|
for _, record := range records { |
||||
|
ids = append(ids, record.ID) |
||||
|
} |
||||
|
|
||||
|
tagsQuery, tagsArgs, err := sq.Select("*").From("device_tag").Where(sq.Eq{"device_id": ids}).ToSql() |
||||
|
if err != nil { |
||||
|
return nil, dbErr(err) |
||||
|
} |
||||
|
propsQuery, propsArgs, err := sq.Select("*").From("device_property").Where(sq.Eq{"device_id": ids}).ToSql() |
||||
|
if err != nil { |
||||
|
return nil, dbErr(err) |
||||
|
} |
||||
|
stateQuery, stateArgs, err := sq.Select("*").From("device_state").Where(sq.Eq{"device_id": ids}).ToSql() |
||||
|
if err != nil { |
||||
|
return nil, dbErr(err) |
||||
|
} |
||||
|
|
||||
|
states := make([]deviceStateRecord, 0, len(records)) |
||||
|
props := make([]devicePropertyRecord, 0, len(records)*8) |
||||
|
tags := make([]deviceTagRecord, 0, len(records)*4) |
||||
|
|
||||
|
err = r.DBX.SelectContext(ctx, &states, stateQuery, stateArgs...) |
||||
|
if err != nil { |
||||
|
return nil, dbErr(err) |
||||
|
} |
||||
|
err = r.DBX.SelectContext(ctx, &props, propsQuery, propsArgs...) |
||||
|
if err != nil { |
||||
|
return nil, dbErr(err) |
||||
|
} |
||||
|
err = r.DBX.SelectContext(ctx, &tags, tagsQuery, tagsArgs...) |
||||
|
if err != nil { |
||||
|
return nil, dbErr(err) |
||||
|
} |
||||
|
|
||||
|
devices := make([]models.Device, 0, len(records)) |
||||
|
for _, record := range records { |
||||
|
device := models.Device{ |
||||
|
ID: record.ID, |
||||
|
BridgeID: record.BridgeID, |
||||
|
InternalID: record.InternalID, |
||||
|
Icon: record.Icon, |
||||
|
Name: record.Name, |
||||
|
ButtonNames: strings.Split(record.ButtonNames, ","), |
||||
|
DriverProperties: make(map[string]interface{}, 8), |
||||
|
UserProperties: make(map[string]string, 8), |
||||
|
Tags: make([]string, 0, 8), |
||||
|
} |
||||
|
|
||||
|
caps := make([]models.DeviceCapability, 0, 16) |
||||
|
for _, capStr := range strings.Split(record.Capabilities, ",") { |
||||
|
caps = append(caps, models.DeviceCapability(capStr)) |
||||
|
} |
||||
|
device.Capabilities = caps |
||||
|
|
||||
|
for _, state := range states { |
||||
|
if state.DeviceID == record.ID { |
||||
|
device.State = models.DeviceState{ |
||||
|
Power: state.Power, |
||||
|
Color: models.ColorValue{ |
||||
|
Hue: state.Hue, |
||||
|
Saturation: state.Saturation, |
||||
|
Kelvin: state.Kelvin, |
||||
|
}, |
||||
|
Intensity: state.Intensity, |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
driverProps := make(map[string]json.RawMessage, 8) |
||||
|
for _, prop := range props { |
||||
|
if prop.DeviceID == record.ID { |
||||
|
if prop.IsUser { |
||||
|
device.UserProperties[prop.Key] = prop.Value |
||||
|
} else { |
||||
|
driverProps[prop.Key] = json.RawMessage(prop.Value) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
if len(driverProps) > 0 { |
||||
|
j, err := json.Marshal(driverProps) |
||||
|
if err != nil { |
||||
|
return nil, dbErr(err) |
||||
|
} |
||||
|
err = json.Unmarshal(j, &device.DriverProperties) |
||||
|
if err != nil { |
||||
|
return nil, dbErr(err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
for _, tag := range tags { |
||||
|
if tag.DeviceID == record.ID { |
||||
|
device.Tags = append(device.Tags, tag.TagName) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
devices = append(devices, device) |
||||
|
} |
||||
|
|
||||
|
return devices, nil |
||||
|
} |
@ -0,0 +1,16 @@ |
|||||
|
-- +goose Up |
||||
|
-- +goose StatementBegin |
||||
|
CREATE TABLE device_tag |
||||
|
( |
||||
|
device_id INT NOT NULL, |
||||
|
tag_name VARCHAR(255) NOT NULL, |
||||
|
|
||||
|
PRIMARY KEY (device_id, tag_name), |
||||
|
INDEX (tag_name) |
||||
|
); |
||||
|
-- +goose StatementEnd |
||||
|
|
||||
|
-- +goose Down |
||||
|
-- +goose StatementBegin |
||||
|
DROP TABLE device_tag; |
||||
|
-- +goose StatementEnd |
Write
Preview
Loading…
Cancel
Save
Reference in new issue