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