Author SHA1 Message Date
gisle 07173e4e56 Merge branch 'asmodeus' into feature-scenesystem
continuous-integration/drone/push Build is passing
2021-10-04 20:14:16 +02:00
gisle 7b0028ee2b new scene system is kind of working. I haven't tested changing between scenes and Transition effect yet, though.
continuous-integration/drone/push Build is passing
2021-10-04 20:13:57 +02:00
gisle d23a7327ef Merge branch 'asmodeus' into feature-scenesystem
continuous-integration/drone/push Build is failing
2021-10-03 18:42:33 +02:00
gisle a3695a3ddd some more work.
continuous-integration/drone/push Build is failing
2021-09-28 21:57:28 +02:00
gisle ffa757f742 rewrite of publishing mechanism to make scenes first-class.
continuous-integration/drone/push Build is failing
2021-09-26 22:15:49 +02:00
gisle f87c69efc7 this does not work, it needs massive changes.
continuous-integration/drone/push Build is passing
2021-09-26 16:20:02 +02:00
gisle b4fd6df454 Merge branch 'asmodeus' into feature-scenesystem
continuous-integration/drone/push Build is failing
2021-09-26 10:51:28 +02:00
gisle 44716c84d1 scene system so far.
continuous-integration/drone/push Build is failing
2021-09-25 19:04:54 +02:00
20 changed files with 1209 additions and 190 deletions
+48
View File
@@ -5,7 +5,9 @@ import (
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/models"
"github.com/gin-gonic/gin"
"golang.org/x/sync/errgroup"
"log"
"time"
)
func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) {
@@ -194,4 +196,50 @@ func Devices(r gin.IRoutes) {
return 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()
for i := range devices {
devices[i].SceneAssignments = []models.DeviceSceneAssignment{body}
}
config.PublishChannel <- devices
eg := errgroup.Group{}
for i := range devices {
device := devices[i]
eg.Go(func() error {
return config.DeviceRepository().Save(ctxOf(c), &device, 0)
})
}
err = eg.Wait()
if err != nil {
return nil, err
}
return devices, nil
}))
}
+37
View File
@@ -0,0 +1,37 @@
package api
import (
"git.aiterp.net/lucifer/new-server/app/config"
"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
}
return body, nil
}))
}
+7
View File
@@ -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 {
+3 -2
View File
@@ -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
+4
View File
@@ -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()}
}
+13 -1
View File
@@ -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())))
}
+7 -6
View File
@@ -160,18 +160,19 @@ func handleEvent(event models.Event) (responses []models.Event) {
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)
-76
View File
@@ -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()
}
}()
}
+275
View File
@@ -0,0 +1,275 @@
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) ReloadScenes(ctx context.Context) error {
scenes, err := config.SceneRepository().FetchAll(ctx)
if err != nil {
return err
}
p.mu.Lock()
for _, scene := range scenes {
p.sceneData[scene.ID] = &scene
}
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) {
deleteList = append(deleteList, i-len(deleteList))
updatedList = append(updatedList, scene.AllDevices()...)
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
}
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),
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 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.PublishChannel(config.PublishChannel)
go publisher.Run()
return nil
}
+163
View File
@@ -0,0 +1,163 @@
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
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)
}
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
}
updatedDevices = append(updatedDevices, device)
}
}
s.due = false
s.lastInterval = intervalNumber
return updatedDevices
}
+3 -1
View File
@@ -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{})
}
+1
View File
@@ -18,6 +18,7 @@ type State struct {
lightStateTime time.Time
requestTime time.Time
updateTime time.Time
fwSpamTime time.Time
acksPending []uint8
}
+117 -92
View File
@@ -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)
}
// 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)
}
}
} 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)
devices[i].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 {
_, err = tx.ExecContext(ctx, "DELETE FROM device_property WHERE device_id=?", record.ID)
if err != nil {
return dbErr(err)
for key, value := range device.UserProperties {
_, err := tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 1)",
record.ID, key, value,
)
if err != nil {
return dbErr(err)
}
}
}
} 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)
}
for key, value := range device.DriverProperties {
j, err := json.Marshal(value)
if err != nil {
// Eh, it'll get filled by the driver anyway
continue
}
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,
)
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)
}
}
}
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]
}
+118
View File
@@ -0,0 +1,118 @@
package mysql
import (
"context"
"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 {
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
}
+82 -11
View File
@@ -2,21 +2,24 @@ 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"`
State DeviceState `json:"state"`
Tags []string `json:"tags"`
}
type DeviceUpdate struct {
@@ -37,6 +40,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 +68,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 +189,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))
}
+7
View File
@@ -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")
+247
View File
@@ -0,0 +1,247 @@
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 = "Plain"
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:
default:
return ErrSceneRoleUnknownPowerMode
}
switch r.Effect {
case SEStatic, SERandom, SEGradient, 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
}
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.
)
// 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
}
+51 -1
View File
@@ -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
}
}
}
+17
View File
@@ -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