Browse Source
feature-scenesystem (#1)
feature-scenesystem (#1)
Co-authored-by: Gisle Aune <dev@gisle.me> Reviewed-on: https://git.aiterp.net/lucifer/new-server/pulls/1 Co-authored-by: gisle <gisle@hidden-email> Co-committed-by: gisle <gisle@hidden-email>feature-colorvalue2 3.2.0
gisle
3 years ago
38 changed files with 1888 additions and 215 deletions
-
105app/api/devices.go
-
68app/api/scenes.go
-
7app/api/util.go
-
25app/client/client.go
-
2app/client/handler.go
-
16app/client/scene.go
-
5app/config/db.go
-
4app/config/repo.go
-
14app/server.go
-
10app/services/bridges.go
-
22app/services/events.go
-
76app/services/publish.go
-
315app/services/publisher/publisher.go
-
186app/services/publisher/scene.go
-
12cmd/lucy/command.go
-
10cmd/lucy/handlercmd.go
-
3cmd/lucy/main.go
-
165cmd/lucy/scenecmd.go
-
31cmd/lucy/tables.go
-
3internal/drivers/hue/state.go
-
4internal/drivers/lifx/bridge.go
-
1internal/drivers/lifx/state.go
-
2internal/drivers/nanoleaf/bridge.go
-
31internal/mysql/devicerepo.go
-
123internal/mysql/scenerepo.go
-
4models/colorvalue.go
-
72models/device.go
-
7models/errors.go
-
23models/eventhandler.go
-
251models/scene.go
-
50models/shared.go
-
63scene-examples/evening.yaml
-
26scene-examples/flash.yaml
-
57scene-examples/late.yaml
-
40scene-examples/morning.yaml
-
42scene-examples/saturday.yaml
-
17scripts/20210926135923_scene.sql
-
9scripts/20210926152011_device_sceneassignment.sql
@ -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 |
||||
|
})) |
||||
|
} |
@ -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 |
||||
|
} |
@ -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() |
|
||||
} |
|
||||
}() |
|
||||
} |
|
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 <fetch> <id> <group=S> <duration=I>") |
||||
|
} |
||||
|
|
||||
|
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 <fetch>") |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
||||
|
|
@ -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 |
||||
|
|
@ -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 |
@ -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 |
Write
Preview
Loading…
Cancel
Save
Reference in new issue