diff --git a/app/api/bridges.go b/app/api/bridges.go index c82a0ca..ae9e933 100644 --- a/app/api/bridges.go +++ b/app/api/bridges.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/models" "github.com/gin-gonic/gin" @@ -18,9 +19,11 @@ func Bridges(r gin.IRoutes) { r.POST("", handler(func(c *gin.Context) (interface{}, error) { var body struct { - Driver models.DriverKind `json:"driver"` - Address string `json:"address"` - DryRun bool `json:"dryRun"` + Driver models.DriverKind `json:"driver"` + Address string `json:"address"` + Token string `json:"token"` + ForceSave bool `json:"forceSave"` + DryRun bool `json:"dryRun"` } err := parseBody(c, &body) if err != nil { @@ -32,6 +35,22 @@ func Bridges(r gin.IRoutes) { return nil, err } + if body.ForceSave { + bridge := models.Bridge{ + Name: fmt.Sprintf("Manually saved %s bridge (%s)", body.Driver, body.Address), + Driver: body.Driver, + Address: body.Address, + Token: body.Token, + } + + err := config.BridgeRepository().Save(ctxOf(c), &bridge) + if err != nil { + return nil, err + } + + return []models.Bridge{bridge}, nil + } + bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.DryRun) if err != nil { return nil, err diff --git a/app/api/devices.go b/app/api/devices.go new file mode 100644 index 0000000..a7655c9 --- /dev/null +++ b/app/api/devices.go @@ -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 + })) +} diff --git a/app/config/driver.go b/app/config/driver.go index 99625ee..359e53e 100644 --- a/app/config/driver.go +++ b/app/config/driver.go @@ -10,19 +10,16 @@ import ( ) var dp models.DriverProvider -var dpMutex sync.Mutex +var dpOnce sync.Once func DriverProvider() models.DriverProvider { - dpMutex.Lock() - defer dpMutex.Unlock() - - if dp == nil { + dpOnce.Do(func() { dp = drivers.DriverMap{ models.DTNanoLeaf: &nanoleaf.Driver{}, models.DTHue: &hue.Driver{}, models.DTLIFX: &lifx.Driver{}, } - } + }) return dp } diff --git a/app/config/repo.go b/app/config/repo.go index b251e22..35db99d 100644 --- a/app/config/repo.go +++ b/app/config/repo.go @@ -30,7 +30,7 @@ func ColorPresetRepository() models.ColorPresetRepository { func DeviceRepository() models.DeviceRepository { if dRepo == nil { - panic("panik") + dRepo = &mysql.DeviceRepo{DBX: DBX()} } return dRepo diff --git a/app/server.go b/app/server.go index ba1f6ea..953cadc 100644 --- a/app/server.go +++ b/app/server.go @@ -11,12 +11,16 @@ import ( func StartServer() { services.StartEventHandler() + services.StartPublisher() + services.ConnectToBridges() + services.CheckNewDevices() gin.SetMode(gin.ReleaseMode) ginny := gin.New() apiGin := ginny.Group("/api") api.Bridges(apiGin.Group("/bridges")) + api.Devices(apiGin.Group("/devices")) api.ColorPresets(apiGin.Group("/color-presets")) api.DriverKinds(apiGin.Group("/driver-kinds")) api.Events(apiGin.Group("/events")) diff --git a/app/services/bridges.go b/app/services/bridges.go index 816ffd1..14f82e1 100644 --- a/app/services/bridges.go +++ b/app/services/bridges.go @@ -3,11 +3,15 @@ package services import ( "context" "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/models" "log" + "strconv" + "sync" "time" ) var cancelMap = make(map[int]context.CancelFunc, 8) +var cancelMutex sync.Mutex func ConnectToBridges() { go func() { @@ -29,7 +33,10 @@ func runConnectToBridges() error { } for _, bridge := range bridges { - if cancelMap[bridge.ID] != nil { + cancelMutex.Lock() + isRunning := cancelMap[bridge.ID] != nil + cancelMutex.Unlock() + if isRunning { continue } @@ -39,11 +46,30 @@ func runConnectToBridges() error { } ctx, cancel := context.WithCancel(context.Background()) - err = driver.Run(ctx, bridge, config.EventChannel) - + cancelMutex.Lock() cancelMap[bridge.ID] = cancel + cancelMutex.Unlock() + + log.Printf("Running bridge \"%s\" (%d)", bridge.Name, bridge.ID) + + go func(bridge models.Bridge, cancel func()) { + savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID)) + if err != nil { + log.Println("Failed to fetch devices from db for refresh:", err) + } + err = driver.Publish(ctx, bridge, savedDevices) + if err != nil { + log.Println("Failed to publish devices from db before run:", err) + } + + err = driver.Run(ctx, bridge, config.EventChannel) + log.Printf("Bridge \"%s\" (%d) stopped: %s", bridge.Name, bridge.ID, err) - log.Printf("Connected to bridge \"%s\" (%d)", bridge.Name, bridge.ID) + cancelMutex.Lock() + cancel() + cancelMap[bridge.ID] = nil + cancelMutex.Unlock() + }(bridge, cancel) } return nil diff --git a/app/services/events.go b/app/services/events.go index 29b2698..9efb484 100644 --- a/app/services/events.go +++ b/app/services/events.go @@ -2,10 +2,12 @@ package services import ( "context" + "fmt" "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/models" "log" "strconv" + "strings" "time" ) @@ -18,7 +20,7 @@ func StartEventHandler() { } }() - // Dispatch an HourChanged event at every hour + // Generate TimeChanged event go func() { drift := time.Now().Add(time.Minute).Truncate(time.Minute).Sub(time.Now()) time.Sleep(drift + time.Millisecond * 5) @@ -48,7 +50,12 @@ func handleEvent(event models.Event) { } if !X { - log.Println("Unhandled event: " + event.Name) + paramStrings := make([]string, 0, 8) + for key, value := range event.Payload { + paramStrings = append(paramStrings, fmt.Sprintf("%s=%s", key, value)) + } + + log.Printf("Unhandled event %s(%s)", event.Name, strings.Join(paramStrings, ", ")) return } @@ -68,7 +75,6 @@ func handleEvent(event models.Event) { if !handler.MatchesEvent(event, devices) { continue } - } } diff --git a/app/services/publish.go b/app/services/publish.go index f40fee7..dcf1139 100644 --- a/app/services/publish.go +++ b/app/services/publish.go @@ -5,6 +5,8 @@ import ( "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/models" "log" + "sync" + "time" ) func StartPublisher() { @@ -16,32 +18,58 @@ func StartPublisher() { continue } - // Emergency solution! Please avoid! - // Send devices not belonging to the first channel separately - bridgeID := devices[0].BridgeID + lists := make(map[int][]models.Device, 4) for _, device := range devices { - if device.BridgeID != bridgeID { - config.PublishChannel<-[]models.Device{device} - } + lists[device.BridgeID] = append(lists[device.BridgeID], device) } - bridge, err := config.BridgeRepository().Find(ctx, devices[0].BridgeID) + ctx, cancel := context.WithTimeout(ctx, time.Second * 30) + + bridges, err := config.BridgeRepository().FetchAll(ctx) if err != nil { log.Println("Publishing error (1): " + err.Error()) continue } - driver, err := config.DriverProvider().Provide(bridge.Driver) - if err != nil { - log.Println("Publishing error (2): " + err.Error()) - continue - } + wg := sync.WaitGroup{} + for _, devices := range lists { + wg.Add(1) - err = driver.Publish(ctx, bridge, devices) - if err != nil { - log.Println("Publishing error (3): " + err.Error()) - continue + go func(devices []models.Device) { + defer wg.Done() + + var bridge models.Bridge + for _, bridge2 := range bridges { + if bridge2.ID == devices[0].BridgeID { + bridge = bridge2 + } + } + if bridge.ID == 0 { + log.Println("Unknown bridge") + } + + bridge, err := config.BridgeRepository().Find(ctx, devices[0].BridgeID) + if err != nil { + log.Println("Publishing error (1): " + err.Error()) + return + } + + driver, err := config.DriverProvider().Provide(bridge.Driver) + if err != nil { + log.Println("Publishing error (2): " + err.Error()) + return + } + + err = driver.Publish(ctx, bridge, devices) + if err != nil { + log.Println("Publishing error (3): " + err.Error()) + return + } + }(devices) } + + wg.Wait() + cancel() } }() } diff --git a/app/services/synclights.go b/app/services/synclights.go new file mode 100644 index 0000000..1dc0cb2 --- /dev/null +++ b/app/services/synclights.go @@ -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 +} diff --git a/cmd/goose/main.go b/cmd/goose/main.go index a0eadbe..a6c6937 100644 --- a/cmd/goose/main.go +++ b/cmd/goose/main.go @@ -4,6 +4,7 @@ import ( "git.aiterp.net/lucifer/new-server/app/config" "github.com/pressly/goose" "log" + "os" "time" ) @@ -19,7 +20,12 @@ func main() { log.Fatal(err) } - err = goose.Run("up", db, "./scripts") + cmd := os.Getenv("GOOSE_COMMAND") + if cmd == "" { + cmd = "up" + } + + err = goose.Run(cmd, db, "./scripts") if err != nil { log.Fatal(err) } diff --git a/go.mod b/go.mod index 0b46bb6..57bc5e1 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,15 @@ module git.aiterp.net/lucifer/new-server go 1.16 require ( + github.com/Masterminds/squirrel v1.5.0 github.com/gin-gonic/gin v1.7.1 github.com/go-sql-driver/mysql v1.6.0 github.com/jmoiron/sqlx v1.3.4 - github.com/lib/pq v1.10.2 // indirect + github.com/lib/pq v1.10.3 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pressly/goose v2.7.0+incompatible - golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect + golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 // indirect + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c ) diff --git a/go.sum b/go.sum index 7325885..06f4fe8 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Masterminds/squirrel v1.5.0 h1:JukIZisrUXadA9pl3rMkjhiamxiB0cXiu+HGp/Y8cY8= +github.com/Masterminds/squirrel v1.5.0/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -23,11 +25,15 @@ github.com/jmoiron/sqlx v1.3.4 h1:wv+0IJZfL5z0uZoUjlpKgHkgaFSYD+r9CfrXjEXsO7w= github.com/jmoiron/sqlx v1.3.4/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= -github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg= +github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= @@ -46,6 +52,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/pressly/goose v2.7.0+incompatible h1:PWejVEv07LCerQEzMMeAtjuyCKbyprZ/LBa6K5P0OCQ= github.com/pressly/goose v2.7.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -55,8 +62,8 @@ github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc= -golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 h1:3erb+vDS8lU1sxfDHF4/hhWyaXnhIaO+7RgL4fDZORA= +golang.org/x/crypto v0.0.0-20210915214749-c084706c2272/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= @@ -66,6 +73,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= diff --git a/internal/mysql/bridgerepo.go b/internal/mysql/bridgerepo.go index 926b9d6..e47d191 100644 --- a/internal/mysql/bridgerepo.go +++ b/internal/mysql/bridgerepo.go @@ -11,10 +11,10 @@ type BridgeRepo struct { } func (b *BridgeRepo) Find(ctx context.Context, id int) (models.Bridge, error) { - var bridge models.Bridge + var bridge models.Bridge err := b.DBX.GetContext(ctx, &bridge, "SELECT * FROM bridge WHERE id = ?", id) - if err != nil { - return models.Bridge{}, dbErr(err) + if err != nil { + return models.Bridge{}, dbErr(err) } return bridge, nil @@ -22,7 +22,7 @@ func (b *BridgeRepo) Find(ctx context.Context, id int) (models.Bridge, error) { func (b *BridgeRepo) FetchAll(ctx context.Context) ([]models.Bridge, error) { bridges := make([]models.Bridge, 0, 8) - err := b.DBX.GetContext(ctx, &bridges, "SELECT * FROM bridge") + err := b.DBX.SelectContext(ctx, &bridges, "SELECT * FROM bridge") if err != nil { return nil, dbErr(err) } diff --git a/internal/mysql/devicerepo.go b/internal/mysql/devicerepo.go new file mode 100644 index 0000000..1854b64 --- /dev/null +++ b/internal/mysql/devicerepo.go @@ -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 +} diff --git a/models/device.go b/models/device.go index 6e27834..c2d2af7 100644 --- a/models/device.go +++ b/models/device.go @@ -34,19 +34,28 @@ type DeviceState struct { type NewDeviceState struct { Power *bool `json:"power"` Color *string `json:"color"` - Intensity int `json:"intensity"` + Intensity *float64 `json:"intensity"` Temperature int `json:"temperature"` } type DeviceCapability string type DeviceRepository interface { - FindByID(ctx context.Context, id int) (*Device, error) + Find(ctx context.Context, id int) (*Device, error) FetchByReference(ctx context.Context, kind ReferenceKind, value string) ([]Device, error) Save(ctx context.Context, device *Device) error Delete(ctx context.Context, device *Device) error } +func DeviceCapabilitiesToStrings(caps []DeviceCapability) []string { + res := make([]string, 0, len(caps)) + for _, cap := range caps { + res = append(res, string(cap)) + } + + return res +} + var ( DCPower DeviceCapability = "Power" DCColorHS DeviceCapability = "ColorHS" @@ -118,5 +127,9 @@ func (d *Device) SetState(newState NewDeviceState) error { } } + if newState.Intensity != nil && d.HasCapability(DCIntensity) { + d.State.Intensity = *newState.Intensity + } + return nil } diff --git a/models/shared.go b/models/shared.go index bada807..ae35a83 100644 --- a/models/shared.go +++ b/models/shared.go @@ -6,6 +6,7 @@ var ( RKDeviceID ReferenceKind = "DeviceID" RKBridgeID ReferenceKind = "BridgeID" RKTag ReferenceKind = "Tag" + RKAll ReferenceKind = "All" ) diff --git a/scripts/20210522140146_device.sql b/scripts/20210522140146_device.sql index 916ecbb..4442fb5 100644 --- a/scripts/20210522140146_device.sql +++ b/scripts/20210522140146_device.sql @@ -5,12 +5,13 @@ CREATE TABLE device id INT NOT NULL AUTO_INCREMENT, bridge_id INT NOT NULL, internal_id VARCHAR(255) NOT NULL, - icon CHAR NOT NULL, + icon VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, capabilities VARCHAR(255) NOT NULL, button_names VARCHAR(255) NOT NULL, - PRIMARY KEY (id) + PRIMARY KEY (id), + UNIQUE (bridge_id, internal_id) ); -- +goose StatementEnd diff --git a/scripts/20210522140148_device_state.sql b/scripts/20210522140148_device_state.sql index 136ffc6..2079fab 100644 --- a/scripts/20210522140148_device_state.sql +++ b/scripts/20210522140148_device_state.sql @@ -7,7 +7,7 @@ CREATE TABLE device_state saturation DOUBLE NOT NULL, kelvin INT NOT NULL, power TINYINT NOT NULL, - intensity INT NOT NULL, + intensity DOUBLE NOT NULL, PRIMARY KEY (device_id) ); diff --git a/scripts/20210918105052_device_tag.sql b/scripts/20210918105052_device_tag.sql new file mode 100644 index 0000000..4787c8c --- /dev/null +++ b/scripts/20210918105052_device_tag.sql @@ -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