diff --git a/app/api/devices.go b/app/api/devices.go index 4555772..1f0f1d3 100644 --- a/app/api/devices.go +++ b/app/api/devices.go @@ -3,11 +3,14 @@ package api import ( "context" "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/app/services/scene" "git.aiterp.net/lucifer/new-server/models" "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" "log" "strconv" "strings" + "time" ) func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) { @@ -71,12 +74,15 @@ func Devices(r gin.IRoutes) { return nil, err } + _ = scene.GlobalManager().UpdateDevice(ctxOf(c), &devices[i], nil) + set[devices[i].ID] = true changed = append(changed, devices[i]) } } - config.PublishChannel <- changed + + config.PublishChannel <- scene.GlobalManager().FilterUnassigned(changed) go func() { for _, device := range changed { @@ -139,9 +145,11 @@ func Devices(r gin.IRoutes) { if err != nil { return nil, err } + + _ = scene.GlobalManager().UpdateDevice(ctxOf(c), &devices[i], nil) } - config.PublishChannel <- devices + config.PublishChannel <- scene.GlobalManager().FilterUnassigned(devices) go func() { for _, device := range devices { @@ -212,4 +220,52 @@ 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 + } + + assignedScene, 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} + + err := scene.GlobalManager().UpdateDevice(ctxOf(c), &devices[i], assignedScene) + if err != nil { + return nil, err + } + } + + eg := errgroup.Group{} + for i := range devices { + eg.Go(func() error { + return config.DeviceRepository().Save(ctxOf(c), &devices[i], 0) + }) + } + + err = eg.Wait() + if err != nil { + return nil, err + } + + return devices, nil + })) +} \ No newline at end of file diff --git a/app/api/scenes.go b/app/api/scenes.go new file mode 100644 index 0000000..9d67ea7 --- /dev/null +++ b/app/api/scenes.go @@ -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 + })) +} \ No newline at end of file diff --git a/app/config/db.go b/app/config/db.go index 9bb75c9..041fe33 100644 --- a/app/config/db.go +++ b/app/config/db.go @@ -24,8 +24,9 @@ func DBX() *sqlx.DB { MySqlSchema(), )) - dbx.SetMaxIdleConns(50) - dbx.SetMaxOpenConns(100) + dbx.SetMaxIdleConns(20) + dbx.SetMaxOpenConns(40) + dbx.SetConnMaxLifetime(0) } return dbx diff --git a/app/config/repo.go b/app/config/repo.go index d92455b..7216df8 100644 --- a/app/config/repo.go +++ b/app/config/repo.go @@ -20,3 +20,7 @@ func DeviceRepository() models.DeviceRepository { func EventHandlerRepository() models.EventHandlerRepository { return &mysql.EventHandlerRepo{DBX: DBX()} } + +func SceneRepository() models.SceneRepository { + return &mysql.SceneRepo{DBX: DBX()} +} diff --git a/app/server.go b/app/server.go index 6aaa75e..73a7028 100644 --- a/app/server.go +++ b/app/server.go @@ -1,10 +1,12 @@ 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/scene" "github.com/gin-gonic/gin" "log" ) @@ -15,6 +17,8 @@ func StartServer() { services.ConnectToBridges() services.CheckNewDevices() + go scene.GlobalManager().Run(context.Background()) + gin.SetMode(gin.ReleaseMode) ginny := gin.New() @@ -25,6 +29,7 @@ func StartServer() { api.DriverKinds(apiGin.Group("/driver-kinds")) api.Events(apiGin.Group("/events")) api.EventHandlers(apiGin.Group("/event-handlers")) + api.Scenes(apiGin.Group("/scenes")) log.Fatal(ginny.Run(fmt.Sprintf("0.0.0.0:%d", config.ServerPort()))) } diff --git a/app/services/events.go b/app/services/events.go index c1bac4d..ae5a869 100644 --- a/app/services/events.go +++ b/app/services/events.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/app/services/scene" "git.aiterp.net/lucifer/new-server/models" "log" "math" @@ -150,21 +151,31 @@ func handleEvent(event models.Event) (responses []models.Event) { } } - config.PublishChannel <- allDevices + config.PublishChannel <- scene.GlobalManager().FilterUnassigned(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 _, device := range allDevices { + go func(device models.Device) { + err := scene.GlobalManager().UpdateDevice(context.Background(), &device, nil) + if err != nil { + log.Println("Failed to update devices' scene in resposne to event:", err) + } + }(device) + } } + for _, handler := range deadHandlers { wg.Add(1) diff --git a/app/services/publish.go b/app/services/publish.go index 7fd9cf9..11c6ed1 100644 --- a/app/services/publish.go +++ b/app/services/publish.go @@ -47,6 +47,11 @@ func StartPublisher() { } if bridge.ID == 0 { log.Println("Unknown bridge") + return + } + + if bridge.Driver == models.DTLIFX { + return } bridge, err := config.BridgeRepository().Find(ctx, devices[0].BridgeID) diff --git a/app/services/scene/manager.go b/app/services/scene/manager.go index 3b2ac4c..e6eeb1f 100644 --- a/app/services/scene/manager.go +++ b/app/services/scene/manager.go @@ -1,27 +1,84 @@ package scene import ( + "context" + "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/models" + "log" "sync" + "time" ) type Manager struct { mu sync.Mutex - assignments map[int]*Scene + allocations map[int]*Scene + devices map[int]*models.Device scenes []*Scene + + notifyCh chan struct{} } -func (mgr *Manager) UpdateDevice(device *models.Device) { +func (mgr *Manager) UpdateDevice(ctx context.Context, device *models.Device, data *models.Scene) error { + mgr.mu.Lock() + defer mgr.mu.Unlock() + + scene, err := mgr.findScene(ctx, device.SceneAssignments, data) + if err != nil { + return err + } + + if mgr.allocations[device.ID] != nil && mgr.allocations[device.ID] != scene { + mgr.allocations[device.ID].RemoveDevice(*device) + } + + if mgr.allocations[device.ID] != scene { + if mgr.allocations == nil { + mgr.allocations = make(map[int]*Scene, 8) + } + + mgr.allocations[device.ID] = scene + if scene != nil { + scene.due = true + } + } + + if scene != nil { + scene.UpsertDevice(*device) + + if mgr.devices == nil { + mgr.devices = make(map[int]*models.Device) + } + mgr.devices[device.ID] = device + } else { + config.PublishChannel <- []models.Device{*device} + } + + if mgr.notifyCh != nil { + close(mgr.notifyCh) + mgr.notifyCh = nil + } + + return nil } -func (mgr *Manager) GetUnassigned(devices []models.Device) []models.Device { +func (mgr *Manager) UpdateScene(data *models.Scene) { + mgr.mu.Lock() + for _, scene := range mgr.scenes { + if scene.data.ID == data.ID { + scene.UpdateScene(scene.data) + } + } + mgr.mu.Unlock() +} + +func (mgr *Manager) FilterUnassigned(devices []models.Device) []models.Device { mgr.mu.Lock() defer mgr.mu.Unlock() res := make([]models.Device, 0, len(devices)) for _, device := range devices { - if _, ok := mgr.assignments[device.ID]; !ok { + if mgr.allocations[device.ID] == nil { res = append(res, device) } } @@ -29,8 +86,153 @@ func (mgr *Manager) GetUnassigned(devices []models.Device) []models.Device { return res } +func (mgr *Manager) Run(ctx context.Context) { + devices, err := config.DeviceRepository().FetchByReference(ctx, models.RKAll, "") + if err == nil { + for _, device := range devices { + _ = mgr.UpdateDevice(ctx, &device, nil) + } + } + + ticker := time.NewTicker(time.Millisecond * 101) + defer ticker.Stop() + + mgr.mu.Lock() + mgr.notifyCh = make(chan struct{}) + mgr.mu.Unlock() + + for { + mgr.mu.Lock() + if mgr.notifyCh == nil { + mgr.notifyCh = make(chan struct{}) + } + notifyCh := mgr.notifyCh + mgr.mu.Unlock() + + select { + case <-ticker.C: + case <-notifyCh: + case <-ctx.Done(): + return + } + + timeout, cancel := context.WithTimeout(ctx, time.Millisecond*100) + + var updates []models.Device + mgr.mu.Lock() + for _, device := range mgr.devices { + if device == nil { + continue + } + + if mgr.reScene(timeout, device) { + updates = append(updates, *device) + } + } + + for _, scene := range mgr.scenes { + if scene.Due() { + scene.Run() + } + + updates = append(updates, scene.Flush()...) + } + mgr.mu.Unlock() + + cancel() + + if len(updates) > 0 { + log.Println("Updating", len(updates), "devices") + config.PublishChannel <- updates + } + } +} + +func (mgr *Manager) reScene(ctx context.Context, device *models.Device) bool { + scene, err := mgr.findScene(ctx, device.SceneAssignments, nil) + if err != nil { + return false + } + + if mgr.allocations[device.ID] != nil && mgr.allocations[device.ID] != scene { + mgr.allocations[device.ID].RemoveDevice(*device) + } + if mgr.allocations[device.ID] != scene { + if mgr.allocations == nil { + mgr.allocations = make(map[int]*Scene, 8) + } + + mgr.allocations[device.ID] = scene + + if scene == nil { + return true + } + + mgr.allocations[device.ID].UpsertDevice(*device) + } + + return false +} + +func (mgr *Manager) findScene(ctx context.Context, assignments []models.DeviceSceneAssignment, data *models.Scene) (*Scene, error) { + if len(assignments) == 0 { + return nil, nil + } + + var selected *models.DeviceSceneAssignment + for _, assignment := range assignments { + pos := int64(time.Since(assignment.StartTime) / time.Millisecond) + if assignment.DurationMS == 0 || (pos >= -1 && pos < assignment.DurationMS) { + selected = &assignment + } + } + if selected == nil { + return nil, nil + } + + for _, scene := range mgr.scenes { + if scene.group == selected.Group && scene.startTime.Equal(selected.StartTime) && selected.DurationMS == scene.duration.Milliseconds() { + return scene, nil + } + } + + if data == nil { + for i := range mgr.scenes { + if mgr.scenes[i].data.ID == selected.SceneID { + data = &mgr.scenes[i].data + break + } + } + } + + if data == nil { + var err error + data, err = config.SceneRepository().Find(ctx, selected.SceneID) + if err != nil { + return nil, err + } + } + + scene := &Scene{ + startTime: selected.StartTime, + duration: time.Duration(selected.DurationMS) * time.Millisecond, + lastRun: 0, + due: true, + group: selected.Group, + data: *data, + roleList: make([][]models.Device, len(data.Roles)), + deviceMap: make(map[int]*models.Device, 8), + roleMap: make(map[int]int, 8), + changes: make(map[int]models.NewDeviceState, 8), + } + + mgr.scenes = append(mgr.scenes, scene) + + return scene, nil +} + var globalManager = &Manager{} -func Get() *Manager { +func GlobalManager() *Manager { return globalManager } diff --git a/app/services/scene/scene.go b/app/services/scene/scene.go index 17bb28c..87d3fb0 100644 --- a/app/services/scene/scene.go +++ b/app/services/scene/scene.go @@ -1,14 +1,19 @@ package scene import ( + "context" + "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/models" + "math" "time" ) type Scene struct { startTime time.Time duration time.Duration - tag string + lastRun int64 + due bool + group string data models.Scene roleList [][]models.Device deviceMap map[int]*models.Device @@ -21,32 +26,110 @@ func (s *Scene) UpdateScene(scene models.Scene) { s.roleMap = make(map[int]int) for _, device := range s.deviceMap { - s.moveDevice(*device) + s.moveDevice(*device, false) } + + for i, role := range s.data.Roles { + role.ApplyOrder(s.roleList[i]) + } + + s.Run() } func (s *Scene) UpsertDevice(device models.Device) { s.deviceMap[device.ID] = &device - s.moveDevice(device) + s.moveDevice(device, true) } func (s *Scene) RemoveDevice(device models.Device) { + roleIndex, hasRole := s.roleMap[device.ID] + delete(s.deviceMap, device.ID) delete(s.roleMap, device.ID) + + if hasRole { + s.runRole(roleIndex) + } +} + +func (s *Scene) Due() bool { + return s.due || s.intervalNumber() != s.lastRun +} + +func (s *Scene) Flush() []models.Device { + res := make([]models.Device, 0, 8) + for key, change := range s.changes { + if s.deviceMap[key] != nil { + device := *s.deviceMap[key] + err := device.SetState(change) + if err != nil { + continue + } + + res = append(res, device) + } + + delete(s.changes, key) + } + + return res +} + +func (s *Scene) Run() { + s.lastRun = s.intervalNumber() + s.due = false + + for index := range s.roleList { + s.runRole(index) + } } func (s *Scene) runRole(index int) { + role := s.data.Roles[index] + devices := s.roleList[index] + + intervalNumber := s.intervalNumber() + intervalMax := s.intervalMax() + + for i, device := range devices { + newState, changed := role.ApplyEffect(&device, models.SceneRunContext{ + Index: i, + Length: len(devices), + IntervalNumber: intervalNumber, + IntervalMax: intervalMax, + }) + + if !changed { + go func() { + _ = config.DeviceRepository().Save(context.Background(), &device, models.SMState) + }() + } + s.changes[device.ID] = newState + } } func (s *Scene) moveDevice(device models.Device, run bool) { oldRole, hasOldRole := s.roleMap[device.ID] newRole := s.data.RoleIndex(&device) + // If it doesn't move between roles, just update and reorder it. if hasOldRole && oldRole == newRole { + for i, device2 := range s.roleList[newRole] { + if device2.ID == device.ID { + s.roleList[newRole][i] = device + } + } + + if run { + s.data.Roles[newRole].ApplyOrder(s.roleList[newRole]) + s.runRole(newRole) + } + return } + // Take it out of the old role. if hasOldRole { for i, device2 := range s.roleList[oldRole] { if device2.ID == device.ID { @@ -55,33 +138,38 @@ func (s *Scene) moveDevice(device models.Device, run bool) { } } - s.runRole(oldRole) + if run { + s.runRole(oldRole) + } } - s.roleMap[device.ID] = newRole - s.runRole(newRole) + if newRole != -1 { + s.roleMap[device.ID] = newRole + s.roleList[newRole] = append(s.roleList[newRole], device) + + if run { + s.data.Roles[newRole].ApplyOrder(s.roleList[newRole]) + s.runRole(newRole) + } + } + + s.due = true } func (s *Scene) intervalNumber() int64 { intervalDuration := time.Duration(s.data.IntervalMS) * time.Millisecond + if intervalDuration == 0 { + return 0 + } - return int64(time.Since(s.startTime) / intervalDuration) + return int64(math.Round(float64(time.Since(s.startTime)) / float64(intervalDuration))) } -func (s *Scene) intervalFac() float64 { - return clamp(float64(time.Since(s.startTime)) / float64(s.duration)) -} +func (s *Scene) intervalMax() int64 { + intervalDuration := time.Duration(s.data.IntervalMS) * time.Millisecond + if intervalDuration == 0 { + return 1 + } -func (s *Scene) positionFac(i, len int) float64 { - return clamp(float64(i) / float64(len)) + return int64(s.duration / intervalDuration) } - -func clamp(fac float64) float64 { - if fac > 1.00 { - return 1.00 - } else if fac < 0.00 { - return 0.00 - } else { - return fac - } -} \ No newline at end of file diff --git a/internal/mysql/devicerepo.go b/internal/mysql/devicerepo.go index 1d6e373..9932a46 100644 --- a/internal/mysql/devicerepo.go +++ b/internal/mysql/devicerepo.go @@ -12,13 +12,14 @@ import ( ) type deviceRecord struct { - ID int `db:"id"` - BridgeID int `db:"bridge_id"` - InternalID string `db:"internal_id"` - Icon string `db:"icon"` - Name string `db:"name"` - Capabilities string `db:"capabilities"` - ButtonNames string `db:"button_names"` + ID int `db:"id"` + BridgeID int `db:"bridge_id"` + InternalID string `db:"internal_id"` + Icon string `db:"icon"` + Name string `db:"name"` + Capabilities string `db:"capabilities"` + ButtonNames string `db:"button_names"` + SceneAssignmentJSON json.RawMessage `db:"scene_assignments"` } type deviceStateRecord struct { @@ -117,25 +118,32 @@ 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, @@ -144,98 +152,111 @@ func (r *DeviceRepo) Save(ctx context.Context, device *models.Device, mode model button_names = :button_names WHERE id=:id `, record) - if err != nil { - return dbErr(err) - } - - // Let's just be lazy for now, optimize later if need be. - if mode == 0 || mode&models.SMTags != 0 { - _, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID) if err != nil { return dbErr(err) } - } - if mode == 0 || mode&models.SMProperties != 0 { - _, err = tx.ExecContext(ctx, "DELETE FROM device_property WHERE device_id=?", record.ID) - if err != nil { - return dbErr(err) + // Let's just be lazy for now, optimize later if need be. + if mode == 0 || mode&models.SMTags != 0 { + _, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID) + if err != nil { + return dbErr(err) + } } - } - } else { - res, err := tx.NamedExecContext(ctx, ` + + if mode == 0 || mode&models.SMProperties != 0 { + _, err = tx.ExecContext(ctx, "DELETE FROM device_property WHERE device_id=?", record.ID) + if err != nil { + return dbErr(err) + } + } + } else { + res, err := tx.NamedExecContext(ctx, ` INSERT INTO device (bridge_id, internal_id, icon, name, capabilities, button_names) VALUES (:bridge_id, :internal_id, :icon, :name, :capabilities, :button_names) `, record) - if err != nil { - return dbErr(err) - } - - lastID, err := res.LastInsertId() - if err != nil { - return dbErr(err) - } - - record.ID = int(lastID) - device.ID = int(lastID) - } - - if mode == 0 || mode&models.SMTags != 0 { - for _, tag := range device.Tags { - _, err := tx.ExecContext(ctx, "INSERT INTO device_tag (device_id, tag_name) VALUES (?, ?)", record.ID, tag) if err != nil { return dbErr(err) } - } - } - if mode == 0 || mode&models.SMProperties != 0 { - for key, value := range device.UserProperties { - _, err := tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 1)", - record.ID, key, value, - ) + lastID, err := res.LastInsertId() if err != nil { return dbErr(err) } + + record.ID = int(lastID) + devices[i].ID = int(lastID) } - for key, value := range device.DriverProperties { - j, err := json.Marshal(value) - if err != nil { - // Eh, it'll get filled by the driver anyway - continue + if mode == 0 || mode&models.SMTags != 0 { + for _, tag := range device.Tags { + _, err := tx.ExecContext(ctx, "INSERT INTO device_tag (device_id, tag_name) VALUES (?, ?)", record.ID, tag) + if err != nil { + return dbErr(err) + } } + } - _, err = tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 0)", - record.ID, key, string(j), - ) - if err != nil { - // Return err here anyway, it might put the tx in a bad state to ignore it. - return dbErr(err) + if mode == 0 || mode&models.SMProperties != 0 { + for key, value := range device.UserProperties { + _, err := tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 1)", + record.ID, key, value, + ) + if err != nil { + return dbErr(err) + } + } + + for key, value := range device.DriverProperties { + j, err := json.Marshal(value) + if err != nil { + // Eh, it'll get filled by the driver anyway + continue + } + + _, err = tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 0)", + record.ID, key, string(j), + ) + if err != nil { + // Return err here anyway, it might put the tx in a bad state to ignore it. + return dbErr(err) + } } } - } - if mode == 0 || mode&models.SMState != 0 { - _, err = tx.NamedExecContext(ctx, ` + if mode == 0 || mode&models.SMState != 0 { + _, err = tx.NamedExecContext(ctx, ` REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity) VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity) - `, deviceStateRecord{ - DeviceID: record.ID, - Hue: device.State.Color.Hue, - Saturation: device.State.Color.Saturation, - Kelvin: device.State.Color.Kelvin, - Power: device.State.Power, - Intensity: device.State.Intensity, - }) - if err != nil { - return dbErr(err) + `, deviceStateRecord{ + DeviceID: record.ID, + Hue: device.State.Color.Hue, + Saturation: device.State.Color.Saturation, + Kelvin: device.State.Color.Kelvin, + Power: device.State.Power, + Intensity: device.State.Intensity, + }) + if err != nil { + return dbErr(err) + } } } return tx.Commit() } +func (r *DeviceRepo) Save(ctx context.Context, device *models.Device, mode models.SaveMode) error { + devices := []models.Device{*device} + + err := r.SaveMany(ctx, mode, devices) + if err != nil { + return err + } + + *device = devices[0] + return nil +} + func (r *DeviceRepo) Delete(ctx context.Context, device *models.Device) error { _, err := r.DBX.ExecContext(ctx, "DELETE FROM device WHERE Id=?", device.ID) if err != nil { @@ -328,12 +349,15 @@ func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]mo InternalID: record.InternalID, Icon: record.Icon, Name: record.Name, + SceneAssignments: make([]models.DeviceSceneAssignment, 0, 4), ButtonNames: strings.Split(record.ButtonNames, ","), DriverProperties: make(map[string]interface{}, 8), UserProperties: make(map[string]string, 8), Tags: make([]string, 0, 8), } + _ = json.Unmarshal(record.SceneAssignmentJSON, &device.SceneAssignments) + if device.ButtonNames[0] == "" { device.ButtonNames = device.ButtonNames[:0] } diff --git a/internal/mysql/scenerepo.go b/internal/mysql/scenerepo.go new file mode 100644 index 0000000..7c27791 --- /dev/null +++ b/internal/mysql/scenerepo.go @@ -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 +} diff --git a/models/device.go b/models/device.go index ab032f6..0ee9463 100644 --- a/models/device.go +++ b/models/device.go @@ -7,17 +7,18 @@ import ( ) 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 { @@ -66,6 +67,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 } diff --git a/models/scene.go b/models/scene.go index c2d5a29..37edfc3 100644 --- a/models/scene.go +++ b/models/scene.go @@ -1,10 +1,12 @@ package models import ( + "context" "math" "math/rand" "sort" "strings" + "time" ) type Scene struct { @@ -54,15 +56,16 @@ func (s *Scene) RoleIndex(device *Device) int { type SceneEffect string const ( - SEStatic SceneEffect = "Plain" - SERandom SceneEffect = "Random" - SEGradient SceneEffect = "Gradient" - SETransition SceneEffect = "Transition" + 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:"overridePower"` + PowerMode ScenePowerMode `json:"powerMode"` TargetKind ReferenceKind `json:"targetKind"` TargetValue string `json:"targetValue"` Interpolate bool `json:"interpolate"` @@ -70,6 +73,34 @@ type SceneRole struct { States []NewDeviceState `json:"states"` } +type SceneRunContext struct { + Index int + Length int + IntervalNumber int64 + IntervalMax int64 +} + +func (d *SceneRunContext) PositionFacShifted() float64 { + shiftFac := float64((int64(d.Index)+d.IntervalNumber)%int64(d.Length)) * (1 / 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 @@ -134,16 +165,18 @@ func (r *SceneRole) ApplyOrder(devices []Device) { } } -func (r *SceneRole) ApplyEffect(device *Device, intervalFac float64, positionFac float64) (newState NewDeviceState, deviceChanged bool) { +func (r *SceneRole) ApplyEffect(device *Device, c SceneRunContext) (newState NewDeviceState, deviceChanged bool) { switch r.Effect { case SEStatic: newState = r.States[0] case SERandom: newState = r.State(rand.Float64()) case SEGradient: - newState = r.State(positionFac) + newState = r.State(c.PositionFac()) + case SEWalkingGradient: + newState = r.State(c.PositionFacShifted()) case SETransition: - newState = r.State(intervalFac) + newState = r.State(c.IntervalFac()) } switch r.PowerMode { @@ -196,9 +229,17 @@ const ( SPOverride ScenePowerMode = "Override" // Same as above, but Scene state set Device state' power. This is good for "wake up" scenes. ) -type SceneAssignment struct { - Scene *Scene - Device *Device - Tag string +// DeviceSceneAssignment is an entry on the device stack. +type DeviceSceneAssignment struct { + SceneID int `json:"sceneId"` + Group string `json:"group"` + StartTime time.Time `json:"start"` + DurationMS int64 `json:"durationMs"` } +type SceneRepository interface { + Find(ctx context.Context, id int) (*Scene, error) + FetchAll(ctx context.Context) ([]Scene, error) + Save(ctx context.Context, bridge *Scene) error + Delete(ctx context.Context, bridge *Scene) error +} diff --git a/scripts/20210926135923_scene.sql b/scripts/20210926135923_scene.sql new file mode 100644 index 0000000..de23be8 --- /dev/null +++ b/scripts/20210926135923_scene.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE scene +( + id INT NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + interval_ms INT NOT NULL, + roles JSON NOT NULL, + + PRIMARY KEY (id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE scene; +-- +goose StatementEnd diff --git a/scripts/20210926152011_device_sceneassignment.sql b/scripts/20210926152011_device_sceneassignment.sql new file mode 100644 index 0000000..433c2d0 --- /dev/null +++ b/scripts/20210926152011_device_sceneassignment.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE device ADD COLUMN scene_assignments JSON NOT NULL DEFAULT ('[]'); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE device DROP COLUMN IF EXISTS scene_assignments; +-- +goose StatementEnd