From ba22187e6eb39ba3e677decf0b3f003f08c18630 Mon Sep 17 00:00:00 2001 From: gisle Date: Wed, 6 Oct 2021 18:13:16 +0000 Subject: [PATCH] feature-scenesystem (#1) Co-authored-by: Gisle Aune Reviewed-on: https://git.aiterp.net/lucifer/new-server/pulls/1 Co-authored-by: gisle Co-committed-by: gisle --- app/api/devices.go | 107 +++++- app/api/scenes.go | 68 ++++ app/api/util.go | 7 + app/client/client.go | 25 ++ app/client/handler.go | 2 +- app/client/scene.go | 16 + app/config/db.go | 5 +- app/config/repo.go | 4 + app/server.go | 14 +- app/services/bridges.go | 10 - app/services/events.go | 22 +- app/services/publish.go | 76 ----- app/services/publisher/publisher.go | 315 ++++++++++++++++++ app/services/publisher/scene.go | 186 +++++++++++ cmd/lucy/command.go | 12 + cmd/lucy/handlercmd.go | 10 +- cmd/lucy/main.go | 3 + cmd/lucy/scenecmd.go | 165 +++++++++ cmd/lucy/tables.go | 37 +- internal/drivers/hue/state.go | 3 + internal/drivers/lifx/bridge.go | 4 +- internal/drivers/lifx/state.go | 1 + internal/drivers/nanoleaf/bridge.go | 2 +- internal/mysql/devicerepo.go | 189 ++++++----- internal/mysql/scenerepo.go | 123 +++++++ models/colorvalue.go | 4 +- models/device.go | 94 +++++- models/errors.go | 7 + models/eventhandler.go | 35 +- models/scene.go | 251 ++++++++++++++ models/shared.go | 52 ++- scene-examples/evening.yaml | 63 ++++ scene-examples/flash.yaml | 26 ++ scene-examples/late.yaml | 57 ++++ scene-examples/morning.yaml | 40 +++ scene-examples/saturday.yaml | 42 +++ scripts/20210926135923_scene.sql | 17 + .../20210926152011_device_sceneassignment.sql | 9 + 38 files changed, 1888 insertions(+), 215 deletions(-) create mode 100644 app/api/scenes.go create mode 100644 app/client/scene.go delete mode 100644 app/services/publish.go create mode 100644 app/services/publisher/publisher.go create mode 100644 app/services/publisher/scene.go create mode 100644 cmd/lucy/scenecmd.go create mode 100644 internal/mysql/scenerepo.go create mode 100644 models/scene.go create mode 100644 scene-examples/evening.yaml create mode 100644 scene-examples/flash.yaml create mode 100644 scene-examples/late.yaml create mode 100644 scene-examples/morning.yaml create mode 100644 scene-examples/saturday.yaml create mode 100644 scripts/20210926135923_scene.sql create mode 100644 scripts/20210926152011_device_sceneassignment.sql diff --git a/app/api/devices.go b/app/api/devices.go index f77dced..c524b05 100644 --- a/app/api/devices.go +++ b/app/api/devices.go @@ -3,9 +3,11 @@ package api import ( "context" "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/app/services/publisher" "git.aiterp.net/lucifer/new-server/models" "github.com/gin-gonic/gin" "log" + "time" ) func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) { @@ -15,11 +17,21 @@ func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) func Devices(r gin.IRoutes) { r.GET("", handler(func(c *gin.Context) (interface{}, error) { - return config.DeviceRepository().FetchByReference(ctxOf(c), models.RKAll, "") + devices, err := config.DeviceRepository().FetchByReference(ctxOf(c), models.RKAll, "") + if err != nil { + return nil, err + } + + return withSceneState(devices), nil })) r.GET("/:fetch", handler(func(c *gin.Context) (interface{}, error) { - return fetchDevices(ctxOf(c), c.Param("fetch")) + devices, err := fetchDevices(ctxOf(c), c.Param("fetch")) + if err != nil { + return nil, err + } + + return withSceneState(devices), nil })) r.PUT("", handler(func(c *gin.Context) (interface{}, error) { @@ -70,7 +82,7 @@ func Devices(r gin.IRoutes) { } }() - return changed, nil + return withSceneState(changed), nil })) r.PUT("/:fetch", handler(func(c *gin.Context) (interface{}, error) { @@ -98,7 +110,7 @@ func Devices(r gin.IRoutes) { } } - return devices, nil + return withSceneState(devices), nil })) r.PUT("/:fetch/state", handler(func(c *gin.Context) (interface{}, error) { @@ -126,16 +138,13 @@ func Devices(r gin.IRoutes) { config.PublishChannel <- devices go func() { - for _, device := range devices { - err := config.DeviceRepository().Save(context.Background(), &device, models.SMState) - if err != nil { - log.Println("Failed to save device for state:", err) - continue - } + err = config.DeviceRepository().SaveMany(ctxOf(c), models.SMState, devices) + if err != nil { + log.Println("Failed to save devices states") } }() - return devices, nil + return withSceneState(devices), nil })) r.PUT("/:fetch/tags", handler(func(c *gin.Context) (interface{}, error) { @@ -192,6 +201,80 @@ func Devices(r gin.IRoutes) { } } - return devices, nil + return withSceneState(devices), nil })) + + r.PUT("/:fetch/scene", handler(func(c *gin.Context) (interface{}, error) { + var body models.DeviceSceneAssignment + 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 + } + + _, err = config.SceneRepository().Find(ctxOf(c), body.SceneID) + if err != nil { + return nil, err + } + if body.DurationMS < 0 { + body.DurationMS = 0 + } + body.StartTime = time.Now() + + pushMode := c.Query("push") == "true" + for i := range devices { + if pushMode { + devices[i].SceneAssignments = append(devices[i].SceneAssignments, body) + } else { + devices[i].SceneAssignments = []models.DeviceSceneAssignment{body} + } + } + config.PublishChannel <- devices + + err = config.DeviceRepository().SaveMany(ctxOf(c), 0, devices) + if err != nil { + return nil, err + } + + return withSceneState(devices), nil + })) + + r.DELETE("/:fetch/scene", handler(func(c *gin.Context) (interface{}, error) { + 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 { + devices[i].SceneAssignments = []models.DeviceSceneAssignment{} + } + config.PublishChannel <- devices + + err = config.DeviceRepository().SaveMany(ctxOf(c), 0, devices) + if err != nil { + return nil, err + } + + return withSceneState(devices), nil + })) +} + +func withSceneState(devices []models.Device) []models.Device { + res := make([]models.Device, 0, len(devices)) + for _, device := range devices { + device.SceneState = publisher.Global().SceneState(device.ID) + res = append(res, device) + } + + return res } diff --git a/app/api/scenes.go b/app/api/scenes.go new file mode 100644 index 0000000..44c2f1f --- /dev/null +++ b/app/api/scenes.go @@ -0,0 +1,68 @@ +package api + +import ( + "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/app/services/publisher" + "git.aiterp.net/lucifer/new-server/models" + "github.com/gin-gonic/gin" +) + +func Scenes(r gin.IRoutes) { + r.GET("", handler(func(c *gin.Context) (interface{}, error) { + return config.SceneRepository().FetchAll(ctxOf(c)) + })) + + r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) { + return config.SceneRepository().Find(ctxOf(c), intParam(c, "id")) + })) + + r.POST("", handler(func(c *gin.Context) (interface{}, error) { + var body models.Scene + err := parseBody(c, &body) + if err != nil { + return nil, err + } + + err = body.Validate() + if err != nil { + return nil, err + } + + err = config.SceneRepository().Save(ctxOf(c), &body) + if err != nil { + return nil, err + } + + publisher.Global().UpdateScene(body) + + return body, nil + })) + + r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) { + var body models.Scene + err := parseBody(c, &body) + if err != nil { + return nil, err + } + + scene, err := config.SceneRepository().Find(ctxOf(c), intParam(c, "id")) + if err != nil { + return nil, err + } + + body.ID = scene.ID + err = body.Validate() + if err != nil { + return nil, err + } + + err = config.SceneRepository().Save(ctxOf(c), &body) + if err != nil { + return nil, err + } + + publisher.Global().UpdateScene(body) + + return body, nil + })) +} diff --git a/app/api/util.go b/app/api/util.go index 2387488..5cdece7 100644 --- a/app/api/util.go +++ b/app/api/util.go @@ -15,6 +15,13 @@ var errorMap = map[error]int{ models.ErrBadColor: 400, models.ErrInternal: 500, models.ErrUnknownColorFormat: 400, + + models.ErrSceneInvalidInterval: 400, + models.ErrSceneNoRoles: 400, + models.ErrSceneRoleNoStates: 400, + models.ErrSceneRoleUnsupportedOrdering: 422, + models.ErrSceneRoleUnknownEffect: 422, + models.ErrSceneRoleUnknownPowerMode: 422, } type response struct { diff --git a/app/client/client.go b/app/client/client.go index 45c2f6c..c0bba26 100644 --- a/app/client/client.go +++ b/app/client/client.go @@ -60,6 +60,31 @@ func (client *Client) PutDeviceTags(ctx context.Context, fetchStr string, addTag return devices, nil } +func (client *Client) AssignDevice(ctx context.Context, fetchStr string, push bool, assignment models.DeviceSceneAssignment) ([]models.Device, error) { + query := "" + if push { + query = "?push=true" + } + + devices := make([]models.Device, 0, 16) + err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr+"/scene"+query, &devices, assignment) + if err != nil { + return nil, err + } + + return devices, nil +} + +func (client *Client) ClearDevice(ctx context.Context, fetchStr string) ([]models.Device, error) { + devices := make([]models.Device, 0, 16) + err := client.Fetch(ctx, "DELETE", "/api/devices/"+fetchStr+"/scene", &devices, nil) + if err != nil { + return nil, err + } + + return devices, nil +} + func (client *Client) FireEvent(ctx context.Context, event models.Event) error { err := client.Fetch(ctx, "POST", "/api/events", nil, event) if err != nil { diff --git a/app/client/handler.go b/app/client/handler.go index 4ef960c..fdc0ab4 100644 --- a/app/client/handler.go +++ b/app/client/handler.go @@ -28,7 +28,7 @@ func (client *Client) GetHandler(ctx context.Context, id int) (*models.EventHand func (client *Client) PutHandler(ctx context.Context, handler *models.EventHandler) (*models.EventHandler, error) { var response models.EventHandler - err := client.Fetch(ctx, "PUT", fmt.Sprintf("/api/event-handlers/%d", handler.ID), &response, handler) + err := client.Fetch(ctx, "PUT", fmt.Sprintf("/api/event-handlers/%d?hard=true", handler.ID), &response, handler) if err != nil { return nil, err } diff --git a/app/client/scene.go b/app/client/scene.go new file mode 100644 index 0000000..6eda620 --- /dev/null +++ b/app/client/scene.go @@ -0,0 +1,16 @@ +package client + +import ( + "context" + "git.aiterp.net/lucifer/new-server/models" +) + +func (client *Client) GetScenes(ctx context.Context) ([]models.Scene, error) { + scenes := make([]models.Scene, 0, 16) + err := client.Fetch(ctx, "GET", "/api/scenes", &scenes, nil) + if err != nil { + return nil, err + } + + return scenes, nil +} \ No newline at end of file diff --git a/app/config/db.go b/app/config/db.go index 9bb75c9..041fe33 100644 --- a/app/config/db.go +++ b/app/config/db.go @@ -24,8 +24,9 @@ func DBX() *sqlx.DB { MySqlSchema(), )) - dbx.SetMaxIdleConns(50) - dbx.SetMaxOpenConns(100) + dbx.SetMaxIdleConns(20) + dbx.SetMaxOpenConns(40) + dbx.SetConnMaxLifetime(0) } return dbx diff --git a/app/config/repo.go b/app/config/repo.go index d92455b..7216df8 100644 --- a/app/config/repo.go +++ b/app/config/repo.go @@ -20,3 +20,7 @@ func DeviceRepository() models.DeviceRepository { func EventHandlerRepository() models.EventHandlerRepository { return &mysql.EventHandlerRepo{DBX: DBX()} } + +func SceneRepository() models.SceneRepository { + return &mysql.SceneRepo{DBX: DBX()} +} diff --git a/app/server.go b/app/server.go index 6aaa75e..ab32941 100644 --- a/app/server.go +++ b/app/server.go @@ -1,17 +1,28 @@ package app import ( + "context" "fmt" "git.aiterp.net/lucifer/new-server/app/api" "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/app/services" + "git.aiterp.net/lucifer/new-server/app/services/publisher" "github.com/gin-gonic/gin" "log" + "time" ) func StartServer() { + setupCtx, cancel := context.WithTimeout(context.Background(), time.Second * 10) + defer cancel() + + err := publisher.Initialize(setupCtx) + if err != nil { + log.Fatalln("Publish init failed:", err) + return + } + services.StartEventHandler() - services.StartPublisher() services.ConnectToBridges() services.CheckNewDevices() @@ -25,6 +36,7 @@ func StartServer() { api.DriverKinds(apiGin.Group("/driver-kinds")) api.Events(apiGin.Group("/events")) api.EventHandlers(apiGin.Group("/event-handlers")) + api.Scenes(apiGin.Group("/scenes")) log.Fatal(ginny.Run(fmt.Sprintf("0.0.0.0:%d", config.ServerPort()))) } diff --git a/app/services/bridges.go b/app/services/bridges.go index 14f82e1..ea4df28 100644 --- a/app/services/bridges.go +++ b/app/services/bridges.go @@ -5,7 +5,6 @@ import ( "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/models" "log" - "strconv" "sync" "time" ) @@ -53,15 +52,6 @@ func runConnectToBridges() error { 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) diff --git a/app/services/events.go b/app/services/events.go index 2c3b99f..9e86300 100644 --- a/app/services/events.go +++ b/app/services/events.go @@ -155,23 +155,33 @@ func handleEvent(event models.Event) (responses []models.Event) { if err != nil { log.Println("Error updating state for device", device.ID, "err:", err) } + + if action.SetScene != nil { + action.SetScene.StartTime = time.Now() + allDevices[i].SceneAssignments = []models.DeviceSceneAssignment{*action.SetScene} + } + if action.PushScene != nil { + action.PushScene.StartTime = time.Now() + allDevices[i].SceneAssignments = append(allDevices[i].SceneAssignments, *action.PushScene) + } } config.PublishChannel <- allDevices wg := sync.WaitGroup{} - for _, device := range allDevices { - wg.Add(1) - go func(device models.Device) { - err := config.DeviceRepository().Save(context.Background(), &device, models.SMState) + if len(allDevices) > 0 { + wg.Add(1) + go func() { + err := config.DeviceRepository().SaveMany(context.Background(), models.SMState, allDevices) if err != nil { - log.Println("Failed to save device for state:", err) + log.Println("Failed to save devices' state:", err) } wg.Done() - }(device) + }() } + for _, handler := range deadHandlers { wg.Add(1) diff --git a/app/services/publish.go b/app/services/publish.go deleted file mode 100644 index 7fd9cf9..0000000 --- a/app/services/publish.go +++ /dev/null @@ -1,76 +0,0 @@ -package services - -import ( - "context" - "git.aiterp.net/lucifer/new-server/app/config" - "git.aiterp.net/lucifer/new-server/models" - "log" - "sync" - "time" -) - -func StartPublisher() { - ctx := context.Background() - - go func() { - for devices := range config.PublishChannel { - if len(devices) == 0 { - continue - } - - lists := make(map[int][]models.Device, 4) - for _, device := range devices { - lists[device.BridgeID] = append(lists[device.BridgeID], device) - } - - ctx, cancel := context.WithTimeout(ctx, time.Second * 30) - - bridges, err := config.BridgeRepository().FetchAll(ctx) - if err != nil { - log.Println("Publishing error (1): " + err.Error()) - cancel() - continue - } - - wg := sync.WaitGroup{} - for _, devices := range lists { - wg.Add(1) - - 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/publisher/publisher.go b/app/services/publisher/publisher.go new file mode 100644 index 0000000..2550e84 --- /dev/null +++ b/app/services/publisher/publisher.go @@ -0,0 +1,315 @@ +package publisher + +import ( + "context" + "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/models" + "log" + "sync" + "time" +) + +type Publisher struct { + mu sync.Mutex + sceneData map[int]*models.Scene + scenes []*Scene + sceneAssignment map[int]*Scene + started map[int]bool + pending map[int][]models.Device + waiting map[int]chan struct{} +} + +func (p *Publisher) SceneState(deviceID int) *models.DeviceState { + p.mu.Lock() + defer p.mu.Unlock() + + if s := p.sceneAssignment[deviceID]; s != nil { + return s.LastState(deviceID) + } + + return nil +} + +func (p *Publisher) UpdateScene(data models.Scene) { + p.mu.Lock() + p.sceneData[data.ID] = &data + + for _, scene := range p.scenes { + if scene.data.ID == data.ID { + scene.UpdateScene(data) + } + } + p.mu.Unlock() +} + +func (p *Publisher) ReloadScenes(ctx context.Context) error { + scenes, err := config.SceneRepository().FetchAll(ctx) + if err != nil { + return err + } + + p.mu.Lock() + for i, scene := range scenes { + p.sceneData[scene.ID] = &scenes[i] + } + + for _, scene := range p.scenes { + scene.UpdateScene(*p.sceneData[scene.data.ID]) + } + p.mu.Unlock() + + return nil +} + +func (p *Publisher) ReloadDevices(ctx context.Context) error { + devices, err := config.DeviceRepository().FetchByReference(ctx, models.RKAll, "") + if err != nil { + return err + } + + p.Publish(devices...) + + return nil +} + +func (p *Publisher) Publish(devices ...models.Device) { + if len(devices) == 0 { + return + } + + p.mu.Lock() + defer p.mu.Unlock() + + for _, device := range devices { + if !p.started[device.BridgeID] { + p.started[device.BridgeID] = true + go p.runBridge(device.BridgeID) + } + + p.reassignDevice(device) + + if p.sceneAssignment[device.ID] != nil { + p.sceneAssignment[device.ID].UpsertDevice(device) + } else { + p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device) + if p.waiting[device.BridgeID] != nil { + close(p.waiting[device.BridgeID]) + p.waiting[device.BridgeID] = nil + } + } + } +} + +func (p *Publisher) PublishChannel(ch <-chan []models.Device) { + for list := range ch { + p.Publish(list...) + } +} + +func (p *Publisher) Run() { + ticker := time.NewTicker(time.Millisecond * 100) + deleteList := make([]int, 0, 8) + updatedList := make([]models.Device, 0, 16) + + for range ticker.C { + deleteList = deleteList[:0] + updatedList = updatedList[:0] + + p.mu.Lock() + for i, scene := range p.scenes { + if (!scene.endTime.IsZero() && time.Now().After(scene.endTime)) || scene.Empty() { + deleteList = append(deleteList, i-len(deleteList)) + updatedList = append(updatedList, scene.AllDevices()...) + + log.Printf("Removing scene instance for %s (%d)", scene.data.Name, scene.data.ID) + + for _, device := range scene.AllDevices() { + p.sceneAssignment[device.ID] = nil + } + continue + } + + if scene.Due() { + updatedList = append(updatedList, scene.Run()...) + updatedList = append(updatedList, scene.UnaffectedDevices()...) + } + } + + for _, i := range deleteList { + p.scenes = append(p.scenes[:i], p.scenes[i+1:]...) + } + + for _, device := range updatedList { + if !p.started[device.BridgeID] { + p.started[device.BridgeID] = true + go p.runBridge(device.BridgeID) + } + + p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device) + if p.waiting[device.BridgeID] != nil { + close(p.waiting[device.BridgeID]) + p.waiting[device.BridgeID] = nil + } + } + p.mu.Unlock() + } +} + +// reassignDevice re-evaluates the device's scene assignment config. It will return whether the scene changed, which +// should trigger an update. +func (p *Publisher) reassignDevice(device models.Device) bool { + var selectedAssignment *models.DeviceSceneAssignment + for _, assignment := range device.SceneAssignments { + duration := time.Duration(assignment.DurationMS) * time.Millisecond + if duration <= 0 || time.Now().Before(assignment.StartTime.Add(duration)) { + selectedAssignment = &assignment + } + } + + if selectedAssignment == nil { + if p.sceneAssignment[device.ID] != nil { + p.sceneAssignment[device.ID].RemoveDevice(device) + delete(p.sceneAssignment, device.ID) + + // Scene changed + return true + } + + // Stop here, no scene should be assigned. + return false + } else { + if p.sceneData[selectedAssignment.SceneID] == nil { + // Freeze until scene becomes available. + return false + } + } + + if p.sceneAssignment[device.ID] != nil { + scene := p.sceneAssignment[device.ID] + if scene.data.ID == selectedAssignment.SceneID && scene.group == selectedAssignment.Group { + // Current assignment is good. + return false + } + + p.sceneAssignment[device.ID].RemoveDevice(device) + delete(p.sceneAssignment, device.ID) + } + + for _, scene := range p.scenes { + if scene.data.ID == selectedAssignment.SceneID && scene.group == selectedAssignment.Group { + p.sceneAssignment[device.ID] = scene + return true + } + } + + newScene := &Scene{ + data: p.sceneData[selectedAssignment.SceneID], + group: selectedAssignment.Group, + startTime: selectedAssignment.StartTime, + endTime: selectedAssignment.StartTime.Add(time.Duration(selectedAssignment.DurationMS) * time.Millisecond), + roleMap: make(map[int]int, 16), + roleList: make(map[int][]models.Device, 16), + lastStates: make(map[int]models.DeviceState, 16), + due: true, + } + p.sceneAssignment[device.ID] = newScene + p.scenes = append(p.scenes, newScene) + + if selectedAssignment.DurationMS <= 0 { + newScene.endTime = time.Time{} + } + + return true +} + +func (p *Publisher) runBridge(id int) { + defer func() { + p.mu.Lock() + p.started[id] = false + p.mu.Unlock() + }() + + bridge, err := config.BridgeRepository().Find(context.Background(), id) + if err != nil { + log.Println("Failed to get bridge data:", err) + return + } + + driver, err := config.DriverProvider().Provide(bridge.Driver) + if err != nil { + log.Println("Failed to get bridge driver:", err) + log.Println("Maybe Lucifer needs to be updated.") + return + } + + devices := make(map[int]models.Device) + + for { + p.mu.Lock() + if len(p.pending[id]) == 0 { + if p.waiting[id] == nil { + p.waiting[id] = make(chan struct{}) + } + waitCh := p.waiting[id] + p.mu.Unlock() + <-waitCh + p.mu.Lock() + } + + updates := p.pending[id] + p.pending[id] = p.pending[id][:0:0] + p.mu.Unlock() + + // Only allow the latest update per device (this avoids slow bridges causing a backlog of cations). + for key := range devices { + delete(devices, key) + } + for _, update := range updates { + devices[update.ID] = update + } + updates = updates[:0] + for _, value := range devices { + updates = append(updates, value) + } + + err := driver.Publish(context.Background(), bridge, updates) + if err != nil { + log.Println("Failed to publish to driver:", err) + + p.mu.Lock() + p.pending[id] = append(updates, p.pending[id]...) + p.mu.Unlock() + return + } + } +} + +var publisher = Publisher{ + sceneData: make(map[int]*models.Scene), + scenes: make([]*Scene, 0, 16), + sceneAssignment: make(map[int]*Scene, 16), + started: make(map[int]bool), + pending: make(map[int][]models.Device), + waiting: make(map[int]chan struct{}), +} + +func Global() *Publisher { + return &publisher +} + +func Initialize(ctx context.Context) error { + err := publisher.ReloadScenes(ctx) + if err != nil { + return err + } + err = publisher.ReloadDevices(ctx) + if err != nil { + return err + } + + go publisher.Run() + time.Sleep(time.Millisecond * 50) + go publisher.PublishChannel(config.PublishChannel) + + return nil +} diff --git a/app/services/publisher/scene.go b/app/services/publisher/scene.go new file mode 100644 index 0000000..e6d605d --- /dev/null +++ b/app/services/publisher/scene.go @@ -0,0 +1,186 @@ +package publisher + +import ( + "git.aiterp.net/lucifer/new-server/models" + "time" +) + +type Scene struct { + data *models.Scene + group string + startTime time.Time + endTime time.Time + roleMap map[int]int + roleList map[int][]models.Device + lastStates map[int]models.DeviceState + + due bool + lastInterval int64 +} + +// UpdateScene updates the scene data and re-seats all devices. +func (s *Scene) UpdateScene(data models.Scene) { + devices := make([]models.Device, 0, 16) + + // Collect all devices into the undefined role (-1) + for _, list := range s.roleList { + for _, device := range list { + devices = append(devices, device) + s.roleMap[device.ID] = -1 + } + } + s.roleList = map[int][]models.Device{-1: devices} + + // Update data and reset devices. + s.data = &data + for _, device := range append(devices[:0:0], devices...) { + s.UpsertDevice(device) + } +} + +// UpsertDevice moves the device if necessary and updates its state. +func (s *Scene) UpsertDevice(device models.Device) { + if s.data == nil { + s.roleMap[device.ID] = -1 + s.roleList[-1] = append(s.roleList[-1], device) + return + } + + oldIndex, hasOldIndex := s.roleMap[device.ID] + newIndex := s.data.RoleIndex(&device) + + s.roleMap[device.ID] = newIndex + + if hasOldIndex { + for i, device2 := range s.roleList[oldIndex] { + if device2.ID == device.ID { + s.roleList[oldIndex] = append(s.roleList[oldIndex][:i], s.roleList[oldIndex][i+1:]...) + break + } + } + } + + s.due = true + + s.roleList[newIndex] = append(s.roleList[newIndex], device) + if newIndex != -1 { + s.data.Roles[newIndex].ApplyOrder(s.roleList[newIndex]) + } +} + +// RemoveDevice finds and remove a device. It's a noop if the device does not exist in this scene. +func (s *Scene) RemoveDevice(device models.Device) { + roleIndex, hasRoleIndex := s.roleMap[device.ID] + if !hasRoleIndex { + return + } + + for i, device2 := range s.roleList[roleIndex] { + if device2.ID == device.ID { + s.roleList[roleIndex] = append(s.roleList[roleIndex][:i], s.roleList[roleIndex][i+1:]...) + break + } + } + + s.due = true + + delete(s.roleMap, device.ID) + delete(s.lastStates, device.ID) +} + +func (s *Scene) Empty() bool { + for _, list := range s.roleList { + if len(list) > 0 { + return false + } + } + + return true +} + +func (s *Scene) Due() bool { + if s.due { + return true + } + + if s.data.IntervalMS > 0 { + interval := time.Duration(s.data.IntervalMS) * time.Millisecond + return int64(time.Since(s.startTime)/interval) != s.lastInterval + } + + return false +} + +func (s *Scene) UnaffectedDevices() []models.Device { + return append(s.roleList[-1][:0:0], s.roleList[-1]...) +} + +func (s *Scene) AllDevices() []models.Device { + res := make([]models.Device, 0, 16) + for _, list := range s.roleList { + res = append(res, list...) + } + + return res +} + +// Run runs the scene +func (s *Scene) Run() []models.Device { + if s.data == nil { + return []models.Device{} + } + + intervalNumber := int64(0) + intervalMax := int64(1) + if s.data.IntervalMS > 0 { + interval := time.Duration(s.data.IntervalMS) * time.Millisecond + intervalNumber = int64(time.Since(s.startTime) / interval) + + if !s.endTime.IsZero() { + intervalMax = int64(s.endTime.Sub(s.startTime) / interval) + } else { + intervalMax = intervalNumber + 1 + } + } + + updatedDevices := make([]models.Device, 0, 16) + for i, list := range s.roleList { + if i == -1 { + continue + } + + role := s.data.Roles[i] + + for j, device := range list { + newState := role.ApplyEffect(&device, models.SceneRunContext{ + Index: j, + Length: len(list), + IntervalNumber: intervalNumber, + IntervalMax: intervalMax, + }) + + err := device.SetState(newState) + if err != nil { + continue + } + + s.lastStates[device.ID] = device.State + + updatedDevices = append(updatedDevices, device) + } + } + + s.due = false + s.lastInterval = intervalNumber + + return updatedDevices +} + +func (s *Scene) LastState(id int) *models.DeviceState { + lastState, ok := s.lastStates[id] + if !ok { + return nil + } + + return &lastState +} diff --git a/cmd/lucy/command.go b/cmd/lucy/command.go index 2648711..fe7aab1 100644 --- a/cmd/lucy/command.go +++ b/cmd/lucy/command.go @@ -5,6 +5,7 @@ import ( "log" "strconv" "strings" + "time" ) type Param struct { @@ -221,6 +222,17 @@ func (p Params) DeviceState(prefix string) models.NewDeviceState { } } +func (p Params) SceneAssignment(prefix string) *models.DeviceSceneAssignment { + if p.Get(prefix+"scene") == nil { + return nil + } + + return &models.DeviceSceneAssignment{ + SceneID: p.Get(prefix+"scene").IntOr(-1), + Group: p.Get(prefix+"scene.group").StringOr(time.Now().Format(time.RFC3339)), + DurationMS: int64(p.Get(prefix+"scene.duration").IntOr(0)), + } +} type Command struct { Name string diff --git a/cmd/lucy/handlercmd.go b/cmd/lucy/handlercmd.go index 9a65def..05a04d2 100644 --- a/cmd/lucy/handlercmd.go +++ b/cmd/lucy/handlercmd.go @@ -99,7 +99,7 @@ func handlerCmd( func applyCmdToHandler(model models.EventHandler, cmd Command) models.EventHandler { // Remove keys for _, elem := range cmd.Params.Strings(1) { - if elem[0] != '-' { + if !strings.HasPrefix(elem, "-") { continue } keyToRemove := elem[1:] @@ -128,6 +128,10 @@ func applyCmdToHandler(model models.EventHandler, cmd Command) models.EventHandl model.From = models.Never case "to": model.To = models.Never + case "set-scene": + model.Actions.SetScene = nil + case "push-scene": + model.Actions.PushScene = nil } } } @@ -173,5 +177,9 @@ func applyCmdToHandler(model models.EventHandler, cmd Command) models.EventHandl } } + // Scenes + model.Actions.SetScene = cmd.Params.SceneAssignment("set-") + model.Actions.PushScene = cmd.Params.SceneAssignment("push-") + return model } diff --git a/cmd/lucy/main.go b/cmd/lucy/main.go index adc8fa8..0e74490 100644 --- a/cmd/lucy/main.go +++ b/cmd/lucy/main.go @@ -96,6 +96,9 @@ func main() { case "handler": handlerCmd(ctx, c) + case "scene": + sceneCmd(ctx, c) + // EVENT case "run": { diff --git a/cmd/lucy/scenecmd.go b/cmd/lucy/scenecmd.go new file mode 100644 index 0000000..31139d4 --- /dev/null +++ b/cmd/lucy/scenecmd.go @@ -0,0 +1,165 @@ +package main + +import ( + "context" + "git.aiterp.net/lucifer/new-server/app/client" + "git.aiterp.net/lucifer/new-server/models" + "gopkg.in/yaml.v2" + "log" + "os" + "strconv" + "strings" + "unicode" +) + +func sceneCmd( + ctx context.Context, + c client.Client, +) { + cmd := parseCommand(os.Args[2:]) + + switch cmd.Name { + case "create", "update": + { + fileName := cmd.Params.Get(0).String() + if fileName == nil { + log.Fatalln("Missing filename") + } + + file, err := os.Open(*fileName) + if err != nil { + log.Fatalln("Failed to open file:", err) + } + defer file.Close() + + yamlData := make(map[string]interface{}) + err = yaml.NewDecoder(file).Decode(yamlData) + if err != nil { + log.Fatalln("Failed to decode file:", err) + return + } + + yamlData = camelCasify(yamlData) + name, nameOk := yamlData["name"] + if !nameOk { + log.Fatalln("Missing name in yaml data.") + } + + var scene models.Scene + if cmd.Name == "create" { + err := c.Fetch(ctx, "POST", "/api/scenes", &scene, yamlData) + if err != nil { + log.Fatalln("Failed to create scene:", err) + return + } + } else { + scenes, err := c.GetScenes(ctx) + if err != nil { + log.Fatalln("Failed to fetch existing scenes:", err) + return + } + + id := -1 + for _, scene := range scenes { + if scene.Name == name { + id = scene.ID + break + } + } + if id == -1 { + log.Fatalln("Could not find scene with name", name) + return + } + + err = c.Fetch(ctx, "PUT", "/api/scenes/"+strconv.Itoa(id), &scene, yamlData) + if err != nil { + log.Fatalln("Failed to update scene:", err) + return + } + } + } + + case "push", "assign": + { + fetch := cmd.Params.Get(0).String() + id := cmd.Params.Get(1).Int() + if fetch == nil || id == nil { + log.Println("Usage: lucy scene assign ") + } + + devices, err := c.AssignDevice(ctx, *fetch, cmd.Name == "push", models.DeviceSceneAssignment{ + SceneID: *id, + Group: cmd.Params.Get("group").StringOr(*fetch), + DurationMS: int64(cmd.Params.Get("duration").IntOr(0)), + }) + if err != nil { + log.Println("Could not assign devices:", err) + return + } + + WriteDeviceInfoTable(os.Stdout, devices) + } + + case "clear": + fetch := cmd.Params.Get(0).String() + if fetch == nil { + log.Println("Usage: lucy scene clear ") + } + + devices, err := c.ClearDevice(ctx, *fetch) + if err != nil { + log.Println("Could not clear devices:", err) + return + } + + WriteDeviceInfoTable(os.Stdout, devices) + } +} + +func camelCasify(m map[string]interface{}) map[string]interface{} { + m2 := make(map[string]interface{}, len(m)) + for key, value := range m { + b := strings.Builder{} + snake := false + for _, ch := range key { + if ch == '_' { + snake = true + } else if snake { + b.WriteRune(unicode.ToUpper(ch)) + snake = false + } else { + b.WriteRune(ch) + } + } + + switch value := value.(type) { + case []interface{}: + valueCopy := make([]interface{}, len(value)) + for i, elem := range value { + switch elem := elem.(type) { + case map[interface{}]interface{}: + m3 := make(map[string]interface{}) + for k, v := range elem { + if kStr, ok := k.(string); ok { + m3[kStr] = v + } + } + + valueCopy[i] = camelCasify(m3) + case map[string]interface{}: + valueCopy[i] = camelCasify(elem) + default: + valueCopy[i] = elem + } + } + + m2[b.String()] = valueCopy + case map[string]interface{}: + m2[b.String()] = camelCasify(value) + default: + m2[b.String()] = value + } + } + + return m2 +} diff --git a/cmd/lucy/tables.go b/cmd/lucy/tables.go index cb3a15b..7808ca4 100644 --- a/cmd/lucy/tables.go +++ b/cmd/lucy/tables.go @@ -14,16 +14,32 @@ func WriteDeviceStateTable(w io.Writer, devices []models.Device) { table.SetHeader([]string{"ID", "NAME", "POWER", "COLOR", "INTENSITY", "TEMPERATURE"}) table.SetReflowDuringAutoWrap(true) + table.SetColumnAlignment([]int{ + tablewriter.ALIGN_RIGHT, + tablewriter.ALIGN_LEFT, + tablewriter.ALIGN_RIGHT, + tablewriter.ALIGN_LEFT, + tablewriter.ALIGN_RIGHT, + tablewriter.ALIGN_RIGHT, + }) for _, v := range devices { powerStr := "" if v.HasCapability(models.DCPower) { - powerStr = strconv.FormatBool(v.State.Power) + if v.SceneState != nil && v.SceneState.Power != v.State.Power { + powerStr = strconv.FormatBool(v.SceneState.Power) + "*" + } else { + powerStr = strconv.FormatBool(v.State.Power) + } } colorStr := "" if v.HasCapability(models.DCColorHSK, models.DCColorHS, models.DCColorKelvin) { - colorStr = v.State.Color.String() + if v.SceneState != nil && v.SceneState.Color.String() != v.State.Color.String() { + colorStr = v.SceneState.Color.String() + "*" + } else { + colorStr = v.State.Color.String() + } } temperatureString := "" @@ -33,7 +49,11 @@ func WriteDeviceStateTable(w io.Writer, devices []models.Device) { intensityString := "" if v.HasCapability(models.DCIntensity) { - intensityString = strconv.FormatFloat(v.State.Intensity, 'f', -1, 64) + if v.SceneState != nil && v.SceneState.Intensity != v.State.Intensity { + intensityString = strconv.FormatFloat(v.SceneState.Intensity, 'g', 2, 64) + "*" + } else { + intensityString = strconv.FormatFloat(v.State.Intensity, 'f', -1, 64) + } } table.Append([]string{ @@ -104,6 +124,17 @@ func WriteHandlerInfoTable(w io.Writer, handlers []models.EventHandler) { if h.Actions.FireEvent != nil { actionStr += fmt.Sprintf("fireEvent=%s ", (*h.Actions.FireEvent).Name) } + if h.Actions.FireEvent != nil { + actionStr += fmt.Sprintf("fireEvent=%s ", (*h.Actions.FireEvent).Name) + } + if h.Actions.SetScene != nil { + s := h.Actions.SetScene + actionStr += fmt.Sprintf("setScene=(id=%d,group=\"%s\",duration=%d) ", s.SceneID, s.Group, s.DurationMS) + } + if h.Actions.PushScene != nil { + s := h.Actions.PushScene + actionStr += fmt.Sprintf("pushScene=(id=%d,group=\"%s\",duration=%d) ", s.SceneID, s.Group, s.DurationMS) + } eventName := h.EventName if h.OneShot { diff --git a/internal/drivers/hue/state.go b/internal/drivers/hue/state.go index 288eaa0..588f4ae 100644 --- a/internal/drivers/hue/state.go +++ b/internal/drivers/hue/state.go @@ -122,6 +122,9 @@ func (state *hueSensorState) Update(newData SensorData) *models.Event { if time.Since(stateTime) > time.Second*3 { return nil } + if state.prevData == nil { + return nil + } pe := state.prevData.State.ButtonEvent ce := newData.State.ButtonEvent diff --git a/internal/drivers/lifx/bridge.go b/internal/drivers/lifx/bridge.go index cee191e..410ecd1 100644 --- a/internal/drivers/lifx/bridge.go +++ b/internal/drivers/lifx/bridge.go @@ -204,7 +204,9 @@ func (b *Bridge) Run(ctx context.Context, debug bool) error { b.checkAndUpdateState(state) } - if time.Since(state.discoveredTime) > time.Second*10 { + if time.Since(state.discoveredTime) > time.Second*10 && time.Since(state.fwSpamTime) > time.Second * 30 { + state.fwSpamTime = time.Now() + if state.firmware == nil { _, _ = client.Send(state.target, &GetHostFirmware{}) } diff --git a/internal/drivers/lifx/state.go b/internal/drivers/lifx/state.go index 864c8c9..bb0fac1 100644 --- a/internal/drivers/lifx/state.go +++ b/internal/drivers/lifx/state.go @@ -18,6 +18,7 @@ type State struct { lightStateTime time.Time requestTime time.Time updateTime time.Time + fwSpamTime time.Time acksPending []uint8 } diff --git a/internal/drivers/nanoleaf/bridge.go b/internal/drivers/nanoleaf/bridge.go index 2c91727..ce5d916 100644 --- a/internal/drivers/nanoleaf/bridge.go +++ b/internal/drivers/nanoleaf/bridge.go @@ -177,7 +177,7 @@ func (b *bridge) Update(devices []models.Device) { red, green, blue := color.RGB255() newColor := [4]byte{red, green, blue, 255} if newColor != panel.ColorRGBA { - panel.update(newColor, time.Now().Add(time.Millisecond*120)) + panel.update(newColor, time.Now().Add(time.Millisecond*220)) } } else { panel.update([4]byte{0, 0, 0, 0}, time.Now()) diff --git a/internal/mysql/devicerepo.go b/internal/mysql/devicerepo.go index 1d6e373..2b17574 100644 --- a/internal/mysql/devicerepo.go +++ b/internal/mysql/devicerepo.go @@ -12,13 +12,14 @@ import ( ) 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"` + 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"` + SceneAssignmentJSON json.RawMessage `db:"scene_assignments"` } type deviceStateRecord struct { @@ -117,125 +118,146 @@ func (r *DeviceRepo) FetchByReference(ctx context.Context, kind models.Reference return r.populate(ctx, records) } -func (r *DeviceRepo) Save(ctx context.Context, device *models.Device, mode models.SaveMode) error { +func (r *DeviceRepo) SaveMany(ctx context.Context, mode models.SaveMode, devices []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, ","), - } + for i, device := range devices { + scenesJSON, err := json.Marshal(device.SceneAssignments) + if err != nil { + return err + } - if device.ID > 0 { - _, err := tx.NamedExecContext(ctx, ` + record := deviceRecord{ + ID: device.ID, + BridgeID: device.BridgeID, + InternalID: device.InternalID, + SceneAssignmentJSON: scenesJSON, + 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 + button_names = :button_names, + scene_assignments = :scene_assignments WHERE id=:id `, record) - if err != nil { - return dbErr(err) - } - - // Let's just be lazy for now, optimize later if need be. - if mode == 0 || mode&models.SMTags != 0 { - _, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID) if err != nil { return dbErr(err) } - } - if mode == 0 || mode&models.SMProperties != 0 { - _, err = tx.ExecContext(ctx, "DELETE FROM device_property WHERE device_id=?", record.ID) - if err != nil { - return dbErr(err) + // Let's just be lazy for now, optimize later if need be. + if mode == 0 || mode&models.SMTags != 0 { + _, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID) + if err != nil { + return dbErr(err) + } } - } - } else { - res, err := tx.NamedExecContext(ctx, ` + + if mode == 0 || mode&models.SMProperties != 0 { + _, 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) - } - - if mode == 0 || mode&models.SMTags != 0 { - 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) } - } - } - if mode == 0 || mode&models.SMProperties != 0 { - 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, - ) + lastID, err := res.LastInsertId() if err != nil { return dbErr(err) } + + record.ID = int(lastID) + devices[i].ID = int(lastID) } - for key, value := range device.DriverProperties { - j, err := json.Marshal(value) - if err != nil { - // Eh, it'll get filled by the driver anyway - continue + if mode == 0 || mode&models.SMTags != 0 { + 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) + } } + } - _, 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) + if mode == 0 || mode&models.SMProperties != 0 { + 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) + } } } - } - if mode == 0 || mode&models.SMState != 0 { - _, err = tx.NamedExecContext(ctx, ` + if mode == 0 || mode&models.SMState != 0 { + _, 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) + `, 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) Save(ctx context.Context, device *models.Device, mode models.SaveMode) error { + devices := []models.Device{*device} + + err := r.SaveMany(ctx, mode, devices) + if err != nil { + return err + } + + *device = devices[0] + return nil +} + 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 { @@ -328,12 +350,15 @@ func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]mo InternalID: record.InternalID, Icon: record.Icon, Name: record.Name, + SceneAssignments: make([]models.DeviceSceneAssignment, 0, 4), ButtonNames: strings.Split(record.ButtonNames, ","), DriverProperties: make(map[string]interface{}, 8), UserProperties: make(map[string]string, 8), Tags: make([]string, 0, 8), } + _ = json.Unmarshal(record.SceneAssignmentJSON, &device.SceneAssignments) + if device.ButtonNames[0] == "" { device.ButtonNames = device.ButtonNames[:0] } diff --git a/internal/mysql/scenerepo.go b/internal/mysql/scenerepo.go new file mode 100644 index 0000000..b8db256 --- /dev/null +++ b/internal/mysql/scenerepo.go @@ -0,0 +1,123 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "git.aiterp.net/lucifer/new-server/models" + "github.com/jmoiron/sqlx" +) + +type sceneRecord struct { + ID int `db:"id"` + Name string `db:"name"` + IntervalMS int64 `db:"interval_ms"` + RoleJSON json.RawMessage `db:"roles"` +} + +type SceneRepo struct { + DBX *sqlx.DB +} + +func (r *SceneRepo) Find(ctx context.Context, id int) (*models.Scene, error) { + var scene sceneRecord + err := r.DBX.GetContext(ctx, &scene, "SELECT * FROM scene WHERE id = ?", id) + if err != nil { + return nil, dbErr(err) + } + + return r.populateOne(&scene) +} + +func (r *SceneRepo) FetchAll(ctx context.Context) ([]models.Scene, error) { + scenes := make([]sceneRecord, 0, 8) + err := r.DBX.SelectContext(ctx, &scenes, "SELECT * FROM scene") + if err != nil { + if err == sql.ErrNoRows { + return []models.Scene{}, nil + } + + return nil, dbErr(err) + } + + return r.populate(scenes) +} + +func (r *SceneRepo) Save(ctx context.Context, scene *models.Scene) error { + j, err := json.Marshal(scene.Roles) + if err != nil { + return err + } + + if scene.ID > 0 { + _, err := r.DBX.ExecContext( + ctx, + "UPDATE scene SET name = ?, interval_ms = ?, roles = ? WHERE id = ?", + scene.Name, scene.IntervalMS, j, scene.ID, + ) + + if err != nil { + return dbErr(err) + } + } else { + rs, err := r.DBX.ExecContext( + ctx, + "INSERT INTO scene (name, interval_ms, roles) VALUES (?, ?, ?)", + scene.Name, scene.IntervalMS, j, + ) + + if err != nil { + return dbErr(err) + } + + id, err := rs.LastInsertId() + if err != nil { + return dbErr(err) + } + + scene.ID = int(id) + } + + return nil +} + +func (r *SceneRepo) Delete(ctx context.Context, scene *models.Scene) error { + _, err := r.DBX.ExecContext(ctx, "DELETE FROM scene WHERE id = ?", scene.ID) + if err != nil { + return dbErr(err) + } + + scene.ID = 0 + return nil +} + +func (r *SceneRepo) populateOne(record *sceneRecord) (*models.Scene, error) { + records, err := r.populate([]sceneRecord{*record}) + if err != nil { + return nil, err + } + + return &records[0], nil +} + +func (r *SceneRepo) populate(records []sceneRecord) ([]models.Scene, error) { + res := make([]models.Scene, 0, len(records)) + + for _, record := range records { + scene := models.Scene{ + ID: record.ID, + Name: record.Name, + IntervalMS: record.IntervalMS, + Roles: make([]models.SceneRole, 0, 8), + } + + err := json.Unmarshal(record.RoleJSON, &scene.Roles) + if err != nil { + return nil, dbErr(err) + } + + res = append(res, scene) + } + + return res, nil +} diff --git a/models/colorvalue.go b/models/colorvalue.go index 0bfff3e..119e170 100644 --- a/models/colorvalue.go +++ b/models/colorvalue.go @@ -26,9 +26,11 @@ func (c *ColorValue) String() string { return fmt.Sprintf("k:%d", c.Kelvin) } - return fmt.Sprintf("hs:%g,%g", c.Hue, c.Saturation) + return fmt.Sprintf("hs:%.4g,%.3g", c.Hue, c.Saturation) } + + func ParseColorValue(raw string) (ColorValue, error) { tokens := strings.SplitN(raw, ":", 2) if len(tokens) != 2 { diff --git a/models/device.go b/models/device.go index e8e5146..aa76fe2 100644 --- a/models/device.go +++ b/models/device.go @@ -2,21 +2,25 @@ package models import ( "context" + "math" "strings" + "time" ) type Device struct { - ID int `json:"id"` - BridgeID int `json:"bridgeID"` - InternalID string `json:"internalId"` - Icon string `json:"icon"` - Name string `json:"name"` - Capabilities []DeviceCapability `json:"capabilities"` - ButtonNames []string `json:"buttonNames"` - DriverProperties map[string]interface{} `json:"driverProperties"` - UserProperties map[string]string `json:"userProperties"` - State DeviceState `json:"state"` - Tags []string `json:"tags"` + ID int `json:"id"` + BridgeID int `json:"bridgeID"` + InternalID string `json:"internalId"` + Icon string `json:"icon"` + Name string `json:"name"` + Capabilities []DeviceCapability `json:"capabilities"` + ButtonNames []string `json:"buttonNames"` + DriverProperties map[string]interface{} `json:"driverProperties"` + UserProperties map[string]string `json:"userProperties"` + SceneAssignments []DeviceSceneAssignment `json:"sceneAssignments"` + SceneState *DeviceState `json:"sceneState"` + State DeviceState `json:"state"` + Tags []string `json:"tags"` } type DeviceUpdate struct { @@ -37,6 +41,13 @@ type DeviceState struct { Temperature float64 `json:"temperature"` } +type DeviceScene struct { + SceneID int `json:"sceneId"` + Time time.Time `json:"time"` + DurationMS int64 `json:"duration"` + Tag string `json:"tag"` +} + type NewDeviceState struct { Power *bool `json:"power"` Color *string `json:"color"` @@ -58,6 +69,7 @@ type DeviceRepository interface { Find(ctx context.Context, id int) (*Device, error) FetchByReference(ctx context.Context, kind ReferenceKind, value string) ([]Device, error) Save(ctx context.Context, device *Device, mode SaveMode) error + SaveMany(ctx context.Context, mode SaveMode, devices []Device) error Delete(ctx context.Context, device *Device) error } @@ -178,3 +190,63 @@ func (d *Device) SetState(newState NewDeviceState) error { return nil } + +func (s *NewDeviceState) RelativeTo(device Device) NewDeviceState { + n := NewDeviceState{} + + if s.Intensity != nil { + intensity := device.State.Intensity * *s.Intensity + n.Intensity = &intensity + } + if s.Color != nil { + c, err := ParseColorValue(*s.Color) + if err == nil { + c.Hue = math.Mod(device.State.Color.Hue+c.Hue, 360) + c.Saturation *= device.State.Color.Saturation + c.Kelvin += device.State.Color.Kelvin + } + } + + return n +} + +func (s *NewDeviceState) Interpolate(other NewDeviceState, fac float64) NewDeviceState { + n := NewDeviceState{} + + if s.Power != nil && other.Power != nil { + if fac >= 0.5 { + n.Power = other.Power + } else { + n.Power = s.Power + } + } + + if s.Color != nil && other.Color != nil { + sc, err := ParseColorValue(*s.Color) + oc, err2 := ParseColorValue(*other.Color) + if err == nil && err2 == nil { + rc := ColorValue{} + rc.Hue = interpolateFloat(sc.Hue, oc.Hue, fac) + rc.Saturation = interpolateFloat(sc.Saturation, oc.Saturation, fac) + rc.Kelvin = interpolateInt(sc.Kelvin, oc.Kelvin, fac) + + rcStr := rc.String() + n.Color = &rcStr + } + } + + if s.Intensity != nil && other.Intensity != nil { + n.Intensity = new(float64) + *n.Intensity = interpolateFloat(*s.Intensity, *other.Intensity, fac) + } + + return n +} + +func interpolateFloat(a, b, fac float64) float64 { + return (a * (1 - fac)) + (b * fac) +} + +func interpolateInt(a, b int, fac float64) int { + return int((float64(a) * (1 - fac)) + (float64(b) * fac)) +} diff --git a/models/errors.go b/models/errors.go index 8566d9a..ee40e6b 100644 --- a/models/errors.go +++ b/models/errors.go @@ -22,3 +22,10 @@ var ErrInvalidPacketSize = errors.New("invalid packet size") var ErrReadTimeout = errors.New("read timeout") var ErrUnrecognizedPacketType = errors.New("packet type not recognized") var ErrBridgeRunningRequired = errors.New("this operation cannot be performed when bridge is not running") + +var ErrSceneInvalidInterval = errors.New("scene interval must be 0 (=disabled) or greater") +var ErrSceneNoRoles = errors.New("scene cannot have zero rules") +var ErrSceneRoleNoStates = errors.New("scene rule has no states") +var ErrSceneRoleUnsupportedOrdering = errors.New("scene rule has an unsupported ordering") +var ErrSceneRoleUnknownEffect = errors.New("scene rule has an unknown effect") +var ErrSceneRoleUnknownPowerMode = errors.New("scene rule has an unknown power mode") diff --git a/models/eventhandler.go b/models/eventhandler.go index 31a2b7e..0a7ea4f 100644 --- a/models/eventhandler.go +++ b/models/eventhandler.go @@ -5,6 +5,7 @@ import ( "regexp" "strconv" "strings" + "time" ) type EventHandler struct { @@ -168,6 +169,20 @@ func (c *EventCondition) checkDevice(key string, device Device) (matches bool, s } return c.matches(strconv.FormatFloat(device.State.Temperature, 'f', -1, 64)), false + case "scene": + if len(device.SceneAssignments) == 0 { + return false, false + } + + sceneId := -1 + for _, assignment := range device.SceneAssignments { + duration := time.Duration(assignment.DurationMS) * time.Millisecond + if duration <= 0 || time.Now().Before(assignment.StartTime.Add(duration)) { + sceneId = assignment.SceneID + } + } + + return c.matches(strconv.Itoa(sceneId)), false default: return false, true } @@ -214,12 +229,14 @@ func (c *EventCondition) matches(value string) bool { } type EventAction struct { - SetPower *bool `json:"setPower"` - SetColor *string `json:"setColor"` - SetIntensity *float64 `json:"setIntensity"` - SetTemperature *int `json:"setTemperature"` - AddIntensity *float64 `json:"addIntensity"` - FireEvent *Event `json:"fireEvent"` + SetPower *bool `json:"setPower"` + SetColor *string `json:"setColor"` + SetIntensity *float64 `json:"setIntensity"` + SetTemperature *int `json:"setTemperature"` + AddIntensity *float64 `json:"addIntensity"` + FireEvent *Event `json:"fireEvent"` + SetScene *DeviceSceneAssignment `json:"setScene"` + PushScene *DeviceSceneAssignment `json:"pushScene"` } func (action *EventAction) Apply(other EventAction) { @@ -238,6 +255,12 @@ func (action *EventAction) Apply(other EventAction) { if action.FireEvent == nil { action.FireEvent = other.FireEvent } + if action.SetScene == nil { + action.SetScene = other.SetScene + } + if action.PushScene == nil { + action.PushScene = other.PushScene + } } func (c EventCondition) String() string { diff --git a/models/scene.go b/models/scene.go new file mode 100644 index 0000000..7dd215e --- /dev/null +++ b/models/scene.go @@ -0,0 +1,251 @@ +package models + +import ( + "context" + "math" + "math/rand" + "sort" + "strings" + "time" +) + +type Scene struct { + ID int `json:"id"` + Name string `json:"name"` + IntervalMS int64 `json:"interval"` + Roles []SceneRole `json:"roles"` +} + +func (s *Scene) Validate() error { + if s.IntervalMS < 0 { + return ErrSceneInvalidInterval + } + if len(s.Roles) == 0 { + return ErrSceneNoRoles + } + + for _, role := range s.Roles { + err := role.Validate() + if err != nil { + return err + } + } + + return nil +} + +func (s *Scene) Role(device *Device) *SceneRole { + index := s.RoleIndex(device) + if index == -1 { + return nil + } + + return &s.Roles[index] +} + +func (s *Scene) RoleIndex(device *Device) int { + for i, role := range s.Roles { + if role.TargetKind.Matches(device, role.TargetValue) { + return i + } + } + + return -1 +} + +type SceneEffect string + +const ( + SEStatic SceneEffect = "Static" + SERandom SceneEffect = "Random" + SEGradient SceneEffect = "Gradient" + SEWalkingGradient SceneEffect = "WalkingGradient" + SETransition SceneEffect = "Transition" +) + +type SceneRole struct { + Effect SceneEffect `json:"effect"` + PowerMode ScenePowerMode `json:"powerMode"` + TargetKind ReferenceKind `json:"targetKind"` + TargetValue string `json:"targetValue"` + Interpolate bool `json:"interpolate"` + Relative bool `json:"relative"` + Order string `json:"order"` + States []NewDeviceState `json:"states"` +} + +type SceneRunContext struct { + Index int + Length int + IntervalNumber int64 + IntervalMax int64 +} + +func (d *SceneRunContext) PositionFacShifted() float64 { + shiftFac := float64(d.IntervalNumber%int64(d.Length)) / float64(d.Length) + + return math.Mod(d.PositionFac()+shiftFac, 1) +} + +func (d *SceneRunContext) PositionFac() float64 { + return float64(d.Index) / float64(d.Length) +} + +func (d *SceneRunContext) IntervalFac() float64 { + fac := float64(d.IntervalNumber) / float64(d.IntervalMax) + if fac > 1 { + return 1 + } else if fac < 0 { + return 0 + } else { + return fac + } +} + +func (r *SceneRole) Validate() error { + if len(r.States) == 0 { + return ErrSceneRoleNoStates + } + + switch r.TargetKind { + case RKTag, RKBridgeID, RKDeviceID, RKName, RKAll: + default: + return ErrBadInput + } + + switch r.PowerMode { + case SPScene, SPDevice, SPBoth: + default: + return ErrSceneRoleUnknownPowerMode + } + + switch r.Effect { + case SEStatic, SERandom, SEGradient, SEWalkingGradient, SETransition: + default: + return ErrSceneRoleUnknownEffect + } + + switch r.Order { + case "", "-name", "name", "+name", "-id", "id", "+id": + default: + return ErrSceneRoleUnsupportedOrdering + } + + return nil +} + +func (r *SceneRole) ApplyOrder(devices []Device) { + if r.Order == "" { + return + } + + desc := strings.HasPrefix(r.Order, "-") + + orderKey := r.Order + if desc || strings.HasPrefix(r.Order, "+") { + orderKey = orderKey[1:] + } + + switch orderKey { + case "id": + sort.Slice(devices, func(i, j int) bool { + if desc { + return devices[i].ID > devices[j].ID + } + + return devices[i].ID < devices[j].ID + }) + case "name": + sort.Slice(devices, func(i, j int) bool { + if desc { + return strings.Compare(devices[i].Name, devices[j].Name) < 0 + } + + return strings.Compare(devices[i].Name, devices[j].Name) > 0 + }) + } +} + +func (r *SceneRole) ApplyEffect(device *Device, c SceneRunContext) (newState NewDeviceState) { + switch r.Effect { + case SEStatic: + newState = r.States[0] + case SERandom: + newState = r.State(rand.Float64()) + case SEGradient: + newState = r.State(c.PositionFac()) + case SEWalkingGradient: + newState = r.State(c.PositionFacShifted()) + case SETransition: + newState = r.State(c.IntervalFac()) + } + + if r.Relative { + newState = newState.RelativeTo(*device) + } + + switch r.PowerMode { + case SPDevice: + newState.Power = nil + case SPScene: + // Do nothing + case SPBoth: + if newState.Power != nil { + powerIntersection := *newState.Power && device.State.Power + newState.Power = &powerIntersection + } + } + + return +} + +func (r *SceneRole) State(fac float64) NewDeviceState { + if len(r.States) == 0 { + return NewDeviceState{} + } else if len(r.States) == 1 { + return r.States[0] + } + + if r.Interpolate { + if len(r.States) == 2 { + return r.States[0].Interpolate(r.States[1], fac) + } else { + pos := fac * float64(len(r.States)-1) + start := math.Floor(pos) + localFac := pos - start + + return r.States[int(start)].Interpolate(r.States[int(start)+1], localFac) + } + } else { + index := int(fac * float64(len(r.States))) + if index == len(r.States) { + index = len(r.States) - 1 + } + + return r.States[index] + } +} + +type ScenePowerMode string + +const ( + SPDevice ScenePowerMode = "Device" // Device state decides power. Scene state may only power off. + SPScene ScenePowerMode = "Scene" // Scene state decide power. + SPBoth ScenePowerMode = "Both" // Power is only on if both Device and Scene says it is. +) + +// DeviceSceneAssignment is an entry on the device stack. StartTime and DurationMS are only respected when the scene +// instance is created for the group. +type DeviceSceneAssignment struct { + SceneID int `json:"sceneId"` + Group string `json:"group"` + StartTime time.Time `json:"start"` + DurationMS int64 `json:"durationMs"` +} + +type SceneRepository interface { + Find(ctx context.Context, id int) (*Scene, error) + FetchAll(ctx context.Context) ([]Scene, error) + Save(ctx context.Context, bridge *Scene) error + Delete(ctx context.Context, bridge *Scene) error +} diff --git a/models/shared.go b/models/shared.go index 3917b58..d152004 100644 --- a/models/shared.go +++ b/models/shared.go @@ -15,6 +15,56 @@ var ( RKName ReferenceKind = "Name" ) +func (rk ReferenceKind) Matches(device *Device, value string) bool { + switch rk { + case RKName: + if strings.HasPrefix(value, "*") { + return strings.HasSuffix(device.Name, value[1:]) + } else if strings.HasSuffix(value, "*") { + return strings.HasPrefix(device.Name, value[:len(value)-1]) + } else { + return device.Name == value + } + case RKDeviceID: + idStr := strconv.Itoa(device.ID) + for _, idStr2 := range strings.Split(value, ",") { + if idStr == idStr2 { + return true + } + } + case RKBridgeID: + idStr := strconv.Itoa(device.BridgeID) + for _, idStr2 := range strings.Split(value, ",") { + if idStr == idStr2 { + return true + } + } + case RKTag: + hadAny := false + for _, tag := range strings.Split(value, ",") { + if strings.HasPrefix(tag, "-") { + if device.HasTag(tag[1:]) { + return false + } + } else if strings.HasPrefix(tag, "+") { + if !device.HasTag(tag[1:]) { + return false + } + } else { + if device.HasTag(tag) { + hadAny = true + } + } + } + + return hadAny + case RKAll: + return true + } + + return false +} + func ParseFetchString(fetchStr string) (ReferenceKind, string) { if strings.HasPrefix(fetchStr, "tag:") { return RKTag, fetchStr[4:] @@ -34,4 +84,4 @@ func ParseFetchString(fetchStr string) (ReferenceKind, string) { return RKDeviceID, fetchStr } -} +} \ No newline at end of file diff --git a/scene-examples/evening.yaml b/scene-examples/evening.yaml new file mode 100644 index 0000000..825b83f --- /dev/null +++ b/scene-examples/evening.yaml @@ -0,0 +1,63 @@ +name: Evening +interval: 1500 +roles: + - effect: WalkingGradient + power_mode: Device + target_kind: Tag + target_value: Hexagon + interpolate: true + relative: false + order: -name + states: + - color: 'hs:30,1' + intensity: 0.20 + - color: 'hs:30,1' + intensity: 0.15 + + - effect: Gradient + power_mode: Device + target_kind: Tag + target_value: SquareLeft + interpolate: true + relative: false + order: +name + states: + - color: 'hs:25,1' + intensity: 0.075 + - color: 'hs:50,1' + intensity: 0.15 + + - effect: Random + power_mode: Device + target_kind: Tag + target_value: SquareRight + interpolate: true + relative: false + order: +name + states: + - color: 'hs:220,0.7' + intensity: 0.22 + - color: 'hs:220,0.6' + intensity: 0.25 + + - effect: Static + power_mode: Device + target_kind: Tag + target_value: Accent + interpolate: true + relative: false + order: +name + states: + - color: 'hs:250,0.7' + intensity: 0.25 + + - effect: Static + power_mode: Device + target_kind: All + target_value: "" + interpolate: true + relative: false + order: +name + states: + - color: 'k:2000' + intensity: 0.3 diff --git a/scene-examples/flash.yaml b/scene-examples/flash.yaml new file mode 100644 index 0000000..dc5ed51 --- /dev/null +++ b/scene-examples/flash.yaml @@ -0,0 +1,26 @@ +name: Flash +interval: 0 +roles: + - effect: Static + power_mode: Device + target_kind: Tag + target_value: Nanoleaf + states: + - intensity: 1 + color: hs:220,0.1 + + - effect: Static + power_mode: Device + target_kind: Tag + target_value: Hue + states: + - intensity: 1 + color: k:6500 + + - effect: Static + power_mode: Device + target_kind: All + target_value: "" + states: + - intensity: 1 + color: hs:0,0 diff --git a/scene-examples/late.yaml b/scene-examples/late.yaml new file mode 100644 index 0000000..c6d4613 --- /dev/null +++ b/scene-examples/late.yaml @@ -0,0 +1,57 @@ +name: Late +interval: 60000 +roles: + - effect: Random + power_mode: Device + target_kind: Tag + target_value: Hexagon + interpolate: true + relative: false + order: +name + states: + - color: 'hs:35,1' + intensity: 0.08 + - color: 'hs:35,1' + intensity: 0.085 + - color: 'hs:35,1' + intensity: 0.09 + - color: 'hs:35,1' + intensity: 0.12 + + - effect: Gradient + power_mode: Both + target_kind: Tag + target_value: Square + interpolate: true + relative: false + order: +name + states: + - color: 'hs:25,1' + intensity: 0.05 + power: off + - color: 'hs:30,1' + intensity: 0.06 + power: off + - color: 'hs:35,1' + intensity: 0.07 + power: on + + - effect: Static + power_mode: Device + target_kind: Tag + target_value: Nightstand + interpolate: true + relative: false + order: +name + states: + - color: 'k:2000' + intensity: 0.15 + + - effect: Static + power_mode: Scene + target_kind: All + target_value: '' + interpolate: true + relative: false + states: + - power: false diff --git a/scene-examples/morning.yaml b/scene-examples/morning.yaml new file mode 100644 index 0000000..52432b4 --- /dev/null +++ b/scene-examples/morning.yaml @@ -0,0 +1,40 @@ +name: Morning +interval: 1000 +roles: + - effect: WalkingGradient + power_mode: Device + target_kind: Tag + target_value: Hexagon + interpolate: true + relative: false + order: +name + states: + - color: 'hs:220,0.4' + intensity: 0.65 + - color: 'hs:220,0.5' + intensity: 0.50 + + - effect: Random + power_mode: Device + target_kind: Tag + target_value: Square + interpolate: true + relative: false + order: +name + states: + - color: 'hs:220,0.6' + intensity: 0.30 + - color: 'hs:220,0.5' + intensity: 0.35 + + - effect: Static + power_mode: Device + target_kind: All + target_value: '' + interpolate: true + relative: false + order: +name + states: + - color: 'hs:250,0.6' + intensity: 0.3 + diff --git a/scene-examples/saturday.yaml b/scene-examples/saturday.yaml new file mode 100644 index 0000000..7ee87d4 --- /dev/null +++ b/scene-examples/saturday.yaml @@ -0,0 +1,42 @@ +name: Saturday +interval: 700 +roles: + - effect: WalkingGradient + power_mode: Device + target_kind: Tag + target_value: Hexagon + interpolate: true + relative: false + order: +name + states: + - color: 'hs:220,0.5' + intensity: 0.25 + - color: 'hs:220,0.5' + intensity: 0.30 + + - effect: Random + power_mode: Device + target_kind: Tag + target_value: Square + interpolate: true + relative: false + order: +name + states: + - color: 'hs:220,0.6' + intensity: 0.15 + - color: 'hs:220,0.55' + intensity: 0.16 + - color: 'hs:220,0.5' + intensity: 0.20 + + - effect: Static + power_mode: Device + target_kind: All + target_value: '' + interpolate: true + relative: false + order: +name + states: + - color: 'hs:250,0.7' + intensity: 0.3 + diff --git a/scripts/20210926135923_scene.sql b/scripts/20210926135923_scene.sql new file mode 100644 index 0000000..de23be8 --- /dev/null +++ b/scripts/20210926135923_scene.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE scene +( + id INT NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + interval_ms INT NOT NULL, + roles JSON NOT NULL, + + PRIMARY KEY (id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE scene; +-- +goose StatementEnd diff --git a/scripts/20210926152011_device_sceneassignment.sql b/scripts/20210926152011_device_sceneassignment.sql new file mode 100644 index 0000000..433c2d0 --- /dev/null +++ b/scripts/20210926152011_device_sceneassignment.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE device ADD COLUMN scene_assignments JSON NOT NULL DEFAULT ('[]'); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE device DROP COLUMN IF EXISTS scene_assignments; +-- +goose StatementEnd