feature-scenesystem
#1
Merged
gisle
merged 11 commits from feature-scenesystem
into asmodeus
3 years ago
38 changed files with 1888 additions and 215 deletions
-
107app/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
-
37cmd/lucy/tables.go
-
3internal/drivers/hue/state.go
-
4internal/drivers/lifx/bridge.go
-
1internal/drivers/lifx/state.go
-
2internal/drivers/nanoleaf/bridge.go
-
189internal/mysql/devicerepo.go
-
123internal/mysql/scenerepo.go
-
4models/colorvalue.go
-
94models/device.go
-
7models/errors.go
-
35models/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