26 Commits
Author SHA1 Message Date
gisle 6947fc3461 add delete light endpoint and forget for Hue.
continuous-integration/drone/push Build is passing
2022-01-31 23:09:00 +01:00
gisle 600c90a614 undo group nonsense in hue bridge.
continuous-integration/drone/push Build is passing
2022-01-28 23:01:44 +01:00
Stian Fredrik Aune a08685c3cd Location, location, location.
continuous-integration/drone Build is passing
2022-01-21 19:26:06 +01:00
Stian Fredrik Aune 15536c6078 lucy factory - sensor support 2022-01-20 22:07:04 +01:00
Stian Fredrik Aune 2bb2154e5b SceneDimmer bug 2022-01-12 17:23:56 +01:00
Stian Fredrik Aune 8c2ef2dac3 factory command 2022-01-12 17:01:56 +01:00
Stian Fredrik Aune f4e584938b enable 'lucy handler delete all' 2022-01-08 21:02:35 +01:00
Stian Fredrik Aune 8c9b47aa1d Add temperature to API.
continuous-integration/drone/push Build is passing
2021-11-07 19:03:42 +01:00
Stian Fredrik Aune 5bd7a7f6b6 cleanup and fix
continuous-integration/drone/push Build is passing
2021-11-07 18:56:21 +01:00
Stian Fredrik Aune 88bd4dc073 Mill. Take 1.
continuous-integration/drone/push Build is failing
2021-11-07 18:38:32 +01:00
gisle 6443e0ecd3 any/all.scene=-1 now checks for no scene.
continuous-integration/drone/push Build is passing
2021-10-31 09:51:06 +01:00
gisle 147231e4fd add sensor scenes for Motion and Temperature.
continuous-integration/drone/push Build is passing
2021-10-30 14:38:14 +02:00
gisle b191455753 optimize Hue light updates by using temporary groups.
continuous-integration/drone/push Build is passing
2021-10-30 12:05:56 +02:00
gisle ba22187e6e feature-scenesystem (#1)
continuous-integration/drone/push Build is passing
Co-authored-by: Gisle Aune <dev@gisle.me>
Reviewed-on: #1
Co-authored-by: gisle <gisle@hidden-email>
Co-committed-by: gisle <gisle@hidden-email>
2021-10-06 18:13:16 +00:00
Stian Fredrik Aune d91bd472dd Merge branch 'asmodeus' of git.aiterp.net:lucifer/new-server into asmodeus
continuous-integration/drone/push Build is failing
continuous-integration/drone Build is passing
continuous-integration/drone/tag Build is passing
2021-10-03 19:10:06 +02:00
Stian Fredrik Aune 251687b85d Edit. 2021-10-03 19:10:02 +02:00
gisle ade49589a9 add another test and fix local.
continuous-integration/drone/push Build is failing
2021-10-03 18:35:04 +02:00
gisle ad2f912a6f change presence events, fix non-eq conditions.
continuous-integration/drone/push Build is failing
2021-10-02 17:34:44 +02:00
gisle 2abb4b1024 add time-of-day limitation to events.
continuous-integration/drone/push Build is failing
2021-10-02 14:43:15 +02:00
gisle b83673b2cc change nanoleaf default names to start at 1 instead of 0.
continuous-integration/drone/push Build is failing
2021-09-30 23:08:33 +02:00
gisle 6fe0d8b864 adapt Nanoleaf driver to Canvas.
continuous-integration/drone/push Build is failing
2021-09-30 23:07:11 +02:00
Stian Fredrik Aune c7d7e264ca Lucy handler add 2021-09-28 21:54:03 +02:00
gisle 0cd325e8db add SaveMode for device which can be 0 (All) or a bitmask for what to save. It did not improve performance
continuous-integration/drone/push Build is passing
2021-09-26 10:50:54 +02:00
Stian Fredrik Aune b1fa52e53f lucy handler delete
continuous-integration/drone/push Build is passing
2021-09-25 19:03:11 +02:00
Stian Fredrik Aune b28496fd6b less focus on oneshot
continuous-integration/drone/push Build is passing
2021-09-25 17:27:56 +02:00
Stian Fredrik Aune 35c3a387c9 lucy handler list
continuous-integration/drone/push Build is passing
2021-09-25 17:26:27 +02:00
69 changed files with 4094 additions and 322 deletions
-9
View File
@@ -4,15 +4,6 @@ kind: pipeline
type: docker
steps:
- name: go-build
image: golang:1.17
commands:
- go test -v ./...
when:
event:
exclude:
- tag
- name: go-test
image: golang:1.17
commands:
+1 -1
View File
@@ -52,7 +52,7 @@ func Bridges(r gin.IRoutes) {
return []models.Bridge{bridge}, nil
}
bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.DryRun)
bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.Token, body.DryRun)
if err != nil {
return nil, err
}
+141 -37
View File
@@ -3,41 +3,36 @@ package api
import (
"context"
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/app/services/publisher"
"git.aiterp.net/lucifer/new-server/models"
"github.com/gin-gonic/gin"
"log"
"strconv"
"strings"
"time"
)
func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) {
if strings.HasPrefix(fetchStr, "tag:") {
return config.DeviceRepository().FetchByReference(ctx, models.RKTag, fetchStr[4:])
} else if strings.HasPrefix(fetchStr, "bridge:") {
return config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, fetchStr[7:])
} else if strings.HasPrefix(fetchStr, "id:") {
return config.DeviceRepository().FetchByReference(ctx, models.RKDeviceID, fetchStr[7:])
} else if strings.HasPrefix(fetchStr, "name:") {
return config.DeviceRepository().FetchByReference(ctx, models.RKName, fetchStr[7:])
}else if fetchStr == "all" {
return config.DeviceRepository().FetchByReference(ctx, models.RKAll, "")
} else {
_, err := strconv.Atoi(fetchStr)
if err != nil {
return config.DeviceRepository().FetchByReference(ctx, models.RKName, fetchStr)
}
return config.DeviceRepository().FetchByReference(ctx, models.RKDeviceID, fetchStr)
}
kind, value := models.ParseFetchString(fetchStr)
return config.DeviceRepository().FetchByReference(ctx, kind, value)
}
func Devices(r gin.IRoutes) {
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
return config.DeviceRepository().FetchByReference(ctxOf(c), models.RKAll, "")
devices, err := config.DeviceRepository().FetchByReference(ctxOf(c), models.RKAll, "")
if err != nil {
return nil, err
}
return withSceneState(devices), nil
}))
r.GET("/:fetch", handler(func(c *gin.Context) (interface{}, error) {
return fetchDevices(ctxOf(c), c.Param("fetch"))
devices, err := fetchDevices(ctxOf(c), c.Param("fetch"))
if err != nil {
return nil, err
}
return withSceneState(devices), nil
}))
r.PUT("", handler(func(c *gin.Context) (interface{}, error) {
@@ -80,7 +75,7 @@ func Devices(r gin.IRoutes) {
go func() {
for _, device := range changed {
err := config.DeviceRepository().Save(context.Background(), &device)
err := config.DeviceRepository().Save(context.Background(), &device, models.SMState)
if err != nil {
log.Println("Failed to save device for state:", err)
continue
@@ -88,7 +83,7 @@ func Devices(r gin.IRoutes) {
}
}()
return changed, nil
return withSceneState(changed), nil
}))
r.PUT("/:fetch", handler(func(c *gin.Context) (interface{}, error) {
@@ -109,14 +104,14 @@ func Devices(r gin.IRoutes) {
for i := range devices {
devices[i].ApplyUpdate(update)
err := config.DeviceRepository().Save(context.Background(), &devices[i])
err := config.DeviceRepository().Save(context.Background(), &devices[i], models.SMProperties)
if err != nil {
log.Println("Failed to save device for state:", err)
continue
}
}
return devices, nil
return withSceneState(devices), nil
}))
r.PUT("/:fetch/state", handler(func(c *gin.Context) (interface{}, error) {
@@ -143,17 +138,12 @@ func Devices(r gin.IRoutes) {
config.PublishChannel <- devices
go func() {
for _, device := range devices {
err := config.DeviceRepository().Save(context.Background(), &device)
if err != nil {
log.Println("Failed to save device for state:", err)
continue
}
}
}()
err = config.DeviceRepository().SaveMany(ctxOf(c), models.SMState, devices)
if err != nil {
log.Println("Failed to save devices states")
}
return devices, nil
return withSceneState(devices), nil
}))
r.PUT("/:fetch/tags", handler(func(c *gin.Context) (interface{}, error) {
@@ -204,12 +194,126 @@ func Devices(r gin.IRoutes) {
device.Tags = append(device.Tags[:index], device.Tags[index+1:]...)
}
err = config.DeviceRepository().Save(ctxOf(c), device)
err = config.DeviceRepository().Save(ctxOf(c), device, models.SMTags)
if err != nil {
return nil, err
}
}
return devices, nil
return withSceneState(devices), nil
}))
r.PUT("/:fetch/scene", handler(func(c *gin.Context) (interface{}, error) {
var body models.DeviceSceneAssignment
err := parseBody(c, &body)
if err != nil {
return nil, err
}
devices, err := fetchDevices(ctxOf(c), c.Param("fetch"))
if err != nil {
return nil, err
}
if len(devices) == 0 {
return []models.Device{}, nil
}
_, err = config.SceneRepository().Find(ctxOf(c), body.SceneID)
if err != nil {
return nil, err
}
if body.DurationMS < 0 {
body.DurationMS = 0
}
body.StartTime = time.Now()
pushMode := c.Query("push") == "true"
for i := range devices {
if pushMode {
devices[i].SceneAssignments = append(devices[i].SceneAssignments, body)
} else {
devices[i].SceneAssignments = []models.DeviceSceneAssignment{body}
}
}
config.PublishChannel <- devices
err = config.DeviceRepository().SaveMany(ctxOf(c), 0, devices)
if err != nil {
return nil, err
}
return withSceneState(devices), nil
}))
r.DELETE("/:fetch/scene", handler(func(c *gin.Context) (interface{}, error) {
devices, err := fetchDevices(ctxOf(c), c.Param("fetch"))
if err != nil {
return nil, err
}
if len(devices) == 0 {
return []models.Device{}, nil
}
for i := range devices {
devices[i].SceneAssignments = []models.DeviceSceneAssignment{}
}
config.PublishChannel <- devices
err = config.DeviceRepository().SaveMany(ctxOf(c), 0, devices)
if err != nil {
return nil, err
}
return withSceneState(devices), nil
}))
r.DELETE("/:fetch", handler(func(c *gin.Context) (interface{}, error) {
id, err := strconv.Atoi(c.Param("fetch"))
if err != nil {
return nil, err
}
device, err := config.DeviceRepository().Find(ctxOf(c), id)
if err != nil {
return nil, err
}
if c.Query("forgetmenot") != "true" {
bridge, err := config.BridgeRepository().Find(ctxOf(c), device.BridgeID)
if err != nil {
return nil, err
}
driver, err := config.DriverProvider().Provide(bridge.Driver)
if err != nil {
return nil, err
}
forgettableDriver, ok := driver.(models.ForgettableDriver)
if !ok {
return nil, models.ErrCannotForget
}
err = forgettableDriver.ForgetDevice(ctxOf(c), bridge, *device)
if err != nil {
return nil, err
}
}
err = config.DeviceRepository().Delete(ctxOf(c), device)
if err != nil {
return nil, err
}
return device, nil
}))
}
func withSceneState(devices []models.Device) []models.Device {
res := make([]models.Device, 0, len(devices))
for _, device := range devices {
device.SceneState = publisher.Global().SceneState(device.ID)
res = append(res, device)
}
return res
}
+17 -6
View File
@@ -35,17 +35,28 @@ func EventHandlers(r gin.IRoutes) {
}))
r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) {
var body models.EventHandlerUpdate
err := parseBody(c, &body)
if err != nil {
return nil, err
}
handler, err := config.EventHandlerRepository().FindByID(ctxOf(c), intParam(c, "id"))
if err != nil {
return nil, err
}
handler.ApplyUpdate(body)
if c.Query("hard") == "true" {
var body models.EventHandler
err := parseBody(c, &body)
if err != nil || body.ID != intParam(c, "id") {
return nil, err
}
handler = &body
} else {
var body models.EventHandlerUpdate
err := parseBody(c, &body)
if err != nil {
return nil, err
}
handler.ApplyUpdate(body)
}
err = config.EventHandlerRepository().Save(ctxOf(c), handler)
if err != nil {
+68
View File
@@ -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
}))
}
+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 {
+25
View File
@@ -60,6 +60,31 @@ func (client *Client) PutDeviceTags(ctx context.Context, fetchStr string, addTag
return devices, nil
}
func (client *Client) AssignDevice(ctx context.Context, fetchStr string, push bool, assignment models.DeviceSceneAssignment) ([]models.Device, error) {
query := ""
if push {
query = "?push=true"
}
devices := make([]models.Device, 0, 16)
err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr+"/scene"+query, &devices, assignment)
if err != nil {
return nil, err
}
return devices, nil
}
func (client *Client) ClearDevice(ctx context.Context, fetchStr string) ([]models.Device, error) {
devices := make([]models.Device, 0, 16)
err := client.Fetch(ctx, "DELETE", "/api/devices/"+fetchStr+"/scene", &devices, nil)
if err != nil {
return nil, err
}
return devices, nil
}
func (client *Client) FireEvent(ctx context.Context, event models.Event) error {
err := client.Fetch(ctx, "POST", "/api/events", nil, event)
if err != nil {
+57
View File
@@ -0,0 +1,57 @@
package client
import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
)
func (client *Client) GetHandlers(ctx context.Context) ([]models.EventHandler, error) {
handlers := make([]models.EventHandler, 0, 16)
err := client.Fetch(ctx, "GET", "/api/event-handlers", &handlers, nil)
if err != nil {
return nil, err
}
return handlers, nil
}
func (client *Client) GetHandler(ctx context.Context, id int) (*models.EventHandler, error) {
var handler models.EventHandler
err := client.Fetch(ctx, "GET", fmt.Sprintf("/api/event-handlers/%d", id), &handler, nil)
if err != nil {
return nil, err
}
return &handler, nil
}
func (client *Client) PutHandler(ctx context.Context, handler *models.EventHandler) (*models.EventHandler, error) {
var response models.EventHandler
err := client.Fetch(ctx, "PUT", fmt.Sprintf("/api/event-handlers/%d?hard=true", handler.ID), &response, handler)
if err != nil {
return nil, err
}
return &response, nil
}
func (client *Client) AddHandler(ctx context.Context, handler models.EventHandler) (*models.EventHandler, error) {
var result models.EventHandler
err := client.Fetch(ctx, "POST", "/api/event-handlers", &result, handler)
if err != nil {
return nil, err
}
return &result, nil
}
func (client *Client) DeleteHandler(ctx context.Context, id int) (*models.EventHandler, error) {
var handler models.EventHandler
err := client.Fetch(ctx, "DELETE", fmt.Sprintf("/api/event-handlers/%d", id), &handler, nil)
if err != nil {
return nil, err
}
return &handler, nil
}
+16
View File
@@ -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
}
+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
+2
View File
@@ -4,6 +4,7 @@ import (
"git.aiterp.net/lucifer/new-server/internal/drivers"
"git.aiterp.net/lucifer/new-server/internal/drivers/hue"
"git.aiterp.net/lucifer/new-server/internal/drivers/lifx"
"git.aiterp.net/lucifer/new-server/internal/drivers/mill"
"git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf"
"git.aiterp.net/lucifer/new-server/models"
"sync"
@@ -18,6 +19,7 @@ func DriverProvider() models.DriverProvider {
models.DTNanoLeaf: &nanoleaf.Driver{},
models.DTHue: &hue.Driver{},
models.DTLIFX: &lifx.Driver{},
models.DTMill: &mill.Driver{},
}
})
+18 -1
View File
@@ -1,17 +1,34 @@
package config
import (
"log"
"os"
"strconv"
"time"
)
func Location() *time.Location {
env, ok := os.LookupEnv("LUCIFER_LOCATION")
if !ok {
return time.UTC
}
loc, err := time.LoadLocation(env)
if err != nil {
log.Printf("Location lookup failed: %s (%s)", env, err.Error())
return time.UTC
}
return loc
}
func MySqlHost() string { return strEnv("LUCIFER_MYSQL_HOST") }
func MySqlPort() int { return intEnv("LUCIFER_MYSQL_PORT") }
func MySqlSchema() string { return strEnv("LUCIFER_MYSQL_SCHEMA") }
func MySqlUsername() string { return strEnv("LUCIFER_MYSQL_USERNAME") }
func MySQlPassword() string { return strEnv("LUCIFER_MYSQL_PASSWORD") }
func ServerPort() int { return intEnv("LUCIFER_SERVER_PORT") }
func ServerPort() int { return intEnv("LUCIFER_SERVER_PORT") }
func strEnv(key string) string {
env, ok := os.LookupEnv(key)
+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()}
}
+16 -1
View File
@@ -1,17 +1,29 @@
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"
"git.aiterp.net/lucifer/new-server/models"
"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 +37,9 @@ func StartServer() {
api.DriverKinds(apiGin.Group("/driver-kinds"))
api.Events(apiGin.Group("/events"))
api.EventHandlers(apiGin.Group("/event-handlers"))
api.Scenes(apiGin.Group("/scenes"))
models.TimeOfDayTimeZone = config.Location()
log.Fatal(ginny.Run(fmt.Sprintf("0.0.0.0:%d", config.ServerPort())))
}
-10
View File
@@ -5,7 +5,6 @@ import (
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/models"
"log"
"strconv"
"sync"
"time"
)
@@ -53,15 +52,6 @@ func runConnectToBridges() error {
log.Printf("Running bridge \"%s\" (%d)", bridge.Name, bridge.ID)
go func(bridge models.Bridge, cancel func()) {
savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID))
if err != nil {
log.Println("Failed to fetch devices from db for refresh:", err)
}
err = driver.Publish(ctx, bridge, savedDevices)
if err != nil {
log.Println("Failed to publish devices from db before run:", err)
}
err = driver.Run(ctx, bridge, config.EventChannel)
log.Printf("Bridge \"%s\" (%d) stopped: %s", bridge.Name, bridge.ID, err)
+48 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/app/services/publisher"
"git.aiterp.net/lucifer/new-server/models"
"log"
"math"
@@ -66,7 +67,6 @@ func handleEvent(event models.Event) (responses []models.Event) {
err := handleSpecial(event)
if err != nil {
log.Printf("Special event handler error (%s): %v", event.Name, err)
return
}
paramStrings := make([]string, 0, 8)
@@ -93,6 +93,9 @@ func handleEvent(event models.Event) (responses []models.Event) {
if handler.EventName != event.Name {
continue
}
if !handler.IsWithinTimeRange() {
continue
}
devices, err := config.DeviceRepository().FetchByReference(ctx, handler.TargetKind, handler.TargetValue)
if err != nil {
@@ -144,27 +147,41 @@ func handleEvent(event models.Event) (responses []models.Event) {
newState.Intensity = &newIntensity
}
if action.SetTemperature != nil {
newState.Temperature = action.SetTemperature
}
err = allDevices[i].SetState(newState)
if err != nil {
log.Println("Error updating state for device", device.ID, "err:", err)
}
if action.SetScene != nil {
action.SetScene.StartTime = time.Now()
allDevices[i].SceneAssignments = []models.DeviceSceneAssignment{*action.SetScene}
}
if action.PushScene != nil {
action.PushScene.StartTime = time.Now()
allDevices[i].SceneAssignments = append(allDevices[i].SceneAssignments, *action.PushScene)
}
}
config.PublishChannel <- allDevices
wg := sync.WaitGroup{}
for _, device := range allDevices {
wg.Add(1)
go func(device models.Device) {
err := config.DeviceRepository().Save(context.Background(), &device)
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)
@@ -203,7 +220,30 @@ func handleSpecial(event models.Event) error {
}
return driver.Publish(ctx, bridge, devices)
case models.ENSensorPresenceStarted, models.ENSensorPresenceEnded, models.ENSensorTemperature:
id, _ := strconv.Atoi(event.Payload["deviceId"])
updateTime, _ := strconv.ParseInt(event.Payload["lastUpdated"], 10, 64)
state := models.SceneSensor{
ID: id,
UpdateTime: time.Unix(updateTime, 0),
}
switch event.Name {
case models.ENSensorPresenceStarted:
presence := true
state.Presence = &presence
case models.ENSensorPresenceEnded:
presence := false
state.Presence = &presence
case models.ENSensorTemperature:
temperature, _ := strconv.ParseFloat(event.Payload["temperature"], 64)
state.Temperature = &temperature
}
publisher.Global().UpdateSensor(state)
return nil
default:
return nil
}
}
}
+62
View File
@@ -0,0 +1,62 @@
package handlerfactory
import (
"git.aiterp.net/lucifer/new-server/models"
"gopkg.in/yaml.v2"
"io"
"io/ioutil"
"strings"
)
type FactoryConfig struct {
Mode FCMOde `yaml:"mode"`
Target string `yaml:"target"`
SceneNames []string `yaml:"scenes"`
TriggerDeviceID string `yaml:"trigger_device_id"`
After []After `yaml:"after"`
From string `yaml:"from"`
To string `yaml:"to"`
}
type FCMOde string
var (
SceneDimmer FCMOde = "SceneDimmer"
MotionSensor FCMOde = "MotionSensor"
)
type After struct {
Minutes int `yaml:"minutes"`
NewState models.NewDeviceState `yaml:"new_state"`
}
func ParseConfigs(reader io.Reader) ([]FactoryConfig, error) {
readAll, err := ioutil.ReadAll(reader)
if err != nil {
return nil, err
}
splits := strings.Split(string(readAll), "\n---\n")
configs := make([]FactoryConfig, 0, 8)
for _, split := range splits {
var factoryConfig FactoryConfig
err := yaml.NewDecoder(strings.NewReader(split)).Decode(&factoryConfig)
if err != nil {
return nil, err
}
configs = append(configs, factoryConfig)
}
return configs, nil
}
func (f *FactoryConfig) FromTime() models.TimeOfDay {
tod, _ := models.ParseTimeOfDay(f.From)
return tod
}
func (f *FactoryConfig) ToTime() models.TimeOfDay {
tod, _ := models.ParseTimeOfDay(f.To)
return tod
}
+235
View File
@@ -0,0 +1,235 @@
package handlerfactory
import (
"fmt"
"git.aiterp.net/lucifer/new-server/models"
)
func CreateHandlers(
config FactoryConfig,
scenes []models.Scene,
existing []models.EventHandler,
) ([]models.EventHandler, []models.EventHandler) {
switch config.Mode {
case SceneDimmer:
return sceneDimmer(config, scenes, existing)
case MotionSensor:
return motionSensor(config, existing)
default:
return []models.EventHandler{}, []models.EventHandler{}
}
}
func sceneDimmer(
config FactoryConfig,
scenes []models.Scene,
existing []models.EventHandler,
) ([]models.EventHandler, []models.EventHandler) {
kind, refValue := models.ParseFetchString(config.Target)
toRemove := make([]models.EventHandler, 0, 8)
for _, old := range existing {
if old.Conditions["deviceId"].EQ == config.TriggerDeviceID {
toRemove = append(toRemove, old)
}
}
resolvedScenes := make([]models.Scene, 0, len(config.SceneNames))
for _, name := range config.SceneNames {
for _, scene := range scenes {
if name == scene.Name {
resolvedScenes = append(resolvedScenes, scene)
break
}
}
}
if len(scenes) < 2 {
return []models.EventHandler{}, []models.EventHandler{}
}
handlers := make([]models.EventHandler, 0, len(scenes)+4)
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "On"},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetPower: &myTrue},
From: config.FromTime(),
To: config.ToTime(),
})
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "Off"},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetPower: &myFalse},
From: config.FromTime(),
To: config.ToTime(),
})
for i, scene := range resolvedScenes {
if i > 0 {
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "DimDown"},
"any.scene": {EQ: fmt.Sprintf("%d", scene.ID)},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetScene: &models.DeviceSceneAssignment{
SceneID: resolvedScenes[i-1].ID,
Group: refValue,
DurationMS: 0,
}},
From: config.FromTime(),
To: config.ToTime(),
})
}
if i < len(resolvedScenes)-1 {
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "DimUp"},
"any.scene": {EQ: fmt.Sprintf("%d", scene.ID)},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetScene: &models.DeviceSceneAssignment{
SceneID: resolvedScenes[i+1].ID,
Group: refValue,
DurationMS: 0,
}},
From: config.FromTime(),
To: config.ToTime(),
})
}
}
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "DimDown"},
},
OneShot: false,
Priority: 90,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetScene: &models.DeviceSceneAssignment{
SceneID: resolvedScenes[len(resolvedScenes)-1].ID,
Group: refValue,
DurationMS: 0,
}},
From: config.FromTime(),
To: config.ToTime(),
})
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "DimUp"},
},
OneShot: false,
Priority: 90,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetScene: &models.DeviceSceneAssignment{
SceneID: resolvedScenes[0].ID,
Group: refValue,
DurationMS: 0,
}},
From: config.FromTime(),
To: config.ToTime(),
})
return optimizeLists(handlers, toRemove)
}
func motionSensor(
config FactoryConfig,
existing []models.EventHandler,
) ([]models.EventHandler, []models.EventHandler) {
kind, refValue := models.ParseFetchString(config.Target)
toRemove := make([]models.EventHandler, 0, 8)
for _, old := range existing {
if old.From != config.FromTime() || old.To != config.ToTime() {
continue
}
if old.Conditions["deviceId"].EQ == config.TriggerDeviceID {
toRemove = append(toRemove, old)
}
}
handlers := make([]models.EventHandler, 0, len(config.After))
for _, after := range config.After {
if after.Minutes == 0 {
handlers = append(handlers, models.EventHandler{
EventName: "SensorPresenceStarted",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{
SetPower: after.NewState.Power,
SetColor: after.NewState.Color,
SetIntensity: after.NewState.Intensity,
SetTemperature: after.NewState.Temperature,
},
From: config.FromTime(),
To: config.ToTime(),
})
} else {
handlers = append(handlers, models.EventHandler{
EventName: "SensorPresenceEnded",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"minutesElapsed": {EQ: fmt.Sprintf("%d", after.Minutes)},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{
SetPower: after.NewState.Power,
SetColor: after.NewState.Color,
SetIntensity: after.NewState.Intensity,
SetTemperature: after.NewState.Temperature,
},
From: config.FromTime(),
To: config.ToTime(),
})
}
}
return optimizeLists(handlers, toRemove)
}
var myTrue = true
var myFalse = false
+72
View File
@@ -0,0 +1,72 @@
package handlerfactory
import (
"git.aiterp.net/lucifer/new-server/models"
)
func optimizeLists(
add []models.EventHandler,
del []models.EventHandler,
) ([]models.EventHandler, []models.EventHandler) {
newAdd := make([]models.EventHandler, 0, len(add))
newDel := make([]models.EventHandler, 0, len(del))
OptimizeLoop1:
for _, addItem := range add {
for _, delItem := range del {
if checkDuplicate(&addItem, &delItem) {
continue OptimizeLoop1
}
}
newAdd = append(newAdd, addItem)
}
OptimizeLoop2:
for _, delItem := range del {
for _, addItem := range add {
if checkDuplicate(&addItem, &delItem) {
continue OptimizeLoop2
}
}
newDel = append(newDel, delItem)
}
return newAdd, newDel
}
func checkDuplicate(a *models.EventHandler, b *models.EventHandler) bool {
check := true
check = check && a.EventName == b.EventName
check = check && len(a.Conditions) == len(b.Conditions)
for k := range a.Conditions {
check = check && a.Conditions[k] == b.Conditions[k]
}
check = check && a.OneShot == b.OneShot
check = check && a.Priority == b.Priority
check = check && a.TargetKind == b.TargetKind
check = check && a.TargetValue == b.TargetValue
check = check && a.From == b.From
check = check && a.To == b.To
aPower := a.Actions.SetPower
bPower := b.Actions.SetPower
powerEq := (aPower == nil && bPower == nil) || (aPower != nil && bPower != nil && *aPower == *bPower)
aColor := a.Actions.SetColor
bColor := b.Actions.SetColor
colorEq := (aColor == nil && bColor == nil) || (aColor != nil && bColor != nil && *aColor == *bColor)
aInt := a.Actions.SetIntensity
bInt := b.Actions.SetIntensity
intEq := (aInt == nil && bInt == nil) || (aInt != nil && bInt != nil && *aInt == *bInt)
aTemp := a.Actions.SetTemperature
bTemp := b.Actions.SetTemperature
tempEq := (aTemp == nil && bTemp == nil) || (aTemp != nil && bTemp != nil && *aTemp == *bTemp)
check = check && powerEq && colorEq && intEq && tempEq
return check
}
-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()
}
}()
}
+330
View File
@@ -0,0 +1,330 @@
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{}
sensorCache map[int]models.SceneSensor
}
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) UpdateSensor(data models.SceneSensor) {
p.mu.Lock()
if assignment := p.sceneAssignment[data.ID]; assignment != nil {
assignment.UpdateSensor(data)
}
p.sensorCache[data.ID] = 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)
if cache, ok := p.sensorCache[device.ID]; ok {
p.sceneAssignment[device.ID].UpdateSensor(cache)
}
} 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{}),
sensorCache: make(map[int]models.SceneSensor),
}
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
}
+225
View File
@@ -0,0 +1,225 @@
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
sensors []models.SceneSensor
due bool
lastInterval int64
}
func (s *Scene) UpdateSensor(data models.SceneSensor) {
s.due = true
for i, sensor := range s.sensors {
if sensor.ID == data.ID {
s.sensors[i] = data
return
}
}
s.sensors = append(s.sensors, data)
}
// 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)
for i, sensor := range s.sensors {
if sensor.ID == device.ID {
s.sensors = append(s.sensors[:i], s.sensors[i+1:]...)
break
}
}
}
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{}
}
currentTime := time.Now()
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
}
sensorCount := 0
for _, device := range list {
if device.IsOnlySensor() {
sensorCount += 1
}
}
role := s.data.Roles[i]
jSkip := 0
for j, device := range list {
if device.IsOnlySensor() {
updatedDevices = append(updatedDevices, device)
jSkip += 1
continue
}
newState := role.ApplyEffect(&device, models.SceneRunContext{
CurrentTime: currentTime,
Index: j - jSkip,
Length: len(list) - sensorCount,
IntervalNumber: intervalNumber,
IntervalMax: intervalMax,
Sensors: s.sensors,
})
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
}
+1 -1
View File
@@ -66,7 +66,7 @@ func checkNewDevices() error {
log.Println("Saving new device", driverDevice.InternalID)
err := config.DeviceRepository().Save(ctx, &driverDevice)
err := config.DeviceRepository().Save(ctx, &driverDevice, models.SMState|models.SMProperties|models.SMTags)
if err != nil {
log.Println("Failed to save device:", err)
continue
+1 -1
View File
@@ -33,7 +33,7 @@ func main() {
}
// Find bridge
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, !*flagPair)
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, *flagToken, !*flagPair)
if err != nil {
log.Fatalln("Failed to search bridge:", err)
}
+102 -8
View File
@@ -1,15 +1,18 @@
package main
import (
"git.aiterp.net/lucifer/new-server/models"
"log"
"strconv"
"strings"
"time"
)
type Param struct {
Index int
Key string
Value string
Index int
Key string
Value string
Operator string
}
func (p *Param) String() *string {
@@ -20,6 +23,28 @@ func (p *Param) String() *string {
return &p.Value
}
func (p *Param) TimeOfDay() *models.TimeOfDay {
if p == nil {
return nil
}
tod, err := models.ParseTimeOfDay(p.Value)
if err != nil {
return nil
}
return &tod
}
func (p *Param) TimeOfDayOr(fallback models.TimeOfDay) models.TimeOfDay {
tod := p.TimeOfDay()
if tod == nil {
return fallback
}
return *tod
}
func (p *Param) StringOr(fallback string) string {
if p == nil {
return fallback
@@ -28,7 +53,6 @@ func (p *Param) StringOr(fallback string) string {
return p.Value
}
func (p *Param) Int() *int {
if p == nil {
return nil
@@ -42,6 +66,15 @@ func (p *Param) Int() *int {
return &n
}
func (p *Param) IntOr(other int) int {
val := p.Int()
if val == nil {
return other
}
return *val
}
func (p *Param) Float() *float64 {
if p == nil {
return nil
@@ -108,7 +141,7 @@ func (p Params) Subset(prefix string) Params {
for _, param := range p {
if param.Index == -1 && strings.HasPrefix(param.Key, prefix) {
res = append(res, Param{Index: -1, Key: param.Key[len(prefix):], Value: param.Value})
res = append(res, Param{Index: -1, Key: param.Key[len(prefix):], Value: param.Value, Operator: param.Operator})
}
}
@@ -152,6 +185,55 @@ func (p Params) Strings(minIndex int) []string {
return res
}
func (p Params) EventConditions() map[string]models.EventCondition {
ecMap := make(map[string]models.EventCondition, len(p))
for _, param := range p {
element, ok := ecMap[param.Key]
if !ok {
element = models.EventCondition{}
}
switch param.Operator {
case "<":
element.LT = param.Value
case "<=":
element.LTE = param.Value
case ">=":
element.GTE = param.Value
case ">":
element.GT = param.Value
default:
element.EQ = param.Value
}
ecMap[param.Key] = element
}
return ecMap
}
func (p Params) DeviceState(prefix string) models.NewDeviceState {
return models.NewDeviceState{
Power: p.Get(prefix + "power").Bool(),
Color: p.Get(prefix + "color").String(),
Intensity: p.Get(prefix + "intensity").Float(),
Temperature: p.Get(prefix + "temperature").Int(),
}
}
func (p Params) SceneAssignment(prefix string) *models.DeviceSceneAssignment {
if p.Get(prefix+"scene") == nil {
return nil
}
return &models.DeviceSceneAssignment{
SceneID: p.Get(prefix+"scene").IntOr(-1),
Group: p.Get(prefix+"scene.group").StringOr(time.Now().Format(time.RFC3339)),
DurationMS: int64(p.Get(prefix+"scene.duration").IntOr(0)),
}
}
type Command struct {
Name string
Params Params
@@ -169,10 +251,22 @@ func parseCommand(args []string) Command {
nextIndex := 0
for _, arg := range args[1:] {
kv := strings.SplitN(arg, "=", 2)
kvle := strings.SplitN(arg, "<=", 2)
kvge := strings.SplitN(arg, ">=", 2)
kvl := strings.SplitN(arg, "<", 2)
kvg := strings.SplitN(arg, ">", 2)
kve := strings.SplitN(arg, "=", 2)
if len(kv) == 2 {
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kv[0], Value: kv[1]})
if len(kvle) == 2 {
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kvle[0], Value: kvle[1], Operator: "<="})
} else if len(kvge) == 2 {
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kvge[0], Value: kvge[1], Operator: ">="})
} else if len(kvl) == 2 {
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kvl[0], Value: kvl[1], Operator: "<"})
} else if len(kvg) == 2 {
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kvg[0], Value: kvg[1], Operator: ">"})
} else if len(kve) == 2 {
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kve[0], Value: kve[1], Operator: "="})
} else {
cmd.Params = append(cmd.Params, Param{Index: nextIndex, Value: arg})
nextIndex += 1
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/app/client"
"git.aiterp.net/lucifer/new-server/app/services/handlerfactory"
"log"
"os"
)
func factoryCmd(
ctx context.Context,
c client.Client,
) {
cmd := parseCommand(os.Args[2:])
switch cmd.Name {
case "run":
fileName := cmd.Params.Get(0).String()
if fileName == nil {
log.Fatalln("Missing filename")
}
scenes, err := c.GetScenes(ctx)
if err != nil {
log.Fatalln(err.Error())
}
handlers, err := c.GetHandlers(ctx)
if err != nil {
log.Fatalln(err.Error())
}
file, err := os.Open(*fileName)
if err != nil {
log.Fatalln("Failed to open file:", err)
}
defer file.Close()
configs, err := handlerfactory.ParseConfigs(file)
if err != nil {
log.Fatalln("Failed to parse file:", err)
}
for _, config := range configs {
toAdd, toRemove := handlerfactory.CreateHandlers(config, scenes, handlers)
for _, removing := range toRemove {
_, err := c.DeleteHandler(ctx, removing.ID)
if err != nil {
log.Fatalln(err.Error())
}
log.Printf("Removed handler %d", removing.ID)
}
for _, adding := range toAdd {
res, err := c.AddHandler(ctx, adding)
if err != nil {
log.Fatalln(err.Error())
}
log.Printf("Added handler %d", res.ID)
}
}
default:
if cmd.Name != "help" {
log.Println("Unknown command:", cmd.Name)
}
_, _ = fmt.Fprintln(os.Stderr, helpString[1:])
}
}
+202
View File
@@ -0,0 +1,202 @@
package main
import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/app/client"
"git.aiterp.net/lucifer/new-server/models"
"log"
"os"
"strings"
)
func handlerCmd(
ctx context.Context,
c client.Client,
) {
cmd := parseCommand(os.Args[2:])
switch cmd.Name {
case "list":
handlers, err := c.GetHandlers(ctx)
if err != nil {
log.Fatalln(err)
}
WriteHandlerInfoTable(os.Stdout, handlers)
case "add":
model := models.EventHandler{}
// Event name
model.EventName = cmd.Params.Get(0).StringOr("")
if len(model.EventName) == 0 {
log.Fatalln("Event name needed!")
}
// Search
fetchStr := cmd.Params.Get(1).String()
if fetchStr == nil {
log.Fatalln("Search filter needed!")
}
model.TargetKind, model.TargetValue = models.ParseFetchString(*fetchStr)
model = applyCmdToHandler(model, cmd)
newHandler, err := c.AddHandler(ctx, model)
if err != nil {
log.Fatalln(err)
}
WriteHandlerInfoTable(os.Stdout, []models.EventHandler{*newHandler})
case "edit":
id := cmd.Params.Get(0).IntOr(0)
if id == 0 {
log.Fatalln("ID missing")
}
model, err := c.GetHandler(ctx, id)
if err != nil {
log.Fatalln(err.Error())
}
eventStr := cmd.Params.Get("event").String()
if eventStr != nil {
model.EventName = *eventStr
}
fetchStr := cmd.Params.Get("search").String()
if fetchStr != nil {
model.TargetKind, model.TargetValue = models.ParseFetchString(*fetchStr)
}
modified := applyCmdToHandler(*model, cmd)
returned, err := c.PutHandler(ctx, &modified)
if err != nil {
log.Fatalln(err.Error())
}
WriteHandlerInfoTable(os.Stdout, []models.EventHandler{*returned})
case "delete":
if cmd.Params.Get(0).StringOr("_not") == "all" {
handlers, err := c.GetHandlers(ctx)
if err != nil {
log.Fatalln(err)
}
for _, handler := range handlers {
_, err = c.DeleteHandler(ctx, handler.ID)
if err != nil {
log.Fatalln(err)
}
}
log.Printf("Deleted %d handlers", len(handlers))
} else {
id := cmd.Params.Get(0).Int()
if id == nil {
log.Fatalln("ID missing")
}
handler, err := c.DeleteHandler(ctx, *id)
if err != nil {
log.Fatalln(err)
}
WriteHandlerInfoTable(os.Stdout, []models.EventHandler{*handler})
}
default:
if cmd.Name != "help" {
log.Println("Unknown command:", cmd.Name)
}
_, _ = fmt.Fprintln(os.Stderr, helpString[1:])
}
}
func applyCmdToHandler(model models.EventHandler, cmd Command) models.EventHandler {
// Remove keys
for _, elem := range cmd.Params.Strings(1) {
if !strings.HasPrefix(elem, "-") {
continue
}
keyToRemove := elem[1:]
if strings.HasPrefix(keyToRemove, "conditions.") {
condKeyToRemove := keyToRemove[11:]
delete(model.Conditions, condKeyToRemove)
} else if strings.HasPrefix(keyToRemove, "fire.payload.") && model.Actions.FireEvent != nil {
firePayloadKeyToRemove := keyToRemove[13:]
delete(model.Actions.FireEvent.Payload, firePayloadKeyToRemove)
} else {
switch keyToRemove {
case "set-power":
model.Actions.SetPower = nil
case "set-color":
model.Actions.SetColor = nil
case "set-intensity":
model.Actions.SetIntensity = nil
case "add-intensity":
model.Actions.AddIntensity = nil
case "fire":
fallthrough
case "fire.name":
model.Actions.FireEvent = nil
case "from":
model.From = models.Never
case "to":
model.To = models.Never
case "set-scene":
model.Actions.SetScene = nil
case "push-scene":
model.Actions.PushScene = nil
}
}
}
// From / To
model.From = cmd.Params.Get("from").TimeOfDayOr(models.Never)
model.To = cmd.Params.Get("to").TimeOfDayOr(models.Never)
if model.From.IsNever() != model.To.IsNever() {
log.Fatalln("from and to must be specified together!")
}
// One shot
model.OneShot = cmd.Params.Get("one-shot").StringOr("false") == "true"
// Priority
model.Priority = cmd.Params.Get("priority").IntOr(100)
// Conditions
model.Conditions = cmd.Params.Subset("conditions").EventConditions()
// Power, Color, Intensity, Temperature
nds := cmd.Params.DeviceState("set-")
model.Actions.SetPower = nds.Power
model.Actions.SetColor = nds.Color
model.Actions.SetIntensity = nds.Intensity
model.Actions.SetTemperature = nds.Temperature
// Add intensity
model.Actions.AddIntensity = cmd.Params.Get("add-intensity").Float()
// Fire
fireParams := cmd.Params.Subset("fire")
fireName := fireParams.Get("name").String()
if fireName != nil {
firePayloadParams := fireParams.Subset("payload")
model.Actions.FireEvent = &models.Event{
Name: *fireName,
Payload: make(map[string]string, len(firePayloadParams)),
}
for key, value := range firePayloadParams.StringMap() {
model.Actions.FireEvent.Payload[key] = value
}
}
// Scenes
model.Actions.SetScene = cmd.Params.SceneAssignment("set-")
model.Actions.PushScene = cmd.Params.SceneAssignment("push-")
return model
}
+53
View File
@@ -13,6 +13,59 @@ DEVICE COMMANDS
tag <search> <[+/-]tag-name>
update <search> <name=S> <icon=S> <prop.*=S/NULL>
EVENT HANDLER COMMANDS
handler list
handler add <event> <search> \
<one-shot=B> \
<priority=N> \
<from=TOD to=TOD> \
<conditions.[any.*|all.*|*][<|<=|=|=>|>]*> \
<set-power=B> <set-color=C> <set-intensity=F> <set-temperature=N> \
<add-intensity=F> \
<fire.name=S> <fire.payload.*=S>
handler edit <id> \
(...all arguments from 'add', appending conditions and fire payload matchers...) \
<event=S> <search=S>
<-from> <-to> \
<-set-power> <-set-color> <-set-intensity> <-set-temperature> \
<-conditions.*> \
<-fire> <-fire.payload.*>
handler delete <id>
EVENT HANDLER FACTORY COMMANDS
factory run <file.yaml>
File example (use '---' to delimit multiple configs in one file):
mode: SceneDimmer
target: tag:Soverom
scenes:
- kveldslys
- landskap
- vasking
trigger_device_id: 14
---
mode: MotionSensor
target: tag:Bod
trigger_device_id: 36
after:
- minutes: 0
new_state:
power: true
color: hs:240,0.6
intensity: 0.8
- minutes: 1
new_state:
power: false
from: 08:00
to: 15:59:59
---
(more modes coming soon)
WARNING: Similar event handlers will be removed!
EVENT COMMANDS
run <event name> <*=S>
NOTE: You may have to surround arguments including < or > with quotation marks!
`
+23 -13
View File
@@ -17,6 +17,8 @@ func main() {
cfg = &Config{Remote: "http://localhost:8000"}
}
ctx := context.Background()
c := client.Client{APIRoot: cfg.Remote}
cmd := parseCommand(os.Args[1:])
@@ -30,21 +32,20 @@ func main() {
fetchStr = &s
}
devices, err := c.GetDevices(context.Background(), *fetchStr)
devices, err := c.GetDevices(ctx, *fetchStr)
if err != nil {
log.Fatalln(err)
}
WriteDeviceStateTable(os.Stdout, devices)
if cmd.Params.Get("info").StringOr("false") == "true" {
WriteDeviceInfoTable(os.Stdout, devices)
} else {
WriteDeviceStateTable(os.Stdout, devices)
}
}
case "set":
{
devices, err := c.PutDeviceState(context.Background(), cmd.Params.Get(0).StringOr("all"), models.NewDeviceState{
Power: cmd.Params.Get("power").Bool(),
Color: cmd.Params.Get("color").String(),
Intensity: cmd.Params.Get("intensity").Float(),
Temperature: cmd.Params.Get("temperature").Int(),
})
devices, err := c.PutDeviceState(ctx, cmd.Params.Get(0).StringOr("all"), cmd.Params.DeviceState(""))
if err != nil {
log.Fatalln(err)
}
@@ -67,7 +68,7 @@ func main() {
}
devices, err := c.PutDeviceTags(
context.Background(),
ctx,
cmd.Params.Get(0).StringOr("all"),
addTags,
removeTags,
@@ -80,9 +81,9 @@ func main() {
}
case "update":
{
devices, err := c.PutDevice(context.Background(), cmd.Params.Get(0).StringOr("all"), models.DeviceUpdate{
Name: cmd.Params.Get("name").String(),
Icon: cmd.Params.Get("icon").String(),
devices, err := c.PutDevice(ctx, cmd.Params.Get(0).StringOr("all"), models.DeviceUpdate{
Name: cmd.Params.Get("name").String(),
Icon: cmd.Params.Get("icon").String(),
UserProperties: cmd.Params.Subset("prop.").StringPtrMap(),
})
if err != nil {
@@ -92,6 +93,15 @@ func main() {
WriteDeviceInfoTable(os.Stdout, devices)
}
case "factory":
factoryCmd(ctx, c)
case "handler":
handlerCmd(ctx, c)
case "scene":
sceneCmd(ctx, c)
// EVENT
case "run":
{
@@ -105,7 +115,7 @@ func main() {
Payload: cmd.Params.StringMap(),
}
err := c.FireEvent(context.Background(), event)
err := c.FireEvent(ctx, event)
if err != nil {
log.Fatalln(err)
}
+165
View File
@@ -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
}
+85 -4
View File
@@ -1,6 +1,7 @@
package main
import (
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"github.com/olekukonko/tablewriter"
"io"
@@ -13,26 +14,46 @@ func WriteDeviceStateTable(w io.Writer, devices []models.Device) {
table.SetHeader([]string{"ID", "NAME", "POWER", "COLOR", "INTENSITY", "TEMPERATURE"})
table.SetReflowDuringAutoWrap(true)
table.SetColumnAlignment([]int{
tablewriter.ALIGN_RIGHT,
tablewriter.ALIGN_LEFT,
tablewriter.ALIGN_RIGHT,
tablewriter.ALIGN_LEFT,
tablewriter.ALIGN_RIGHT,
tablewriter.ALIGN_RIGHT,
})
for _, v := range devices {
powerStr := ""
if v.HasCapability(models.DCPower) {
powerStr = strconv.FormatBool(v.State.Power)
if v.SceneState != nil && v.SceneState.Power != v.State.Power {
powerStr = strconv.FormatBool(v.SceneState.Power) + "*"
} else {
powerStr = strconv.FormatBool(v.State.Power)
}
}
colorStr := ""
if v.HasCapability(models.DCColorHSK, models.DCColorHS, models.DCColorKelvin) {
colorStr = v.State.Color.String()
if v.SceneState != nil && v.SceneState.Color.String() != v.State.Color.String() {
colorStr = v.SceneState.Color.String() + "*"
} else {
colorStr = v.State.Color.String()
}
}
temperatureString := ""
if v.HasCapability(models.DCTemperatureControl, models.DCTemperatureSensor) {
temperatureString = strconv.FormatFloat(v.State.Temperature, 'f', -1, 64)
temperatureString = fmt.Sprintf("%d", v.State.Temperature)
}
intensityString := ""
if v.HasCapability(models.DCIntensity) {
intensityString = strconv.FormatFloat(v.State.Intensity, 'f', -1, 64)
if v.SceneState != nil && v.SceneState.Intensity != v.State.Intensity {
intensityString = strconv.FormatFloat(v.SceneState.Intensity, 'g', 2, 64) + "*"
} else {
intensityString = strconv.FormatFloat(v.State.Intensity, 'f', -1, 64)
}
}
table.Append([]string{
@@ -72,3 +93,63 @@ func WriteDeviceInfoTable(w io.Writer, devices []models.Device) {
table.Render()
}
func WriteHandlerInfoTable(w io.Writer, handlers []models.EventHandler) {
table := tablewriter.NewWriter(w)
table.SetHeader([]string{"ID", "EVENT NAME", "PRIORITY", "CONDITIONS", "TARGET", "ACTION"})
table.SetReflowDuringAutoWrap(false)
for _, h := range handlers {
condStr := ""
for key, value := range h.Conditions {
condStr += fmt.Sprintf("%s=%s ", key, value)
}
actionStr := ""
if h.Actions.SetPower != nil {
actionStr += fmt.Sprintf("setPower=%t ", *h.Actions.SetPower)
}
if h.Actions.SetColor != nil {
actionStr += fmt.Sprintf("setColor=%s ", *h.Actions.SetColor)
}
if h.Actions.SetIntensity != nil {
actionStr += fmt.Sprintf("setIntensity=%.02f ", *h.Actions.SetIntensity)
}
if h.Actions.SetTemperature != nil {
actionStr += fmt.Sprintf("setTemperature=%d ", *h.Actions.SetTemperature)
}
if h.Actions.AddIntensity != nil {
actionStr += fmt.Sprintf("addIntensity=%.02f ", *h.Actions.AddIntensity)
}
if h.Actions.FireEvent != nil {
actionStr += fmt.Sprintf("fireEvent=%s ", (*h.Actions.FireEvent).Name)
}
if h.Actions.FireEvent != nil {
actionStr += fmt.Sprintf("fireEvent=%s ", (*h.Actions.FireEvent).Name)
}
if h.Actions.SetScene != nil {
s := h.Actions.SetScene
actionStr += fmt.Sprintf("setScene=(id=%d,group=\"%s\",duration=%d) ", s.SceneID, s.Group, s.DurationMS)
}
if h.Actions.PushScene != nil {
s := h.Actions.PushScene
actionStr += fmt.Sprintf("pushScene=(id=%d,group=\"%s\",duration=%d) ", s.SceneID, s.Group, s.DurationMS)
}
eventName := h.EventName
if h.OneShot {
eventName += " (one shot)"
}
table.Append([]string{
strconv.Itoa(h.ID),
eventName,
strconv.Itoa(h.Priority),
condStr,
fmt.Sprintf("%s:%s", h.TargetKind, h.TargetValue),
actionStr,
})
}
table.Render()
}
+6
View File
@@ -20,6 +20,7 @@ github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfC
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jmoiron/sqlx v1.3.4 h1:wv+0IJZfL5z0uZoUjlpKgHkgaFSYD+r9CfrXjEXsO7w=
github.com/jmoiron/sqlx v1.3.4/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
@@ -55,6 +56,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose v2.7.0+incompatible h1:PWejVEv07LCerQEzMMeAtjuyCKbyprZ/LBa6K5P0OCQ=
github.com/pressly/goose v2.7.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -69,6 +71,7 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 h1:3erb+vDS8lU1sxfDHF4/hhWyaXnhIaO+7RgL4fDZORA=
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -78,10 +81,13 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
printf "Building lucy...\n"
go mod download || exit 1
go build -ldflags "-w -s" -o lucy ./cmd/lucy || exit 1
printf "Removing old lucy...\n"
sudo rm /usr/local/bin/lucy > /dev/null 2>&1
printf "Installing...\n"
sudo mv ./lucy /usr/local/bin/lucy
+110 -3
View File
@@ -10,6 +10,7 @@ import (
"io"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -22,6 +23,7 @@ type Bridge struct {
externalID int
lightStates []*hueLightState
sensorStates []*hueSensorState
quarantine map[string]time.Time
syncingPublish uint32
}
@@ -33,6 +35,10 @@ func (b *Bridge) Refresh(ctx context.Context) error {
b.mu.Lock()
for index, light := range lightMap {
if time.Now().Before(b.quarantine[light.Uniqueid]) {
continue
}
var state *hueLightState
for _, existingState := range b.lightStates {
if existingState.index == index {
@@ -67,6 +73,10 @@ func (b *Bridge) Refresh(ctx context.Context) error {
b.mu.Lock()
for index, sensor := range sensorMap {
if time.Now().Before(b.quarantine[sensor.UniqueID]) {
continue
}
var state *hueSensorState
for _, existingState := range b.sensorStates {
if existingState.index == index {
@@ -76,9 +86,10 @@ func (b *Bridge) Refresh(ctx context.Context) error {
if state == nil {
state = &hueSensorState{
index: index,
uniqueID: sensor.UniqueID,
externalID: -1,
index: index,
uniqueID: sensor.UniqueID,
externalID: -1,
presenceCooldown: -2,
}
b.sensorStates = append(b.sensorStates, state)
@@ -135,6 +146,44 @@ func (b *Bridge) SyncStale(ctx context.Context) error {
return eg.Wait()
}
func (b *Bridge) ForgetDevice(ctx context.Context, device models.Device) error {
// Find index
b.mu.Lock()
found := false
index := -1
for i, ls := range b.lightStates {
if ls.uniqueID == device.InternalID {
found = true
index = i
}
}
b.mu.Unlock()
if !found {
return models.ErrNotFound
}
// Delete light from bridge
err := b.deleteLight(ctx, index)
if err != nil {
return err
}
// Remove light state from local list. I don't know if the quarantine is necessary, but let's have it anyway.
b.mu.Lock()
for i, ls := range b.lightStates {
if ls.uniqueID == device.InternalID {
b.lightStates = append(b.lightStates[:i], b.lightStates[i+1:]...)
}
}
if b.quarantine == nil {
b.quarantine = make(map[string]time.Time, 1)
}
b.quarantine[device.InternalID] = time.Now().Add(time.Second * 30)
b.mu.Unlock()
return nil
}
func (b *Bridge) SyncSensors(ctx context.Context) ([]models.Event, error) {
sensorMap, err := b.getSensors(ctx)
if err != nil {
@@ -169,6 +218,14 @@ func (b *Bridge) putLightState(ctx context.Context, index int, input LightStateI
return b.put(ctx, fmt.Sprintf("lights/%d/state", index), input, nil)
}
func (b *Bridge) putGroupLightState(ctx context.Context, index int, input LightStateInput) error {
return b.put(ctx, fmt.Sprintf("groups/%d/action", index), input, nil)
}
func (b *Bridge) deleteLight(ctx context.Context, index int) error {
return b.delete(ctx, fmt.Sprintf("lights/%d", index), nil)
}
func (b *Bridge) getToken(ctx context.Context) (string, error) {
result := make([]CreateUserResponse, 0, 1)
err := b.post(ctx, "", CreateUserInput{DeviceType: "git.aiterp.net/lucifer"}, &result)
@@ -206,6 +263,33 @@ func (b *Bridge) getSensors(ctx context.Context) (map[int]SensorData, error) {
return result, nil
}
func (b *Bridge) getGroups(ctx context.Context) (map[int]GroupData, error) {
result := make(map[int]GroupData, 16)
err := b.get(ctx, "groups", &result)
if err != nil {
return nil, err
}
return result, nil
}
func (b *Bridge) postGroup(ctx context.Context, input GroupData) (int, error) {
var res []struct {
Success struct {
ID string `json:"id"`
} `json:"success"`
}
err := b.post(ctx, "groups", input, &res)
id, _ := strconv.Atoi(res[0].Success.ID)
return id, err
}
func (b *Bridge) deleteGroup(ctx context.Context, index int) error {
return b.delete(ctx, "groups/"+strconv.Itoa(index), nil)
}
func (b *Bridge) get(ctx context.Context, resource string, target interface{}) error {
if b.token != "" {
resource = b.token + "/" + resource
@@ -225,6 +309,29 @@ func (b *Bridge) get(ctx context.Context, resource string, target interface{}) e
return json.NewDecoder(res.Body).Decode(target)
}
func (b *Bridge) delete(ctx context.Context, resource string, target interface{}) error {
if b.token != "" {
resource = b.token + "/" + resource
}
req, err := http.NewRequest("DELETE", fmt.Sprintf("http://%s/api/%s", b.host, resource), nil)
if err != nil {
return err
}
res, err := httpClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer res.Body.Close()
if target == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(target)
}
func (b *Bridge) post(ctx context.Context, resource string, body interface{}, target interface{}) error {
rb, err := reqBody(body)
if err != nil {
+88
View File
@@ -3,8 +3,14 @@ package hue
import (
"encoding/xml"
"errors"
"strconv"
)
type GroupData struct {
Name string `json:"name"`
Lights []string `json:"lights"`
}
type DiscoveryEntry struct {
Id string `json:"id"`
InternalIPAddress string `json:"internalipaddress"`
@@ -83,6 +89,58 @@ type LightStateInput struct {
TransitionTime *int `json:"transitiontime,omitempty"`
}
func (input *LightStateInput) Equal(other LightStateInput) bool {
if (input.On != nil) != (other.On != nil) {
return false
} else if input.On != nil && *input.On != *other.On {
return false
}
if (input.Bri != nil) != (other.Bri != nil) {
return false
} else if input.Bri != nil && *input.Bri != *other.Bri {
return false
}
if (input.Hue != nil) != (other.Hue != nil) {
return false
} else if input.Hue != nil && *input.Hue != *other.Hue {
return false
}
if (input.Sat != nil) != (other.Sat != nil) {
return false
} else if input.Sat != nil && *input.Sat != *other.Sat {
return false
}
if (input.Effect != nil) != (other.Effect != nil) {
return false
} else if input.Effect != nil && *input.Effect != *other.Effect {
return false
}
if (input.XY != nil) != (other.XY != nil) {
return false
} else if input.XY != nil && *input.XY != *other.XY {
return false
}
if (input.CT != nil) != (other.CT != nil) {
return false
} else if input.CT != nil && *input.CT != *other.CT {
return false
}
if (input.Alert != nil) != (other.Alert != nil) {
return false
} else if input.Alert != nil && *input.Alert != *other.Alert {
return false
}
return true
}
type LightData struct {
State LightState `json:"state"`
Type string `json:"type"`
@@ -192,6 +250,36 @@ type BridgeDeviceInfo struct {
} `xml:"device"`
}
type syncGroup struct {
GroupIndex int
State LightStateInput
Indexes []int
ArrayIndexes []int
}
func (sg *syncGroup) Matches(group *GroupData) bool {
if len(sg.Indexes) != len(group.Lights) {
return false
}
for _, idx := range sg.Indexes {
found := false
for _, idx2 := range group.Lights {
n := strconv.Itoa(idx)
if n == idx2 {
found = true
break
}
}
if !found {
return false
}
}
return true
}
var buttonNames = []string{"On", "DimUp", "DimDown", "Off"}
var errLinkButtonNotPressed = errors.New("link button not pressed")
+10 -1
View File
@@ -19,7 +19,7 @@ type Driver struct {
bridges []*Bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address string, dryRun bool) ([]models.Bridge, error) {
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) {
if address == "" {
if !dryRun {
return nil, models.ErrAddressOnlyDryRunnable
@@ -372,6 +372,15 @@ func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models
}
}
func (d *Driver) ForgetDevice(ctx context.Context, bridge models.Bridge, device models.Device) error {
b, err := d.ensureBridge(ctx, bridge)
if err != nil {
return err
}
return b.ForgetDevice(ctx, device)
}
func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*Bridge, error) {
d.mu.Lock()
for _, bridge := range d.bridges {
+63 -15
View File
@@ -1,6 +1,7 @@
package hue
import (
"encoding/json"
"git.aiterp.net/lucifer/new-server/models"
"strconv"
"time"
@@ -16,12 +17,37 @@ type hueLightState struct {
stale bool
}
func (s *hueLightState) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Index int `json:"index"`
UniqueID string `json:"uniqueId"`
ExternalID int `json:"externalId"`
Info LightData `json:"info"`
Input LightStateInput `json:"input"`
Stale bool `json:"stale"`
}{
Index: s.index,
UniqueID: s.uniqueID,
ExternalID: s.externalID,
Info: s.info,
Input: s.input,
Stale: s.stale,
})
}
func (s *hueLightState) Update(state models.DeviceState) {
input := LightStateInput{}
if state.Power {
input.On = ptrBool(true)
if state.Color.IsKelvin() {
input.CT = ptrInt(1000000 / state.Color.Kelvin)
if *input.CT < s.info.Capabilities.Control.CT.Min {
*input.CT = s.info.Capabilities.Control.CT.Min
}
if *input.CT > s.info.Capabilities.Control.CT.Max {
*input.CT = s.info.Capabilities.Control.CT.Max
}
if s.input.CT == nil || *s.input.CT != *input.CT {
s.stale = true
}
@@ -67,6 +93,13 @@ func (s *hueLightState) Update(state models.DeviceState) {
}
func (s *hueLightState) CheckStaleness(state LightState) {
if s.input.On == nil || state.On != *s.input.On {
s.stale = true
}
if !state.On {
return
}
if state.ColorMode == "xy" {
s.stale = true
if s.input.CT == nil && s.input.Hue == nil {
@@ -80,7 +113,7 @@ func (s *hueLightState) CheckStaleness(state LightState) {
s.stale = true
}
} else {
if s.input.Hue == nil || state.Hue != *s.input.Hue || s.input.Sat == nil || state.Sat == *s.input.Sat {
if s.input.Hue == nil || state.Hue != *s.input.Hue || s.input.Sat == nil || state.Sat != *s.input.Sat {
s.stale = true
}
}
@@ -92,7 +125,7 @@ type hueSensorState struct {
uniqueID string
prevData *SensorData
prevTime time.Time
presenceCooldown bool
presenceCooldown int
}
func (state *hueSensorState) Update(newData SensorData) *models.Event {
@@ -122,6 +155,9 @@ func (state *hueSensorState) Update(newData SensorData) *models.Event {
if time.Since(stateTime) > time.Second*3 {
return nil
}
if state.prevData == nil {
return nil
}
pe := state.prevData.State.ButtonEvent
ce := newData.State.ButtonEvent
@@ -153,26 +189,37 @@ func (state *hueSensorState) Update(newData SensorData) *models.Event {
case "ZLLPresence":
{
if state.prevData != nil && state.prevData.State.Presence != newData.State.Presence {
name := models.ENSensorPresenceStarted
if !newData.State.Presence {
name = models.ENSensorPresenceEnding
state.presenceCooldown = true
}
if newData.State.Presence {
state.presenceCooldown = -1
return &models.Event{
Name: name,
Payload: map[string]string{
"deviceId": strconv.Itoa(state.externalID),
"deviceInternalId": newData.UniqueID,
},
return &models.Event{
Name: models.ENSensorPresenceStarted,
Payload: map[string]string{
"deviceId": strconv.Itoa(state.externalID),
"deviceInternalId": newData.UniqueID,
},
}
} else {
state.presenceCooldown = 0
}
} else if state.presenceCooldown && !newData.State.Presence && time.Since(stateTime) > time.Minute {
state.presenceCooldown = false
}
if state.presenceCooldown == -2 {
state.presenceCooldown = int(time.Since(stateTime) / time.Minute)
}
nextEventWait := time.Minute * time.Duration(state.presenceCooldown)
if state.presenceCooldown != -1 && !newData.State.Presence && time.Since(stateTime) > nextEventWait {
state.presenceCooldown += 1
return &models.Event{
Name: models.ENSensorPresenceEnded,
Payload: map[string]string{
"deviceId": strconv.Itoa(state.externalID),
"deviceInternalId": newData.UniqueID,
"minutesElapsed": strconv.Itoa(state.presenceCooldown - 1),
"secondsElapsed": strconv.Itoa((state.presenceCooldown - 1) * 60),
"lastUpdated": strconv.FormatInt(stateTime.Unix(), 10),
},
}
}
@@ -189,6 +236,7 @@ func (state *hueSensorState) Update(newData SensorData) *models.Event {
"temperature": strconv.FormatFloat(float64(newData.State.Temperature)/100, 'f', 2, 64),
"deviceId": strconv.Itoa(state.externalID),
"deviceInternalId": newData.UniqueID,
"lastUpdated": strconv.FormatInt(stateTime.Unix(), 10),
},
}
}
+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 -1
View File
@@ -14,7 +14,7 @@ type Driver struct {
bridges []*Bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address string, _ bool) ([]models.Bridge, error) {
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, _ bool) ([]models.Bridge, error) {
if address == "" {
ifaces, err := net.Interfaces()
if err != nil {
+1
View File
@@ -18,6 +18,7 @@ type State struct {
lightStateTime time.Time
requestTime time.Time
updateTime time.Time
fwSpamTime time.Time
acksPending []uint8
}
+240
View File
@@ -0,0 +1,240 @@
package mill
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha1"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"io"
"log"
"net/http"
"strconv"
"sync"
"time"
)
type bridge struct {
mu sync.Mutex
luciferID int
username string
password string
token string
userId int
mustRefreshBy time.Time
luciferMillIDMap map[int]int
millLuciferIDMap map[int]int
}
func (b *bridge) listDevices(ctx context.Context) ([]models.Device, error) {
err := b.authenticate(ctx)
if err != nil {
return nil, err
}
var shlRes listHomeResBody
err = b.command(ctx, "selectHomeList", listHomeReqBody{}, &shlRes)
if err != nil {
return nil, err
}
devices := make([]millDevice, 0, 16)
for _, home := range shlRes.HomeList {
var gidRes listDeviceResBody
err = b.command(ctx, "getIndependentDevices", listDeviceReqBody{HomeID: home.HomeID}, &gidRes)
if err != nil {
return nil, err
}
devices = append(devices, gidRes.DeviceInfo...)
}
luciferDevices := make([]models.Device, len(devices), len(devices))
for i, device := range devices {
luciferDevices[i] = models.Device{
ID: b.millLuciferIDMap[device.DeviceID],
BridgeID: b.luciferID,
InternalID: fmt.Sprintf("%d", device.DeviceID),
Icon: "heater",
Name: device.DeviceName,
Capabilities: []models.DeviceCapability{models.DCTemperatureControl, models.DCPower},
ButtonNames: nil,
DriverProperties: map[string]interface{}{
"subDomain": fmt.Sprintf("%d", device.SubDomainID),
},
UserProperties: nil,
SceneAssignments: nil,
SceneState: nil,
State: models.DeviceState{
Power: device.PowerStatus > 0,
Temperature: device.HolidayTemp,
},
Tags: nil,
}
}
return luciferDevices, nil
}
func (b *bridge) pushStateChange(ctx context.Context, deviceModel models.Device) error {
b.mu.Lock()
if b.luciferMillIDMap == nil {
b.luciferMillIDMap = make(map[int]int, 4)
b.millLuciferIDMap = make(map[int]int, 4)
}
if b.luciferMillIDMap[deviceModel.ID] == 0 {
millID, _ := strconv.Atoi(deviceModel.InternalID)
b.luciferMillIDMap[deviceModel.ID] = millID
b.millLuciferIDMap[millID] = deviceModel.ID
}
b.mu.Unlock()
status := 0
if deviceModel.State.Power {
status = 1
}
powerReq := deviceControlReqBody{
SubDomain: deviceModel.DriverProperties["subDomain"].(string),
DeviceID: b.luciferMillIDMap[deviceModel.ID],
TestStatus: 1,
Status: status,
}
err := b.command(ctx, "deviceControl", powerReq, nil)
if err != nil {
return err
}
tempReq := changeInfoReqBody{
DeviceID: b.luciferMillIDMap[deviceModel.ID],
Value: deviceModel.State.Temperature,
TimeZoneNum: "+02:00",
Key: "holidayTemp",
}
err = b.command(ctx, "changeDeviceInfo", tempReq, nil)
if err != nil {
return err
}
return nil
}
func (b *bridge) command(ctx context.Context, command string, payload interface{}, target interface{}) error {
err := b.authenticate(ctx)
if err != nil {
return err
}
url := serviceEndpoint + command
method := "POST"
nonce := makeNonce()
timestamp := fmt.Sprintf("%d", time.Now().Unix())
timeout := "300"
h := sha1.New()
h.Write([]byte(timeout))
h.Write([]byte(timestamp))
h.Write([]byte(nonce))
h.Write([]byte(b.token))
signature := fmt.Sprintf("%x", h.Sum(nil))
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
return err
}
addDefaultHeaders(req)
req.Header.Add("X-Zc-Timestamp", timestamp)
req.Header.Add("X-Zc-Timeout", timeout)
req.Header.Add("X-Zc-Nonce", nonce)
req.Header.Add("X-Zc-User-Id", fmt.Sprintf("%d", b.userId))
req.Header.Add("X-Zc-User-Signature", signature)
req.Header.Add("X-Zc-Content-Length", fmt.Sprintf("%d", len(body)))
res, err := http.DefaultClient.Do(req)
if err != nil {
return models.ErrCannotForwardRequest
} else if res.StatusCode != 200 {
return models.ErrIncorrectToken
}
if target == nil {
return nil
}
err = json.NewDecoder(res.Body).Decode(&target)
if err != nil {
return models.ErrUnexpectedResponse
}
return nil
}
func (b *bridge) authenticate(ctx context.Context) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.mustRefreshBy.Before(time.Now().Add(-1 * time.Minute)) {
body, err := json.Marshal(authReqBody{
Account: b.username,
Password: b.password,
})
if err != nil {
return models.ErrMissingToken
}
req, err := http.NewRequestWithContext(ctx, "POST", accountEndpoint + "login", bytes.NewReader(body))
if err != nil {
return models.ErrMissingToken
}
addDefaultHeaders(req)
res, err := http.DefaultClient.Do(req)
if err != nil {
return models.ErrCannotForwardRequest
} else if res.StatusCode != 200 {
return models.ErrIncorrectToken
}
var resBody authResBody
err = json.NewDecoder(res.Body).Decode(&resBody)
if err != nil {
return models.ErrBridgeSearchFailed
}
log.Printf("Mill: Authenticated as %s", resBody.NickName)
b.userId = resBody.UserID
b.token = resBody.Token
b.mustRefreshBy, err = time.ParseInLocation("2006-01-02 15:04:05", resBody.TokenExpire, location)
}
return nil
}
func makeNonce() string {
buf := make([]byte, 8)
_, _ = io.ReadFull(rand.Reader, buf)
return fmt.Sprintf("%x", buf)
}
func addDefaultHeaders(req *http.Request) {
req.Header.Add("Content-Type", "application/x-zc-object")
req.Header.Add("Connection", "Keep-Alive")
req.Header.Add("X-Zc-Major-Domain", "seanywell")
req.Header.Add("X-Zc-Msg-Name", "millService")
req.Header.Add("X-Zc-Sub-Domain", "milltype")
req.Header.Add("X-Zc-Seq-Id", "1")
req.Header.Add("X-Zc-Version", "1")
}
+100
View File
@@ -0,0 +1,100 @@
package mill
import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"sync"
"time"
)
type Driver struct {
mu sync.Mutex
bridges map[int]*bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address, token string, _ bool) ([]models.Bridge, error) {
bridgeData := models.Bridge{
Name: fmt.Sprintf("Mill account (%s)", address),
Driver: models.DTMill,
Address: address,
Token: token,
}
b, err := d.ensureBridge(bridgeData)
if err != nil {
return nil, err
}
err = b.authenticate(ctx)
if err != nil {
return nil, err
}
return []models.Bridge{bridgeData}, nil
}
func (d *Driver) SearchDevices(context.Context, models.Bridge, time.Duration) ([]models.Device, error) {
// You would have to configure devices with the Mill app, unfortunately.
return []models.Device{}, nil
}
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
b, err := d.ensureBridge(bridge)
if err != nil {
return nil, err
}
return b.listDevices(ctx)
}
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
b, err := d.ensureBridge(bridge)
if err != nil {
return err
}
for _, device := range devices {
err = b.pushStateChange(ctx, device)
if err != nil {
return err
}
}
return nil
}
func (d *Driver) Run(ctx context.Context, _ models.Bridge, _ chan<- models.Event) error {
// TODO: Maybe do something with the thermostat on the device
for {
select {
case <-ctx.Done():
return ctx.Err()
}
}
}
func (d *Driver) ensureBridge(model models.Bridge) (*bridge, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.bridges == nil {
d.bridges = make(map[int]*bridge, 4)
}
if d.bridges[model.ID] == nil {
newBridge := &bridge{
luciferID: model.ID,
username: model.Address,
password: model.Token,
}
if model.ID <= 0 {
return newBridge, nil
}
d.bridges[model.ID] = newBridge
}
return d.bridges[model.ID], nil
}
+77
View File
@@ -0,0 +1,77 @@
package mill
import "time"
const accountEndpoint = "https://eurouter.ablecloud.cn:9005/zc-account/v1/"
const serviceEndpoint = "https://eurouter.ablecloud.cn:9005/millService/v1/"
type millHome struct {
HomeID int64 `json:"homeId"`
HomeName string `json:"homeName"`
}
type millDevice struct {
DeviceID int `json:"deviceId"`
DeviceName string `json:"deviceName"`
PowerStatus int `json:"powerStatus"`
HolidayTemp int `json:"holidayTemp"`
CurrentTemp float64 `json:"currentTemp"`
SubDomainID int `json:"subDomainId"`
}
type authReqBody struct {
Account string `json:"account"`
Password string `json:"password"`
}
type authResBody struct {
Token string `json:"token"`
UserID int `json:"userId"`
NickName string `json:"nickName"`
TokenExpire string `json:"tokenExpire"`
}
type listHomeReqBody struct{}
type listHomeResBody struct {
HomeList []millHome `json:"homeList"`
}
type listDeviceReqBody struct {
HomeID int64 `json:"homeId"`
}
type listDeviceResBody struct {
DeviceInfo []millDevice `json:"deviceInfo"`
}
type changeInfoReqBody struct {
HomeType int `json:"homeType"`
DeviceID int `json:"deviceId"`
Value int `json:"value"`
TimeZoneNum string `json:"timeZoneNum"`
Key string `json:"key"`
}
type deviceControlReqBody struct {
SubDomain string `json:"subDomain"`
DeviceID int `json:"deviceId"`
TestStatus int `json:"testStatus"`
Operation int `json:"operation"`
Status int `json:"status"`
WindStatus int `json:"windStatus"`
TempType int `json:"tempType"`
PowerLevel int `json:"powerLevel"`
}
var location *time.Location
func init() {
myLocation, err := time.LoadLocation("Europe/Oslo")
if err != nil {
panic(err.Error())
}
location = myLocation
}
+14 -4
View File
@@ -37,12 +37,22 @@ func (b *bridge) Devices() []models.Device {
hue, sat, value := colorful.LinearRgb(red, green, blue).Hsv()
shapeType, shapeTypeOK := shapeTypeMap[panel.ShapeType]
if !shapeTypeOK {
shapeType = "NanoLeaf"
}
shapeIcon, shapeIconOK := shapeIconMap[panel.ShapeType]
if !shapeIconOK {
shapeIcon = "lightbulb"
}
results = append(results, models.Device{
ID: -1,
BridgeID: b.externalID,
InternalID: strconv.Itoa(int(panel.ID)),
Icon: "hexagon",
Name: fmt.Sprintf("Hexagon %d", i),
Icon: shapeIcon,
Name: fmt.Sprintf("%s %d", shapeType, i+1),
Capabilities: []models.DeviceCapability{
models.DCPower,
models.DCColorHS,
@@ -83,7 +93,7 @@ func (b *bridge) Refresh(ctx context.Context) error {
b.mu.Lock()
PanelLoop:
for _, panelInfo := range overview.PanelLayout.Data.PositionData {
if panelInfo.PanelID == 0 {
if shapeTypeMap[panelInfo.ShapeType] == "Shapes Controller" {
continue
}
@@ -167,7 +177,7 @@ func (b *bridge) Update(devices []models.Device) {
red, green, blue := color.RGB255()
newColor := [4]byte{red, green, blue, 255}
if newColor != panel.ColorRGBA {
panel.update(newColor, time.Now().Add(time.Millisecond*120))
panel.update(newColor, time.Now().Add(time.Millisecond*220))
}
} else {
panel.update([4]byte{0, 0, 0, 0}, time.Now())
+12
View File
@@ -135,6 +135,18 @@ var shapeTypeMap = map[int]string{
12: "Shapes Controller",
}
var shapeIconMap = map[int]string{
0: "triangle",
1: "rhythm",
2: "Square",
3: "square",
4: "square",
7: "hexagon",
8: "triangle",
9: "triangle-small",
12: "hexagon",
}
var shapeWidthMap = map[int]int{
0: 150,
1: -1,
+1 -1
View File
@@ -17,7 +17,7 @@ type Driver struct {
// SearchBridge checks the bridge at the address. If it's not a dry-run, you must hold down the power button
// before calling this function and wait for the pattern.
func (d *Driver) SearchBridge(ctx context.Context, address string, dryRun bool) ([]models.Bridge, error) {
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) {
res, err := http.Get(fmt.Sprintf("http://%s/device_info", address))
if err != nil {
return nil, err
+128 -89
View File
@@ -12,22 +12,24 @@ 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 {
DeviceID int `db:"device_id"`
Hue float64 `db:"hue"`
Saturation float64 `db:"saturation"`
Kelvin int `db:"kelvin"`
Power bool `db:"power"`
Intensity float64 `db:"intensity"`
DeviceID int `db:"device_id"`
Hue float64 `db:"hue"`
Saturation float64 `db:"saturation"`
Kelvin int `db:"kelvin"`
Power bool `db:"power"`
Intensity float64 `db:"intensity"`
Temperature int `db:"temperature"`
}
type devicePropertyRecord struct {
@@ -117,114 +119,147 @@ 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) 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)
}
if err != nil {
return dbErr(err)
}
// Let's just be lazy for now, optimize later if need be.
_, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID)
if err != nil {
return dbErr(err)
}
_, 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, `
// 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)
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)
}
lastID, err := res.LastInsertId()
if err != nil {
return dbErr(err)
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)
}
}
}
record.ID = int(lastID)
device.ID = int(lastID)
}
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 _, 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)
}
}
for key, value := range device.DriverProperties {
j, err := json.Marshal(value)
if err != nil {
// Eh, it'll get filled by the driver anyway
continue
}
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)
}
}
}
_, 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, `
REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity, temperature)
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity, :temperature)
`, 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,
Temperature: device.State.Temperature,
})
if err != nil {
return dbErr(err)
}
}
}
_, 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)
}
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 {
@@ -317,12 +352,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]
}
@@ -343,6 +381,7 @@ func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]mo
Kelvin: state.Kelvin,
},
Intensity: state.Intensity,
Temperature: state.Temperature,
}
}
}
+17 -2
View File
@@ -14,6 +14,8 @@ type eventHandlerRecord struct {
Priority int `db:"priority"`
TargetKind string `db:"target_kind"`
TargetValue string `db:"target_value"`
From *string `db:"from_tod"`
To *string `db:"to_tod"`
ConditionsJSON json.RawMessage `db:"conditions"`
ActionsJSON json.RawMessage `db:"actions"`
}
@@ -61,12 +63,14 @@ func (r *EventHandlerRepo) Save(ctx context.Context, handler *models.EventHandle
TargetValue: handler.TargetValue,
ConditionsJSON: conditionsJson,
ActionsJSON: actionsJson,
From: handler.From.StringPtr(),
To: handler.To.StringPtr(),
}
if record.ID == 0 {
res, err := r.DBX.NamedExecContext(ctx, `
INSERT INTO event_handler (event_name, one_shot, priority, target_kind, target_value, conditions, actions)
VALUES (:event_name, :one_shot, :priority, :target_kind, :target_value, :conditions, :actions);
INSERT INTO event_handler (event_name, one_shot, priority, target_kind, target_value, conditions, from_tod, to_tod, actions)
VALUES (:event_name, :one_shot, :priority, :target_kind, :target_value, :conditions, :from_tod, :to_tod, :actions);
`, record)
if err != nil {
return dbErr(err)
@@ -87,6 +91,8 @@ func (r *EventHandlerRepo) Save(ctx context.Context, handler *models.EventHandle
target_kind = :target_kind,
target_value = :target_value,
conditions = :conditions,
from_tod = :from_tod,
to_tod = :to_tod,
actions = :actions
WHERE id = :id
`, record)
@@ -139,6 +145,15 @@ func (r *EventHandlerRepo) populate(records []eventHandlerRecord) ([]models.Even
return nil, dbErr(err)
}
handler.From, err = models.ParseTimeOfDayPtr(record.From)
if err != nil {
return nil, dbErr(err)
}
handler.To, err = models.ParseTimeOfDayPtr(record.To)
if err != nil {
return nil, dbErr(err)
}
res = append(res, handler)
}
+123
View File
@@ -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
}
+1
View File
@@ -23,6 +23,7 @@ var (
DTHue DriverKind = "Hue"
DTNanoLeaf DriverKind = "Nanoleaf"
DTLIFX DriverKind = "LIFX"
DTMill DriverKind = "Mill"
)
var ValidDriverKinds = []DriverKind{
+3 -1
View File
@@ -26,9 +26,11 @@ func (c *ColorValue) String() string {
return fmt.Sprintf("k:%d", c.Kelvin)
}
return fmt.Sprintf("hs:%g,%g", c.Hue, c.Saturation)
return fmt.Sprintf("hs:%.4g,%.3g", c.Hue, c.Saturation)
}
func ParseColorValue(raw string) (ColorValue, error) {
tokens := strings.SplitN(raw, ":", 2)
if len(tokens) != 2 {
+101 -15
View File
@@ -3,25 +3,28 @@ package models
import (
"context"
"strings"
"time"
)
type Device struct {
ID int `json:"id"`
BridgeID int `json:"bridgeID"`
InternalID string `json:"internalId"`
Icon string `json:"icon"`
Name string `json:"name"`
Capabilities []DeviceCapability `json:"capabilities"`
ButtonNames []string `json:"buttonNames"`
DriverProperties map[string]interface{} `json:"driverProperties"`
UserProperties map[string]string `json:"userProperties"`
State DeviceState `json:"state"`
Tags []string `json:"tags"`
ID int `json:"id"`
BridgeID int `json:"bridgeID"`
InternalID string `json:"internalId"`
Icon string `json:"icon"`
Name string `json:"name"`
Capabilities []DeviceCapability `json:"capabilities"`
ButtonNames []string `json:"buttonNames"`
DriverProperties map[string]interface{} `json:"driverProperties"`
UserProperties map[string]string `json:"userProperties"`
SceneAssignments []DeviceSceneAssignment `json:"sceneAssignments"`
SceneState *DeviceState `json:"sceneState"`
State DeviceState `json:"state"`
Tags []string `json:"tags"`
}
type DeviceUpdate struct {
Icon *string `json:"icon"`
Name *string `json:"name"`
Icon *string `json:"icon"`
Name *string `json:"name"`
UserProperties map[string]*string `json:"userProperties"`
}
@@ -34,7 +37,14 @@ type DeviceState struct {
Power bool `json:"power"`
Color ColorValue `json:"color,omitempty"`
Intensity float64 `json:"intensity,omitempty"`
Temperature float64 `json:"temperature"`
Temperature int `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 {
@@ -46,10 +56,19 @@ type NewDeviceState struct {
type DeviceCapability string
type SaveMode int
const (
SMState SaveMode = 1
SMProperties SaveMode = 2
SMTags SaveMode = 4
)
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) 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
}
@@ -136,6 +155,17 @@ func (d *Device) HasTag(tags ...string) bool {
return false
}
func (d *Device) IsOnlySensor() bool {
return !d.HasCapability(
DCPower,
DCColorHS,
DCColorHSK,
DCColorKelvin,
DCIntensity,
DCTemperatureControl,
)
}
func (d *Device) HasCapability(capabilities ...DeviceCapability) bool {
for _, c := range d.Capabilities {
for _, c2 := range capabilities {
@@ -168,5 +198,61 @@ func (d *Device) SetState(newState NewDeviceState) error {
d.State.Intensity = *newState.Intensity
}
if newState.Temperature != nil && d.HasCapability(DCTemperatureControl) {
d.State.Temperature = *newState.Temperature
}
return nil
}
func (s *NewDeviceState) RelativeTo(device Device) NewDeviceState {
n := NewDeviceState{}
if s.Intensity != nil {
intensity := device.State.Intensity * *s.Intensity
n.Intensity = &intensity
}
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))
}
+5 -1
View File
@@ -10,9 +10,13 @@ type DriverProvider interface {
}
type Driver interface {
SearchBridge(ctx context.Context, address string, dryRun bool) ([]Bridge, error)
SearchBridge(ctx context.Context, address, token string, dryRun bool) ([]Bridge, error)
SearchDevices(ctx context.Context, bridge Bridge, timeout time.Duration) ([]Device, error)
ListDevices(ctx context.Context, bridge Bridge) ([]Device, error)
Publish(ctx context.Context, bridge Bridge, devices []Device) error
Run(ctx context.Context, bridge Bridge, ch chan<- Event) error
}
type ForgettableDriver interface {
ForgetDevice(ctx context.Context, bridge Bridge, device Device) error
}
+9
View File
@@ -15,6 +15,8 @@ var ErrIncorrectToken = errors.New("driver is not accepting authentication infor
var ErrUnexpectedResponse = errors.New("driver api returned unexpected response (wrong driver selected?)")
var ErrBridgeSearchFailed = errors.New("bridge search failed")
var ErrAddressOnlyDryRunnable = errors.New("this address may only be used for a dry run")
var ErrCannotForwardRequest = errors.New("driver is not able to forward requests")
var ErrCannotForget = errors.New("forget not supported on this bridge type")
var ErrInvalidAddress = errors.New("invalid mac address")
var ErrPayloadTooShort = errors.New("payload too short")
@@ -22,3 +24,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")
-1
View File
@@ -24,7 +24,6 @@ var (
ENBridgeDisconnected = "BridgeDisconnected"
ENButtonPressed = "ButtonPressed"
ENSensorPresenceStarted = "SensorPresenceStarted"
ENSensorPresenceEnding = "SensorPresenceEnding"
ENSensorPresenceEnded = "SensorPresenceEnded"
ENSensorTemperature = "SensorTemperature"
)
+68 -9
View File
@@ -5,6 +5,7 @@ import (
"regexp"
"strconv"
"strings"
"time"
)
type EventHandler struct {
@@ -16,6 +17,8 @@ type EventHandler struct {
TargetKind ReferenceKind `json:"targetKind"`
TargetValue string `json:"targetValue"`
Actions EventAction `json:"actions"`
From TimeOfDay `json:"from"`
To TimeOfDay `json:"to"`
}
type EventHandlerRepository interface {
@@ -42,6 +45,8 @@ type EventHandlerUpdate struct {
SetConditions map[string]EventCondition `json:"setConditions"`
PatchConditions map[string]*EventCondition `json:"patchConditions"`
SetActions *EventAction `json:"setActions"`
SetFrom *TimeOfDay `json:"setFrom"`
SetTo *TimeOfDay `json:"setTo"`
}
func (h *EventHandler) ApplyUpdate(update EventHandlerUpdate) {
@@ -63,6 +68,12 @@ func (h *EventHandler) ApplyUpdate(update EventHandlerUpdate) {
if update.SetOneShot != nil {
h.OneShot = *update.SetOneShot
}
if update.SetFrom != nil {
h.From = *update.SetFrom
}
if update.SetFrom != nil {
h.To = *update.SetFrom
}
if update.SetConditions != nil {
h.Conditions = update.SetConditions
@@ -100,6 +111,14 @@ func (h *EventHandler) MatchesEvent(event Event, targets []Device) bool {
return true
}
func (h *EventHandler) IsWithinTimeRange() bool {
if h.From.IsNever() || h.To.IsNever() {
return true
}
return CurrentTimeOfDay().IsBetween(h.From, h.To)
}
func (c *EventCondition) check(key, value string, targets []Device) bool {
any := strings.HasPrefix(key, "any.")
all := strings.HasPrefix(key, "all.")
@@ -149,17 +168,27 @@ func (c *EventCondition) checkDevice(key string, device Device) (matches bool, s
return false, true
}
return c.matches(strconv.FormatFloat(device.State.Temperature, 'f', -1, 64)), false
return c.matches(strconv.Itoa(device.State.Temperature)), false
case "scene":
sceneId := -1
for _, assignment := range device.SceneAssignments {
duration := time.Duration(assignment.DurationMS) * time.Millisecond
if duration <= 0 || time.Now().Before(assignment.StartTime.Add(duration)) {
sceneId = assignment.SceneID
}
}
return c.matches(strconv.Itoa(sceneId)), false
default:
return false, true
}
}
var numRegex = regexp.MustCompile("^{-[0-9].}+$")
var numRegex = regexp.MustCompile("^-*[0-9]+(.[0-9]+)*$")
func (c *EventCondition) matches(value string) bool {
if numRegex.MatchString(value) {
numValue, _ := strconv.ParseFloat(c.LT, 64)
numValue, _ := strconv.ParseFloat(value, 64)
stillAlive := true
if c.LT != "" {
@@ -179,7 +208,7 @@ func (c *EventCondition) matches(value string) bool {
if stillAlive && c.GTE != "" {
gte, _ := strconv.ParseFloat(c.GTE, 64)
stillAlive = numValue == gte
stillAlive = numValue >= gte
}
if stillAlive && c.GT != "" {
@@ -196,11 +225,14 @@ func (c *EventCondition) matches(value string) bool {
}
type EventAction struct {
SetPower *bool `json:"setPower"`
SetColor *string `json:"setColor"`
SetIntensity *float64 `json:"setIntensity"`
AddIntensity *float64 `json:"addIntensity"`
FireEvent *Event `json:"fireEvent"`
SetPower *bool `json:"setPower"`
SetColor *string `json:"setColor"`
SetIntensity *float64 `json:"setIntensity"`
SetTemperature *int `json:"setTemperature"`
AddIntensity *float64 `json:"addIntensity"`
FireEvent *Event `json:"fireEvent"`
SetScene *DeviceSceneAssignment `json:"setScene"`
PushScene *DeviceSceneAssignment `json:"pushScene"`
}
func (action *EventAction) Apply(other EventAction) {
@@ -219,4 +251,31 @@ func (action *EventAction) Apply(other EventAction) {
if action.FireEvent == nil {
action.FireEvent = other.FireEvent
}
if action.SetScene == nil {
action.SetScene = other.SetScene
}
if action.PushScene == nil {
action.PushScene = other.PushScene
}
}
func (c EventCondition) String() string {
str := make([]string, 0, 5)
if len(c.LT) > 0 {
str = append(str, "lt:"+c.LT)
}
if len(c.LTE) > 0 {
str = append(str, "lte:"+c.LTE)
}
if len(c.EQ) > 0 {
str = append(str, c.EQ)
}
if len(c.GTE) > 0 {
str = append(str, "gte:"+c.GTE)
}
if len(c.GT) > 0 {
str = append(str, "gte:"+c.GT)
}
return strings.Join(str, ";")
}
+312
View File
@@ -0,0 +1,312 @@
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"
SEMotion SceneEffect = "Motion"
SETemperature SceneEffect = "Temperature"
)
type SceneRole struct {
Effect SceneEffect `json:"effect"`
MotionSeconds float64 `json:"motionSeconds"`
MinTemperature int `json:"minTemperature"`
MaxTemperature int `json:"maxTemperature"`
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 {
CurrentTime time.Time
Index int
Length int
IntervalNumber int64
IntervalMax int64
Sensors []SceneSensor
}
type SceneSensor struct {
ID int
UpdateTime time.Time
Temperature *float64
Presence *bool
}
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, SEMotion, SETemperature:
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())
case SEMotion:
presence := false
absenceTime := time.Time{}
for _, sensors := range c.Sensors {
if sensors.Presence == nil {
continue
}
if *sensors.Presence {
presence = true
} else {
if sensors.UpdateTime.After(absenceTime) {
absenceTime = sensors.UpdateTime
}
}
}
if presence {
newState = r.State(0.0)
} else {
fac := c.CurrentTime.Sub(absenceTime).Seconds() / r.MotionSeconds
if fac < 0 {
fac = 0
} else if fac > 1 {
fac = 1
}
newState = r.State(fac)
}
case SETemperature:
avg := 0.0
count := 0
for _, sensor := range c.Sensors {
if sensor.Temperature == nil {
continue
}
if count == 0 {
avg = *sensor.Temperature
count = 1
} else {
avg = ((avg * float64(count)) + *sensor.Temperature) / float64(count+1)
count += 1
}
}
}
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
}
+76
View File
@@ -1,5 +1,10 @@
package models
import (
"strconv"
"strings"
)
type ReferenceKind string
var (
@@ -9,3 +14,74 @@ var (
RKAll ReferenceKind = "All"
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:]
} else if strings.HasPrefix(fetchStr, "bridge:") {
return RKBridgeID, fetchStr[7:]
} else if strings.HasPrefix(fetchStr, "id:") {
return RKDeviceID, fetchStr[7:]
} else if strings.HasPrefix(fetchStr, "name:") {
return RKName, fetchStr[7:]
} else if fetchStr == "all" {
return RKAll, ""
} else {
_, err := strconv.Atoi(fetchStr)
if err != nil {
return RKName, fetchStr
}
return RKDeviceID, fetchStr
}
}
+140
View File
@@ -0,0 +1,140 @@
package models
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
var TimeOfDayTimeZone = time.UTC
type TimeOfDay int
func (t *TimeOfDay) UnmarshalJSON(v []byte) error {
if bytes.Equal(v, []byte("null")) {
*t = -1
}
var str string
err := json.Unmarshal(v, &str)
if err != nil {
var n int
err = json.Unmarshal(v, &n)
if err != nil {
return err
}
if n < -1 && n >= 86400 {
return fmt.Errorf("value outside range 0..86400: %d", n)
}
}
t2, err := ParseTimeOfDay(str)
if err != nil {
return err
}
*t = t2
return nil
}
func (t TimeOfDay) MarshalJSON() ([]byte, error) {
if t.IsNever() {
return []byte("null"), nil
}
return json.Marshal(t.String())
}
const Never = TimeOfDay(-1)
func (t *TimeOfDay) String() string {
if *t < 0 {
return "n/a"
}
return fmt.Sprintf("%02d:%02d:%02d", *t/3600, (*t/60)%60, *t%60)
}
func (t *TimeOfDay) StringPtr() *string {
if *t < 0 {
return nil
}
s := t.String()
return &s
}
func (t TimeOfDay) IsNever() bool {
return t == Never
}
func (t TimeOfDay) IsBetween(from TimeOfDay, to TimeOfDay) bool {
if from == to {
return t == from
} else if from > to {
return t >= from || t <= to
} else {
return t >= from && t <= to
}
}
func CurrentTimeOfDay() TimeOfDay {
return TimeOfDayFromDate(time.Now().In(TimeOfDayTimeZone))
}
func TimeOfDayFromDate(date time.Time) TimeOfDay {
localDate := date.In(TimeOfDayTimeZone)
return NewTimeOfDay(localDate.Hour(), localDate.Minute(), localDate.Second())
}
func NewTimeOfDay(hours, minutes, seconds int) TimeOfDay {
return TimeOfDay(((hours % 24) * 3600) + ((minutes % 60) * 60) + seconds)
}
func ParseTimeOfDayPtr(str *string) (TimeOfDay, error) {
if str == nil {
return Never, nil
}
return ParseTimeOfDay(*str)
}
func ParseTimeOfDay(str string) (TimeOfDay, error) {
if str == "" || str == "n/a" || str == "N/A" {
return -1, nil
}
split := strings.SplitN(str, ":", 4)
res := 0
n, err := strconv.Atoi(split[0])
if err != nil {
return Never, err
}
res += n * 3600
if len(split) >= 2 {
n, err := strconv.Atoi(split[1])
if err != nil {
return Never, err
}
res += n * 60
}
if len(split) >= 3 {
n, err := strconv.Atoi(split[2])
if err != nil {
return Never, err
}
res += n
}
if res >= 86400 || res < 0 {
return Never, fmt.Errorf("invalid time of day string %s (=%d)", str, res)
}
return TimeOfDay(res), nil
}
+42
View File
@@ -0,0 +1,42 @@
package models
import (
"fmt"
"testing"
)
func tod(s string) TimeOfDay {
tod, err := ParseTimeOfDay(s)
if err != nil {
panic(err)
}
return tod
}
func TestTimeOfDay_IsBetween(t *testing.T) {
table := []struct{
Value TimeOfDay
From TimeOfDay
To TimeOfDay
Expected bool
}{
{ tod("16:13:11"), tod("07"), tod("21:30"), true },
{ tod("16:13:31"), tod("21:30"), tod("07"), false },
{ tod("23:15:32"), tod("21:30"), tod("07"), true },
{ tod("04:13:57"), tod("21:30"), tod("07"), true },
{ tod("16:14:43"), tod("15:30"), tod("16:00"), false },
{ tod("16:14:43"), tod("15:30"), tod("16:15"), true },
{ tod("08:45:43"), tod("07:00:00"), tod("21:30:00"), true },
{ tod("07:00:00"), tod("07:00:00"), tod("21:30:00"), true },
}
for i, row := range table {
t.Run(fmt.Sprintf("row_%d", i), func (t *testing.T) {
if row.Value.IsBetween(row.From, row.To) != row.Expected {
t.Log(row.Value, row.From, row.To)
t.Fail()
}
})
}
}
+63
View File
@@ -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
+26
View File
@@ -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
+57
View File
@@ -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
+40
View File
@@ -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
+42
View File
@@ -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
+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
@@ -0,0 +1,13 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE event_handler
ADD COLUMN from_tod VARCHAR(255),
ADD COLUMN to_tod VARCHAR(255);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE event_handler
DROP COLUMN IF EXISTS from_tod,
DROP COLUMN IF EXISTS to_tod;
-- +goose StatementEnd
@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE device_state
ADD COLUMN temperature INT NOT NULL DEFAULT 0;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE device_state
DROP COLUMN temperature;
-- +goose StatementEnd