Compare commits
68
Commits
7406a25003
...
3.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6947fc3461 | ||
|
|
600c90a614 | ||
|
|
a08685c3cd | ||
|
|
15536c6078 | ||
|
|
2bb2154e5b | ||
|
|
8c2ef2dac3 | ||
|
|
f4e584938b | ||
|
|
8c9b47aa1d | ||
|
|
5bd7a7f6b6 | ||
|
|
88bd4dc073 | ||
|
|
6443e0ecd3 | ||
|
|
147231e4fd | ||
|
|
b191455753 | ||
|
|
ba22187e6e | ||
|
|
d91bd472dd | ||
|
|
251687b85d | ||
|
|
ade49589a9 | ||
|
|
ad2f912a6f | ||
|
|
2abb4b1024 | ||
|
|
b83673b2cc | ||
|
|
6fe0d8b864 | ||
|
|
c7d7e264ca | ||
|
|
0cd325e8db | ||
|
|
b1fa52e53f | ||
|
|
b28496fd6b | ||
|
|
35c3a387c9 | ||
|
|
b7b6b06748 | ||
|
|
27f8c6b91d | ||
|
|
0846290047 | ||
|
|
9b9e5e9da8 | ||
|
|
3c2aa7212d | ||
|
|
d6c38d647f | ||
|
|
06fa73b916 | ||
|
|
7a6ee3f87b | ||
|
|
d623647e21 | ||
|
|
4fd49d0480 | ||
|
|
38d777404b | ||
|
|
2f9bd3c007 | ||
|
|
56a0525b9d | ||
|
|
1cf8c019e5 | ||
|
|
b2c9cca400 | ||
|
|
4109d41bec | ||
|
|
616adc14a4 | ||
|
|
26fcc1f314 | ||
|
|
4e734c86a5 | ||
|
|
766c534fcb | ||
|
|
79fc2a319c | ||
|
|
e9bfbfbec7 | ||
|
|
b2375e5864 | ||
|
|
22c80a90a1 | ||
|
|
95ec918ed1 | ||
|
|
fff01befda | ||
|
|
ea3037b03a | ||
|
|
677d9aa9b1 | ||
|
|
5cdb4f3cf5 | ||
|
|
74fb2c6b5a | ||
|
|
2aa2757828 | ||
|
|
38d370894e | ||
|
|
a2e719def9 | ||
|
|
883115bf8b | ||
|
|
954d6bd3e1 | ||
|
|
e1f38e3dcd | ||
|
|
f045bc0f4d | ||
|
|
e4078de845 | ||
|
|
d6fdb48023 | ||
|
|
11ef2997c8 | ||
|
|
21157e4330 | ||
|
|
f7f8989d20 |
+31
@@ -0,0 +1,31 @@
|
||||
name: lucifer3
|
||||
|
||||
kind: pipeline
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
- name: go-test
|
||||
image: golang:1.17
|
||||
commands:
|
||||
- go test -v ./...
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- tag
|
||||
|
||||
- name: docker-backend-tag
|
||||
image: plugins/docker
|
||||
settings:
|
||||
auto_tag: true
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
repo: r.vmaple.dev/lucifer/backend
|
||||
registry: r.vmaple.dev
|
||||
dockerfile: docker/backend/Dockerfile
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
|
||||
@@ -2,3 +2,10 @@
|
||||
.env
|
||||
.idea/
|
||||
*.iml
|
||||
/bridgetest
|
||||
/server
|
||||
/goose
|
||||
/bridgetest.exe
|
||||
/server.exe
|
||||
/goose.exe
|
||||
/10.24.4.1
|
||||
@@ -0,0 +1,144 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Bridges(r gin.IRoutes) {
|
||||
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return config.BridgeRepository().FetchAll(ctxOf(c))
|
||||
}))
|
||||
|
||||
r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
}))
|
||||
|
||||
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
|
||||
var body struct {
|
||||
Driver models.DriverKind `json:"driver"`
|
||||
Address string `json:"address"`
|
||||
Token string `json:"token"`
|
||||
ForceSave bool `json:"forceSave"`
|
||||
DryRun bool `json:"dryRun"`
|
||||
}
|
||||
err := parseBody(c, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
driver, err := config.DriverProvider().Provide(body.Driver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if body.ForceSave {
|
||||
bridge := models.Bridge{
|
||||
Name: fmt.Sprintf("Manually saved %s bridge (%s)", body.Driver, body.Address),
|
||||
Driver: body.Driver,
|
||||
Address: body.Address,
|
||||
Token: body.Token,
|
||||
}
|
||||
|
||||
err := config.BridgeRepository().Save(ctxOf(c), &bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []models.Bridge{bridge}, nil
|
||||
}
|
||||
|
||||
bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.Token, body.DryRun)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !body.DryRun {
|
||||
for _, bridge := range bridges {
|
||||
err := config.BridgeRepository().Save(ctxOf(c), &bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("Saved new bridge: %s (%s)", bridge.Name, bridge.Address)
|
||||
}
|
||||
}
|
||||
|
||||
return bridges, nil
|
||||
}))
|
||||
|
||||
r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
}
|
||||
err := parseBody(c, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if body.Name != nil {
|
||||
bridge.Name = *body.Name
|
||||
}
|
||||
|
||||
err = config.BridgeRepository().Save(ctxOf(c), &bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bridge, nil
|
||||
}))
|
||||
|
||||
r.PUT("/:id/search", handler(func(c *gin.Context) (interface{}, error) {
|
||||
var body struct {
|
||||
TimeoutMS int64 `json:"timeoutMs"`
|
||||
}
|
||||
err := parseBody(c, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if body.TimeoutMS <= 0 {
|
||||
body.TimeoutMS = 30000
|
||||
}
|
||||
|
||||
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices, err := driver.SearchDevices(ctxOf(c), bridge, time.Millisecond * time.Duration(body.TimeoutMS))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}))
|
||||
|
||||
r.DELETE("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = config.BridgeRepository().Delete(ctxOf(c), &bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bridge, nil
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
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"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
var body []struct {
|
||||
Fetch string `json:"fetch"`
|
||||
SetState models.NewDeviceState `json:"setState"`
|
||||
}
|
||||
err := parseBody(c, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
set := make(map[int]bool)
|
||||
changed := make([]models.Device, 0, 64)
|
||||
for _, job := range body {
|
||||
devices, err := fetchDevices(ctxOf(c), job.Fetch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
return []models.Device{}, nil
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
if set[devices[i].ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
err := devices[i].SetState(job.SetState)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
set[devices[i].ID] = true
|
||||
changed = append(changed, devices[i])
|
||||
}
|
||||
}
|
||||
|
||||
config.PublishChannel <- changed
|
||||
|
||||
go func() {
|
||||
for _, device := range changed {
|
||||
err := config.DeviceRepository().Save(context.Background(), &device, models.SMState)
|
||||
if err != nil {
|
||||
log.Println("Failed to save device for state:", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return withSceneState(changed), nil
|
||||
}))
|
||||
|
||||
r.PUT("/:fetch", handler(func(c *gin.Context) (interface{}, error) {
|
||||
update := models.DeviceUpdate{}
|
||||
err := parseBody(c, &update)
|
||||
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
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
devices[i].ApplyUpdate(update)
|
||||
|
||||
err := config.DeviceRepository().Save(context.Background(), &devices[i], models.SMProperties)
|
||||
if err != nil {
|
||||
log.Println("Failed to save device for state:", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return withSceneState(devices), nil
|
||||
}))
|
||||
|
||||
r.PUT("/:fetch/state", handler(func(c *gin.Context) (interface{}, error) {
|
||||
state := models.NewDeviceState{}
|
||||
err := parseBody(c, &state)
|
||||
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
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
err := devices[i].SetState(state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
config.PublishChannel <- devices
|
||||
|
||||
err = config.DeviceRepository().SaveMany(ctxOf(c), models.SMState, devices)
|
||||
if err != nil {
|
||||
log.Println("Failed to save devices states")
|
||||
}
|
||||
|
||||
return withSceneState(devices), nil
|
||||
}))
|
||||
|
||||
r.PUT("/:fetch/tags", handler(func(c *gin.Context) (interface{}, error) {
|
||||
var body struct {
|
||||
Add []string `json:"add"`
|
||||
Remove []string `json:"remove"`
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
device := &devices[i]
|
||||
|
||||
for _, tag := range body.Add {
|
||||
found := false
|
||||
for _, tag2 := range device.Tags {
|
||||
if tag == tag2 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
device.Tags = append(device.Tags, tag)
|
||||
}
|
||||
}
|
||||
for _, tag := range body.Remove {
|
||||
index := -1
|
||||
for i, tag2 := range device.Tags {
|
||||
if tag == tag2 {
|
||||
index = i
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
device.Tags = append(device.Tags[:index], device.Tags[index+1:]...)
|
||||
}
|
||||
|
||||
err = config.DeviceRepository().Save(ctxOf(c), device, models.SMTags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func EventHandlers(r gin.IRoutes) {
|
||||
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return config.EventHandlerRepository().FetchAll(ctxOf(c))
|
||||
}))
|
||||
|
||||
r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return config.EventHandlerRepository().FindByID(ctxOf(c), intParam(c, "id"))
|
||||
}))
|
||||
|
||||
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
|
||||
var body models.EventHandler
|
||||
err := parseBody(c, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if body.Conditions == nil {
|
||||
body.Conditions = make(map[string]models.EventCondition)
|
||||
}
|
||||
|
||||
err = config.EventHandlerRepository().Save(ctxOf(c), &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}))
|
||||
|
||||
r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
|
||||
|
||||
handler, err := config.EventHandlerRepository().FindByID(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return handler, nil
|
||||
}))
|
||||
|
||||
r.DELETE("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
handler, err := config.EventHandlerRepository().FindByID(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = config.EventHandlerRepository().Delete(ctxOf(c), handler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return handler, nil
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ColorPresets(r gin.IRoutes) {
|
||||
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return config.ColorPresetRepository().FetchAll(ctxOf(c))
|
||||
}))
|
||||
|
||||
r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return config.ColorPresetRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
}))
|
||||
|
||||
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
ColorString string `json:"colorString"`
|
||||
}
|
||||
err := parseBody(c, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newColor, err := models.ParseColorValue(body.ColorString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
preset := models.ColorPreset{
|
||||
Name: body.Name,
|
||||
Value: newColor,
|
||||
}
|
||||
|
||||
preset.Validate()
|
||||
err = config.ColorPresetRepository().Save(ctxOf(c), &preset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return preset, nil
|
||||
}))
|
||||
|
||||
r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
ColorString *string `json:"colorString"`
|
||||
}
|
||||
err := parseBody(c, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
preset, err := config.ColorPresetRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if body.Name != nil {
|
||||
preset.Name = *body.Name
|
||||
}
|
||||
|
||||
if body.ColorString != nil {
|
||||
newColor, err := models.ParseColorValue(*body.ColorString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
preset.Value = newColor
|
||||
}
|
||||
|
||||
preset.Validate()
|
||||
err = config.ColorPresetRepository().Save(ctxOf(c), &preset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return preset, nil
|
||||
}))
|
||||
|
||||
r.DELETE("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
preset, err := config.ColorPresetRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = config.ColorPresetRepository().Delete(ctxOf(c), &preset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return preset, nil
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
+34
-3
@@ -1,13 +1,27 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var errorMap = map[error]int{
|
||||
models.ErrInvalidName: 400,
|
||||
models.ErrNotFound: 404,
|
||||
models.ErrInvalidName: 400,
|
||||
models.ErrBadInput: 400,
|
||||
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 {
|
||||
@@ -26,16 +40,29 @@ func handler(fun func(c *gin.Context) (interface{}, error)) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.JSON(errCode, response{
|
||||
Code: errCode,
|
||||
Code: errCode,
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, val)
|
||||
c.JSON(200, response{
|
||||
Code: 200,
|
||||
Message: "success",
|
||||
Data: val,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func intParam(c *gin.Context, key string) int {
|
||||
i, err := strconv.Atoi(c.Param(key))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
|
||||
func parseBody(c *gin.Context, target interface{}) error {
|
||||
err := json.NewDecoder(c.Request.Body).Decode(target)
|
||||
if err != nil {
|
||||
@@ -44,3 +71,7 @@ func parseBody(c *gin.Context, target interface{}) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ctxOf(c *gin.Context) context.Context {
|
||||
return c.Request.Context()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
APIRoot string
|
||||
}
|
||||
|
||||
func (client *Client) GetDevices(ctx context.Context, fetchStr string) ([]models.Device, error) {
|
||||
devices := make([]models.Device, 0, 16)
|
||||
err := client.Fetch(ctx, "GET", "/api/devices/"+fetchStr, &devices, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func (client *Client) PutDevice(ctx context.Context, fetchStr string, update models.DeviceUpdate) ([]models.Device, error) {
|
||||
devices := make([]models.Device, 0, 16)
|
||||
err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr, &devices, update)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func (client *Client) PutDeviceState(ctx context.Context, fetchStr string, update models.NewDeviceState) ([]models.Device, error) {
|
||||
devices := make([]models.Device, 0, 16)
|
||||
err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr+"/state", &devices, update)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func (client *Client) PutDeviceTags(ctx context.Context, fetchStr string, addTags []string, removeTags []string) ([]models.Device, error) {
|
||||
devices := make([]models.Device, 0, 16)
|
||||
err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr+"/tags", &devices, map[string][]string{
|
||||
"add": addTags,
|
||||
"remove": removeTags,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *Client) Fetch(ctx context.Context, method string, path string, dst interface{}, body interface{}) error {
|
||||
var reqBody io.ReadWriter
|
||||
if body != nil && method != "GET" {
|
||||
reqBody = bytes.NewBuffer(make([]byte, 0, 512))
|
||||
|
||||
err := json.NewEncoder(reqBody).Encode(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, client.APIRoot+path, reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := httpClient.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if !strings.HasPrefix(res.Header.Get("Content-Type"), "application/json") {
|
||||
return fmt.Errorf("%s: %s", path, res.Status)
|
||||
}
|
||||
|
||||
var resJson struct {
|
||||
Code int `json:"code"`
|
||||
Message *string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
err = json.NewDecoder(res.Body).Decode(&resJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resJson.Code != 200 {
|
||||
msg := ""
|
||||
if resJson.Message != nil {
|
||||
msg = *resJson.Message
|
||||
}
|
||||
|
||||
return fmt.Errorf("%d: %s", resJson.Code, msg)
|
||||
}
|
||||
|
||||
if dst == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal(resJson.Data, dst)
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
IdleConnTimeout: time.Minute,
|
||||
},
|
||||
Timeout: time.Minute,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,3 +5,5 @@ import "git.aiterp.net/lucifer/new-server/models"
|
||||
var EventChannel = make(chan models.Event, 8)
|
||||
|
||||
var ChangeChannel = make(chan string, 16)
|
||||
|
||||
var PublishChannel = make(chan []models.Device, 32)
|
||||
|
||||
@@ -23,6 +23,10 @@ func DBX() *sqlx.DB {
|
||||
MySqlPort(),
|
||||
MySqlSchema(),
|
||||
))
|
||||
|
||||
dbx.SetMaxIdleConns(20)
|
||||
dbx.SetMaxOpenConns(40)
|
||||
dbx.SetConnMaxLifetime(0)
|
||||
}
|
||||
|
||||
return dbx
|
||||
|
||||
+18
-4
@@ -1,13 +1,27 @@
|
||||
package config
|
||||
|
||||
import "git.aiterp.net/lucifer/new-server/models"
|
||||
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"
|
||||
)
|
||||
|
||||
var dp models.DriverProvider
|
||||
var dpOnce sync.Once
|
||||
|
||||
func DriverProvider() models.DriverProvider {
|
||||
if dp == nil {
|
||||
panic("not implemented yet")
|
||||
}
|
||||
dpOnce.Do(func() {
|
||||
dp = drivers.DriverMap{
|
||||
models.DTNanoLeaf: &nanoleaf.Driver{},
|
||||
models.DTHue: &hue.Driver{},
|
||||
models.DTLIFX: &lifx.Driver{},
|
||||
models.DTMill: &mill.Driver{},
|
||||
}
|
||||
})
|
||||
|
||||
return dp
|
||||
}
|
||||
|
||||
+18
-1
@@ -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)
|
||||
|
||||
+16
-14
@@ -1,24 +1,26 @@
|
||||
package config
|
||||
|
||||
import "git.aiterp.net/lucifer/new-server/models"
|
||||
|
||||
var (
|
||||
dRepo models.DeviceRepository
|
||||
ehRepo models.EventHandlerRepository
|
||||
import (
|
||||
"git.aiterp.net/lucifer/new-server/internal/mysql"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
)
|
||||
|
||||
func DeviceRepository() models.DeviceRepository {
|
||||
if dRepo == nil {
|
||||
panic("panik")
|
||||
}
|
||||
func BridgeRepository() models.BridgeRepository {
|
||||
return &mysql.BridgeRepo{DBX: DBX()}
|
||||
}
|
||||
|
||||
return dRepo
|
||||
func ColorPresetRepository() models.ColorPresetRepository {
|
||||
return &mysql.ColorPresetRepo{DBX: DBX()}
|
||||
}
|
||||
|
||||
func DeviceRepository() models.DeviceRepository {
|
||||
return &mysql.DeviceRepo{DBX: DBX()}
|
||||
}
|
||||
|
||||
func EventHandlerRepository() models.EventHandlerRepository {
|
||||
if ehRepo == nil {
|
||||
panic("panik")
|
||||
}
|
||||
return &mysql.EventHandlerRepo{DBX: DBX()}
|
||||
}
|
||||
|
||||
return ehRepo
|
||||
func SceneRepository() models.SceneRepository {
|
||||
return &mysql.SceneRepo{DBX: DBX()}
|
||||
}
|
||||
|
||||
+23
-1
@@ -1,23 +1,45 @@
|
||||
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.ConnectToBridges()
|
||||
services.CheckNewDevices()
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
ginny := gin.New()
|
||||
|
||||
apiGin := ginny.Group("/api")
|
||||
api.Bridges(apiGin.Group("/bridges"))
|
||||
api.Devices(apiGin.Group("/devices"))
|
||||
api.ColorPresets(apiGin.Group("/color-presets"))
|
||||
api.DriverKinds(apiGin.Group("/driver-kinds"))
|
||||
api.Events(apiGin.Group("/events"))
|
||||
api.EventHandlers(apiGin.Group("/event-handlers"))
|
||||
api.Scenes(apiGin.Group("/scenes"))
|
||||
|
||||
log.Fatal(ginny.Run(fmt.Sprintf("0.0.0.0:%d", config.ServerPort)))
|
||||
models.TimeOfDayTimeZone = config.Location()
|
||||
|
||||
log.Fatal(ginny.Run(fmt.Sprintf("0.0.0.0:%d", config.ServerPort())))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var cancelMap = make(map[int]context.CancelFunc, 8)
|
||||
var cancelMutex sync.Mutex
|
||||
|
||||
func ConnectToBridges() {
|
||||
go func() {
|
||||
for {
|
||||
err := runConnectToBridges()
|
||||
if err != nil {
|
||||
log.Println("Bridge connection error: ", err.Error())
|
||||
}
|
||||
|
||||
time.Sleep(15 * time.Second)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func runConnectToBridges() error {
|
||||
bridges, err := config.BridgeRepository().FetchAll(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, bridge := range bridges {
|
||||
cancelMutex.Lock()
|
||||
isRunning := cancelMap[bridge.ID] != nil
|
||||
cancelMutex.Unlock()
|
||||
if isRunning {
|
||||
continue
|
||||
}
|
||||
|
||||
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancelMutex.Lock()
|
||||
cancelMap[bridge.ID] = cancel
|
||||
cancelMutex.Unlock()
|
||||
|
||||
log.Printf("Running bridge \"%s\" (%d)", bridge.Name, bridge.ID)
|
||||
|
||||
go func(bridge models.Bridge, cancel func()) {
|
||||
err = driver.Run(ctx, bridge, config.EventChannel)
|
||||
log.Printf("Bridge \"%s\" (%d) stopped: %s", bridge.Name, bridge.ID, err)
|
||||
|
||||
cancelMutex.Lock()
|
||||
cancel()
|
||||
cancelMap[bridge.ID] = nil
|
||||
cancelMutex.Unlock()
|
||||
}(bridge, cancel)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+194
-12
@@ -2,37 +2,61 @@ package services
|
||||
|
||||
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"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartEventHandler() {
|
||||
config.EventChannel<-models.Event{Name: "LuciferStarted"}
|
||||
config.EventChannel <- models.Event{Name: "LuciferStarted"}
|
||||
|
||||
go func() {
|
||||
for event := range config.EventChannel {
|
||||
handleEvent(event)
|
||||
responses := handleEvent(event)
|
||||
|
||||
for _, response := range responses {
|
||||
handleEvent(response)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Dispatch an HourChanged event at every hour
|
||||
// Generate TimeChanged event
|
||||
go func() {
|
||||
drift := time.Now().Add(time.Minute).Truncate(time.Minute).Sub(time.Now())
|
||||
time.Sleep(drift + time.Millisecond * 5)
|
||||
time.Sleep(drift + time.Millisecond*5)
|
||||
|
||||
for range time.NewTicker(time.Minute).C {
|
||||
config.EventChannel <- models.Event{Name: "TimeChanged"}
|
||||
config.EventChannel <- models.Event{Name: "TimeChanged", Payload: map[string]string{
|
||||
"weekdayName": time.Now().In(loc).Format("Monday"),
|
||||
"month": time.Now().In(loc).Format("01"),
|
||||
"year": time.Now().In(loc).Format("2006"),
|
||||
}}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var loc, _ = time.LoadLocation("Europe/Oslo")
|
||||
var ctx = context.Background()
|
||||
var X = false
|
||||
|
||||
func handleEvent(event models.Event) {
|
||||
func handleEvent(event models.Event) (responses []models.Event) {
|
||||
var deadHandlers []models.EventHandler
|
||||
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
duration := time.Since(startTime)
|
||||
if duration > time.Millisecond*250 {
|
||||
log.Println(event.Name, "took long to handle:", duration)
|
||||
}
|
||||
}()
|
||||
|
||||
if !event.HasPayload("hour") {
|
||||
event.AddPayload("hour", time.Now().In(loc).Format("15"))
|
||||
}
|
||||
@@ -40,20 +64,42 @@ func handleEvent(event models.Event) {
|
||||
event.AddPayload("minute", time.Now().In(loc).Format("04"))
|
||||
}
|
||||
|
||||
if !X {
|
||||
return
|
||||
err := handleSpecial(event)
|
||||
if err != nil {
|
||||
log.Printf("Special event handler error (%s): %v", event.Name, err)
|
||||
}
|
||||
|
||||
paramStrings := make([]string, 0, 8)
|
||||
for key, value := range event.Payload {
|
||||
paramStrings = append(paramStrings, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
sort.Strings(paramStrings)
|
||||
|
||||
log.Printf("Event %s(%s)", event.Name, strings.Join(paramStrings, ", "))
|
||||
|
||||
handlers, err := config.EventHandlerRepository().FetchAll(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Error fetchin event halders: %d", err)
|
||||
log.Printf("Error fetchin' event halders: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
allDevices := make([]models.Device, 0, 16)
|
||||
actions := make(map[int]models.EventAction, 16)
|
||||
priorities := make(map[int]int, 16)
|
||||
highestPriority := 0
|
||||
var prioritizedEvent *models.Event
|
||||
|
||||
for _, handler := range handlers {
|
||||
if handler.EventName != event.Name {
|
||||
continue
|
||||
}
|
||||
if !handler.IsWithinTimeRange() {
|
||||
continue
|
||||
}
|
||||
|
||||
devices, err := config.DeviceRepository().FetchByReference(ctx, handler.TargetKind, handler.TargetValue)
|
||||
if err != nil {
|
||||
log.Printf("Error fetchin event halders: %d", err)
|
||||
log.Printf("Error fetchin' event devices: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,7 +107,143 @@ func handleEvent(event models.Event) {
|
||||
continue
|
||||
}
|
||||
|
||||
if handler.OneShot {
|
||||
deadHandlers = append(deadHandlers, handler)
|
||||
}
|
||||
|
||||
if handler.Priority > highestPriority {
|
||||
highestPriority = handler.Priority
|
||||
prioritizedEvent = handler.Actions.FireEvent
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
_, ok := actions[device.ID]
|
||||
if !ok {
|
||||
allDevices = append(allDevices, device)
|
||||
actions[device.ID] = handler.Actions
|
||||
priorities[device.ID] = handler.Priority
|
||||
} else if handler.Priority > priorities[device.ID] {
|
||||
actions[device.ID] = handler.Actions
|
||||
priorities[device.ID] = handler.Priority
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Unhandled event: " + event.Name)
|
||||
if prioritizedEvent != nil {
|
||||
responses = append(responses, *prioritizedEvent)
|
||||
}
|
||||
|
||||
for i, device := range allDevices {
|
||||
action := actions[device.ID]
|
||||
newState := models.NewDeviceState{}
|
||||
|
||||
newState.Color = action.SetColor
|
||||
newState.Power = action.SetPower
|
||||
|
||||
if action.SetIntensity != nil {
|
||||
newState.Intensity = action.SetIntensity
|
||||
} else if action.AddIntensity != nil {
|
||||
newIntensity := math.Max(0, math.Min(1, device.State.Intensity+*action.AddIntensity))
|
||||
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{}
|
||||
|
||||
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 devices' state:", err)
|
||||
}
|
||||
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
|
||||
for _, handler := range deadHandlers {
|
||||
wg.Add(1)
|
||||
|
||||
go func(handler models.EventHandler) {
|
||||
err := config.EventHandlerRepository().Delete(context.Background(), &handler)
|
||||
if err != nil {
|
||||
log.Println("Failed to delete spent one-shot event handler:", err)
|
||||
}
|
||||
|
||||
wg.Done()
|
||||
}(handler)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func handleSpecial(event models.Event) error {
|
||||
switch event.Name {
|
||||
case models.ENBridgeConnected:
|
||||
bridgeId, _ := strconv.Atoi(event.Payload["bridgeId"])
|
||||
bridge, err := config.BridgeRepository().Find(ctx, bridgeId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
devices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, event.Payload["bridgeId"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CheckNewDevices() {
|
||||
go func() {
|
||||
// Wait a bit before the first to let bridges connect.
|
||||
time.Sleep(time.Second * 5)
|
||||
err := checkNewDevices()
|
||||
if err != nil {
|
||||
log.Println("Failed to sync lights:", err)
|
||||
}
|
||||
|
||||
for range time.NewTicker(time.Second * 30).C {
|
||||
err := checkNewDevices()
|
||||
if err != nil {
|
||||
log.Println("Failed to sync lights:", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func checkNewDevices() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*27)
|
||||
defer cancel()
|
||||
|
||||
bridges, err := config.BridgeRepository().FetchAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, bridge := range bridges {
|
||||
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||
if err != nil {
|
||||
log.Println("Unknown/unsupported driver:", bridge.Driver)
|
||||
continue
|
||||
}
|
||||
|
||||
savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID))
|
||||
if err != nil {
|
||||
log.Println("Failed to list devices from db:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
driverDevices, err := driver.ListDevices(ctx, bridge)
|
||||
if err != nil {
|
||||
log.Println("Failed to list devices from driver:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
foundNewDevices := false
|
||||
SaveLoop:
|
||||
for _, driverDevice := range driverDevices {
|
||||
for _, savedDevice := range savedDevices {
|
||||
if savedDevice.InternalID == driverDevice.InternalID {
|
||||
continue SaveLoop
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Saving new device", driverDevice.InternalID)
|
||||
|
||||
err := config.DeviceRepository().Save(ctx, &driverDevice, models.SMState|models.SMProperties|models.SMTags)
|
||||
if err != nil {
|
||||
log.Println("Failed to save device:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
foundNewDevices = true
|
||||
}
|
||||
|
||||
// If new devices were found, publish them so that the driver can be set up.
|
||||
if foundNewDevices {
|
||||
savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID))
|
||||
if err != nil {
|
||||
log.Println("Failed to fetch devices from db second time:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
err = driver.Publish(ctx, bridge, savedDevices)
|
||||
if err != nil {
|
||||
log.Println("Failed to list devices from db:", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+59
-22
@@ -3,9 +3,10 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf"
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"os"
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var flagDriver = flag.String("driver", "Nanoleaf", "The bridge driver to use")
|
||||
var flagDriver = flag.String("driver", string(models.DTNanoLeaf), "The bridge driver to use")
|
||||
var flagAddress = flag.String("address", "127.0.0.1", "The bridge's address")
|
||||
var flagToken = flag.String("token", "", "The bridge's access token / api key / login")
|
||||
var flagPair = flag.Bool("pair", false, "Try to pair with the bridge.")
|
||||
@@ -25,11 +26,14 @@ var flagSearchTimeout = flag.Duration("search-timeout", time.Second*3, "Timeout
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// TODO: Select driver
|
||||
driver := nanoleaf.Driver{}
|
||||
// Find drivers
|
||||
driver, err := config.DriverProvider().Provide(models.DriverKind(*flagDriver))
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to find driver:", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -43,6 +47,15 @@ func main() {
|
||||
log.Println("New token:", bridge.Token)
|
||||
}
|
||||
|
||||
ch := config.EventChannel
|
||||
go func() {
|
||||
err := driver.Run(context.Background(), bridge, ch)
|
||||
if err != nil {
|
||||
log.Fatalln("Run bridge stopped:", err)
|
||||
}
|
||||
}()
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// List devices
|
||||
var devices []models.Device
|
||||
if *flagSearch {
|
||||
@@ -56,20 +69,14 @@ func main() {
|
||||
log.Fatalln("Failed to list devices:", err)
|
||||
}
|
||||
}
|
||||
|
||||
idMap := make(map[string]int)
|
||||
nextId := len(devices) + 1
|
||||
for i := range devices {
|
||||
devices[i].ID = i + 1
|
||||
idMap[devices[i].InternalID] = i + 1
|
||||
}
|
||||
|
||||
_ = driver.Publish(context.Background(), bridge, devices)
|
||||
|
||||
ch := make(chan models.Event)
|
||||
go func() {
|
||||
err := driver.Run(context.Background(), bridge, ch)
|
||||
if err != nil {
|
||||
log.Fatalln("Run bridge stopped:", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
@@ -83,6 +90,35 @@ func main() {
|
||||
text, _ := reader.ReadString('\n')
|
||||
text = strings.Trim(text, "\t \r\n")
|
||||
|
||||
if text == "search" {
|
||||
_, _ = driver.SearchDevices(context.Background(), bridge, time.Second)
|
||||
}
|
||||
if text == "list" || text == "search" || text == "json" {
|
||||
devices, err = driver.ListDevices(context.Background(), bridge)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to list devices:", err)
|
||||
}
|
||||
|
||||
for i, device := range devices {
|
||||
if extId, ok := idMap[device.InternalID]; ok {
|
||||
devices[i].ID = extId
|
||||
} else {
|
||||
idMap[device.InternalID] = nextId
|
||||
devices[i].ID = nextId
|
||||
|
||||
nextId += 1
|
||||
}
|
||||
|
||||
if text != "json" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Device: %d - %s %+v\n", device.ID, device.InternalID, device.Capabilities)
|
||||
}
|
||||
}
|
||||
}
|
||||
if text == "json" {
|
||||
j, _ := json.MarshalIndent(devices, "", " ")
|
||||
fmt.Println(string(j))
|
||||
}
|
||||
|
||||
tokens := strings.Split(text, " ")
|
||||
if len(tokens) < 4 {
|
||||
continue
|
||||
@@ -120,14 +156,15 @@ func main() {
|
||||
if id == -1 || id == device.ID {
|
||||
if (color.IsKelvin() && device.HasCapability(models.DCColorKelvin)) || (color.IsHueSat() && device.HasCapability(models.DCColorHS)) {
|
||||
device.State.Color = color
|
||||
if device.HasCapability(models.DCPower) {
|
||||
device.State.Power = power
|
||||
}
|
||||
if device.HasCapability(models.DCIntensity) {
|
||||
device.State.Intensity = intensity
|
||||
}
|
||||
updatedDevices = append(updatedDevices, device)
|
||||
}
|
||||
if device.HasCapability(models.DCPower) {
|
||||
device.State.Power = power
|
||||
}
|
||||
if device.HasCapability(models.DCIntensity) {
|
||||
device.State.Intensity = intensity
|
||||
}
|
||||
|
||||
updatedDevices = append(updatedDevices, device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -4,20 +4,28 @@ import (
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"github.com/pressly/goose"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
db := config.DBX().DB
|
||||
|
||||
log.Println("Host: " + config.MySqlHost())
|
||||
log.Println("Schema: " + config.MySqlUsername())
|
||||
log.Printf("Target version: %s",time.Now().Format("20060102150405"))
|
||||
log.Printf("Database: %s:%d/%s", config.MySqlHost(), config.MySqlPort(), config.MySqlSchema())
|
||||
log.Printf("Authenticating as: %s", config.MySqlUsername())
|
||||
|
||||
err := goose.SetDialect("mysql")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = goose.Run("up", db, "./scripts")
|
||||
cmd := os.Getenv("GOOSE_COMMAND")
|
||||
if cmd == "" {
|
||||
cmd = "up"
|
||||
}
|
||||
|
||||
err = goose.Run(cmd, db, "./scripts")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Param struct {
|
||||
Index int
|
||||
Key string
|
||||
Value string
|
||||
Operator string
|
||||
}
|
||||
|
||||
func (p *Param) String() *string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return p.Value
|
||||
}
|
||||
|
||||
func (p *Param) Int() *int {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(p.Value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
n, err := strconv.ParseFloat(p.Value, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &n
|
||||
}
|
||||
|
||||
func (p *Param) Bool() *bool {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
v := strings.ToLower(p.Value)
|
||||
if v == "yes" || v == "true" || v == "on" {
|
||||
r := true
|
||||
return &r
|
||||
} else if v == "no" || v == "false" || v == "off" {
|
||||
r := false
|
||||
return &r
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Params []Param
|
||||
|
||||
func (p Params) Get(key interface{}) *Param {
|
||||
switch key := key.(type) {
|
||||
case string:
|
||||
for _, p := range p {
|
||||
if p.Key == key {
|
||||
return &p
|
||||
}
|
||||
}
|
||||
case int:
|
||||
for _, p := range p {
|
||||
if p.Index == key {
|
||||
return &p
|
||||
}
|
||||
}
|
||||
default:
|
||||
log.Panicf("Incorrect key type %T", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Params) Subset(prefix string) Params {
|
||||
if len(p) == 0 {
|
||||
return Params{}
|
||||
}
|
||||
|
||||
if len(prefix) > 0 && !strings.HasSuffix(prefix, ".") {
|
||||
prefix += "."
|
||||
}
|
||||
|
||||
res := make(Params, 0, len(p))
|
||||
|
||||
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, Operator: param.Operator})
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func (p Params) StringMap() map[string]string {
|
||||
res := make(map[string]string)
|
||||
for _, param := range p {
|
||||
if param.Index == -1 {
|
||||
res[param.Key] = param.Value
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func (p Params) StringPtrMap() map[string]*string {
|
||||
res := make(map[string]*string)
|
||||
for _, param := range p {
|
||||
if param.Index == -1 {
|
||||
if param.Value == "NULL" {
|
||||
res[param.Key] = nil
|
||||
} else {
|
||||
res[param.Key] = ¶m.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func (p Params) Strings(minIndex int) []string {
|
||||
res := make([]string, 0, len(p))
|
||||
for _, param := range p {
|
||||
if param.Index >= minIndex {
|
||||
res = append(res, param.Value)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func parseCommand(args []string) Command {
|
||||
if len(args) == 0 {
|
||||
return Command{Name: "help"}
|
||||
}
|
||||
|
||||
cmd := Command{
|
||||
Name: args[0],
|
||||
Params: make(Params, 0, len(args)-1),
|
||||
}
|
||||
|
||||
nextIndex := 0
|
||||
for _, arg := range args[1:] {
|
||||
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(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
|
||||
}
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v2"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Remote string `yaml:"remote"`
|
||||
}
|
||||
|
||||
func loadConfig() (*Config, error) {
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := os.Open(path.Join(homedir, ".config/lucy.yaml"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := Config{}
|
||||
err = yaml.NewDecoder(f).Decode(&cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
const helpString = `
|
||||
lucy – lucifer CLI
|
||||
|
||||
EXAMPLES
|
||||
lucy set tag:Hexagon color=hs:35,1 intensity=0.3
|
||||
lucy run SetProfile name=evening
|
||||
|
||||
DEVICE COMMANDS
|
||||
list <search>
|
||||
set <search> <power=B> <color=C> <intensity=F> <temperature=N>
|
||||
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!
|
||||
`
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/app/client"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Println("Config at $HOME/.config/lucy.yaml failed:", err)
|
||||
cfg = &Config{Remote: "http://localhost:8000"}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
c := client.Client{APIRoot: cfg.Remote}
|
||||
cmd := parseCommand(os.Args[1:])
|
||||
|
||||
switch cmd.Name {
|
||||
// DEVICE
|
||||
case "list":
|
||||
{
|
||||
fetchStr := cmd.Params.Get(0).String()
|
||||
if fetchStr == nil {
|
||||
s := "all"
|
||||
fetchStr = &s
|
||||
}
|
||||
|
||||
devices, err := c.GetDevices(ctx, *fetchStr)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
if cmd.Params.Get("info").StringOr("false") == "true" {
|
||||
WriteDeviceInfoTable(os.Stdout, devices)
|
||||
} else {
|
||||
WriteDeviceStateTable(os.Stdout, devices)
|
||||
}
|
||||
}
|
||||
case "set":
|
||||
{
|
||||
devices, err := c.PutDeviceState(ctx, cmd.Params.Get(0).StringOr("all"), cmd.Params.DeviceState(""))
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
WriteDeviceStateTable(os.Stdout, devices)
|
||||
}
|
||||
case "tag":
|
||||
{
|
||||
var addTags []string
|
||||
var removeTags []string
|
||||
|
||||
for _, tag := range cmd.Params.Strings(1) {
|
||||
if strings.HasPrefix(tag, "+") {
|
||||
addTags = append(addTags, tag[1:])
|
||||
} else if strings.HasPrefix(tag, "-") {
|
||||
removeTags = append(removeTags, tag[1:])
|
||||
} else {
|
||||
addTags = append(addTags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
devices, err := c.PutDeviceTags(
|
||||
ctx,
|
||||
cmd.Params.Get(0).StringOr("all"),
|
||||
addTags,
|
||||
removeTags,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
WriteDeviceInfoTable(os.Stdout, devices)
|
||||
}
|
||||
case "update":
|
||||
{
|
||||
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 {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
WriteDeviceInfoTable(os.Stdout, devices)
|
||||
}
|
||||
|
||||
case "factory":
|
||||
factoryCmd(ctx, c)
|
||||
|
||||
case "handler":
|
||||
handlerCmd(ctx, c)
|
||||
|
||||
case "scene":
|
||||
sceneCmd(ctx, c)
|
||||
|
||||
// EVENT
|
||||
case "run":
|
||||
{
|
||||
name := cmd.Params.Get(0).String()
|
||||
if name == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := models.Event{
|
||||
Name: *name,
|
||||
Payload: cmd.Params.StringMap(),
|
||||
}
|
||||
|
||||
err := c.FireEvent(ctx, event)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
if cmd.Name != "help" {
|
||||
log.Println("Unknown command:", cmd.Name)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(os.Stderr, helpString[1:])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/app/client"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"gopkg.in/yaml.v2"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func sceneCmd(
|
||||
ctx context.Context,
|
||||
c client.Client,
|
||||
) {
|
||||
cmd := parseCommand(os.Args[2:])
|
||||
|
||||
switch cmd.Name {
|
||||
case "create", "update":
|
||||
{
|
||||
fileName := cmd.Params.Get(0).String()
|
||||
if fileName == nil {
|
||||
log.Fatalln("Missing filename")
|
||||
}
|
||||
|
||||
file, err := os.Open(*fileName)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to open file:", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
yamlData := make(map[string]interface{})
|
||||
err = yaml.NewDecoder(file).Decode(yamlData)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to decode file:", err)
|
||||
return
|
||||
}
|
||||
|
||||
yamlData = camelCasify(yamlData)
|
||||
name, nameOk := yamlData["name"]
|
||||
if !nameOk {
|
||||
log.Fatalln("Missing name in yaml data.")
|
||||
}
|
||||
|
||||
var scene models.Scene
|
||||
if cmd.Name == "create" {
|
||||
err := c.Fetch(ctx, "POST", "/api/scenes", &scene, yamlData)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to create scene:", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
scenes, err := c.GetScenes(ctx)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to fetch existing scenes:", err)
|
||||
return
|
||||
}
|
||||
|
||||
id := -1
|
||||
for _, scene := range scenes {
|
||||
if scene.Name == name {
|
||||
id = scene.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == -1 {
|
||||
log.Fatalln("Could not find scene with name", name)
|
||||
return
|
||||
}
|
||||
|
||||
err = c.Fetch(ctx, "PUT", "/api/scenes/"+strconv.Itoa(id), &scene, yamlData)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to update scene:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "push", "assign":
|
||||
{
|
||||
fetch := cmd.Params.Get(0).String()
|
||||
id := cmd.Params.Get(1).Int()
|
||||
if fetch == nil || id == nil {
|
||||
log.Println("Usage: lucy scene assign <fetch> <id> <group=S> <duration=I>")
|
||||
}
|
||||
|
||||
devices, err := c.AssignDevice(ctx, *fetch, cmd.Name == "push", models.DeviceSceneAssignment{
|
||||
SceneID: *id,
|
||||
Group: cmd.Params.Get("group").StringOr(*fetch),
|
||||
DurationMS: int64(cmd.Params.Get("duration").IntOr(0)),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("Could not assign devices:", err)
|
||||
return
|
||||
}
|
||||
|
||||
WriteDeviceInfoTable(os.Stdout, devices)
|
||||
}
|
||||
|
||||
case "clear":
|
||||
fetch := cmd.Params.Get(0).String()
|
||||
if fetch == nil {
|
||||
log.Println("Usage: lucy scene clear <fetch>")
|
||||
}
|
||||
|
||||
devices, err := c.ClearDevice(ctx, *fetch)
|
||||
if err != nil {
|
||||
log.Println("Could not clear devices:", err)
|
||||
return
|
||||
}
|
||||
|
||||
WriteDeviceInfoTable(os.Stdout, devices)
|
||||
}
|
||||
}
|
||||
|
||||
func camelCasify(m map[string]interface{}) map[string]interface{} {
|
||||
m2 := make(map[string]interface{}, len(m))
|
||||
for key, value := range m {
|
||||
b := strings.Builder{}
|
||||
snake := false
|
||||
for _, ch := range key {
|
||||
if ch == '_' {
|
||||
snake = true
|
||||
} else if snake {
|
||||
b.WriteRune(unicode.ToUpper(ch))
|
||||
snake = false
|
||||
} else {
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
|
||||
switch value := value.(type) {
|
||||
case []interface{}:
|
||||
valueCopy := make([]interface{}, len(value))
|
||||
for i, elem := range value {
|
||||
switch elem := elem.(type) {
|
||||
case map[interface{}]interface{}:
|
||||
m3 := make(map[string]interface{})
|
||||
for k, v := range elem {
|
||||
if kStr, ok := k.(string); ok {
|
||||
m3[kStr] = v
|
||||
}
|
||||
}
|
||||
|
||||
valueCopy[i] = camelCasify(m3)
|
||||
case map[string]interface{}:
|
||||
valueCopy[i] = camelCasify(elem)
|
||||
default:
|
||||
valueCopy[i] = elem
|
||||
}
|
||||
}
|
||||
|
||||
m2[b.String()] = valueCopy
|
||||
case map[string]interface{}:
|
||||
m2[b.String()] = camelCasify(value)
|
||||
default:
|
||||
m2[b.String()] = value
|
||||
}
|
||||
}
|
||||
|
||||
return m2
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func WriteDeviceStateTable(w io.Writer, devices []models.Device) {
|
||||
table := tablewriter.NewWriter(w)
|
||||
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) {
|
||||
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) {
|
||||
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 = fmt.Sprintf("%d", v.State.Temperature)
|
||||
}
|
||||
|
||||
intensityString := ""
|
||||
if v.HasCapability(models.DCIntensity) {
|
||||
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{
|
||||
strconv.Itoa(v.ID),
|
||||
v.Name,
|
||||
powerStr,
|
||||
colorStr,
|
||||
intensityString,
|
||||
temperatureString,
|
||||
})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
}
|
||||
|
||||
func WriteDeviceInfoTable(w io.Writer, devices []models.Device) {
|
||||
table := tablewriter.NewWriter(w)
|
||||
table.SetHeader([]string{"ID", "NAME", "ICON", "BUTTONS", "TAGS", "USER PROPERTIES"})
|
||||
|
||||
table.SetReflowDuringAutoWrap(false)
|
||||
|
||||
for _, v := range devices {
|
||||
propStr := ""
|
||||
for key, value := range v.UserProperties {
|
||||
propStr += key + "=" + value + " "
|
||||
}
|
||||
|
||||
table.Append([]string{
|
||||
strconv.Itoa(v.ID),
|
||||
v.Name,
|
||||
v.Icon,
|
||||
strings.Join(v.ButtonNames, ","),
|
||||
strings.Join(v.Tags, ","),
|
||||
propStr,
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#####################
|
||||
## 1. Build server ##
|
||||
#####################
|
||||
FROM golang:1.17 AS build-server
|
||||
|
||||
WORKDIR /project
|
||||
COPY . .
|
||||
|
||||
ENV CGO_ENABLED 0
|
||||
|
||||
RUN go mod download
|
||||
RUN go build -ldflags "-w -s" -o /build/lucifer cmd/server/main.go
|
||||
|
||||
|
||||
|
||||
###################
|
||||
## 2. Run server ##
|
||||
###################
|
||||
FROM alpine:3.14.2
|
||||
|
||||
# Add results from previous builds
|
||||
COPY --from=build-server /build/lucifer /usr/bin/lucifer
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
# Run server
|
||||
CMD lucifer
|
||||
@@ -3,13 +3,17 @@ module git.aiterp.net/lucifer/new-server
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.0
|
||||
github.com/gin-gonic/gin v1.7.1
|
||||
github.com/go-sql-driver/mysql v1.6.0
|
||||
github.com/jmoiron/sqlx v1.3.4
|
||||
github.com/lib/pq v1.10.2 // indirect
|
||||
github.com/lib/pq v1.10.3 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pressly/goose v2.7.0+incompatible
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect
|
||||
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
gopkg.in/yaml.v2 v2.2.8
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/Masterminds/squirrel v1.5.0 h1:JukIZisrUXadA9pl3rMkjhiamxiB0cXiu+HGp/Y8cY8=
|
||||
github.com/Masterminds/squirrel v1.5.0/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -18,20 +20,27 @@ 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=
|
||||
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
|
||||
github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
@@ -39,13 +48,17 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
@@ -55,19 +68,26 @@ github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs
|
||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
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=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
|
||||
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=
|
||||
|
||||
Executable
+11
@@ -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
|
||||
@@ -0,0 +1,425 @@
|
||||
package hue
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Bridge struct {
|
||||
mu sync.Mutex
|
||||
host string
|
||||
token string
|
||||
externalID int
|
||||
lightStates []*hueLightState
|
||||
sensorStates []*hueSensorState
|
||||
quarantine map[string]time.Time
|
||||
syncingPublish uint32
|
||||
}
|
||||
|
||||
func (b *Bridge) Refresh(ctx context.Context) error {
|
||||
lightMap, err := b.getLights(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
state = existingState
|
||||
}
|
||||
}
|
||||
|
||||
if state == nil {
|
||||
state = &hueLightState{
|
||||
index: index,
|
||||
uniqueID: light.Uniqueid,
|
||||
externalID: -1,
|
||||
info: light,
|
||||
}
|
||||
|
||||
b.lightStates = append(b.lightStates, state)
|
||||
} else {
|
||||
if light.Uniqueid != state.uniqueID {
|
||||
state.uniqueID = light.Uniqueid
|
||||
state.externalID = -1
|
||||
}
|
||||
}
|
||||
|
||||
state.CheckStaleness(light.State)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
sensorMap, err := b.getSensors(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
state = existingState
|
||||
}
|
||||
}
|
||||
|
||||
if state == nil {
|
||||
state = &hueSensorState{
|
||||
index: index,
|
||||
uniqueID: sensor.UniqueID,
|
||||
externalID: -1,
|
||||
presenceCooldown: -2,
|
||||
}
|
||||
|
||||
b.sensorStates = append(b.sensorStates, state)
|
||||
} else {
|
||||
if sensor.UniqueID != state.uniqueID {
|
||||
state.uniqueID = sensor.UniqueID
|
||||
state.externalID = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bridge) SyncStale(ctx context.Context) error {
|
||||
indices := make([]int, 0, 4)
|
||||
inputs := make([]LightStateInput, 0, 4)
|
||||
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
b.mu.Lock()
|
||||
for _, state := range b.lightStates {
|
||||
if !state.stale {
|
||||
continue
|
||||
}
|
||||
|
||||
indices = append(indices, state.index)
|
||||
inputs = append(inputs, state.input)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
if len(inputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, input := range inputs {
|
||||
iCopy := i
|
||||
index := indices[i]
|
||||
inputCopy := input
|
||||
|
||||
eg.Go(func() error {
|
||||
err := b.putLightState(ctx, index, inputCopy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.lightStates[iCopy].stale = false
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var events []models.Event
|
||||
|
||||
b.mu.Lock()
|
||||
for idx, sensorData := range sensorMap {
|
||||
for _, state := range b.sensorStates {
|
||||
if idx == state.index {
|
||||
event := state.Update(sensorData)
|
||||
if event != nil {
|
||||
events = append(events, *event)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (b *Bridge) StartDiscovery(ctx context.Context, model string) error {
|
||||
return b.post(ctx, model, nil, nil)
|
||||
}
|
||||
|
||||
func (b *Bridge) putLightState(ctx context.Context, index int, input LightStateInput) error {
|
||||
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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(result) == 0 || result[0].Error != nil {
|
||||
return "", errLinkButtonNotPressed
|
||||
}
|
||||
if result[0].Success == nil {
|
||||
return "", models.ErrUnexpectedResponse
|
||||
}
|
||||
|
||||
return result[0].Success.Username, nil
|
||||
}
|
||||
|
||||
func (b *Bridge) getLights(ctx context.Context) (map[int]LightData, error) {
|
||||
result := make(map[int]LightData, 16)
|
||||
err := b.get(ctx, "lights", &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *Bridge) getSensors(ctx context.Context) (map[int]SensorData, error) {
|
||||
result := make(map[int]SensorData, 16)
|
||||
err := b.get(ctx, "sensors", &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", 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()
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.token != "" {
|
||||
resource = b.token + "/" + resource
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://%s/api/%s", b.host, resource), rb)
|
||||
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) put(ctx context.Context, resource string, body interface{}, target interface{}) error {
|
||||
rb, err := reqBody(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.token != "" {
|
||||
resource = b.token + "/" + resource
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PUT", fmt.Sprintf("http://%s/api/%s", b.host, resource), rb)
|
||||
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 reqBody(body interface{}) (io.Reader, error) {
|
||||
if body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch v := body.(type) {
|
||||
case []byte:
|
||||
return bytes.NewReader(v), nil
|
||||
case string:
|
||||
return strings.NewReader(v), nil
|
||||
case io.Reader:
|
||||
return v, nil
|
||||
default:
|
||||
jsonData, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.NewReader(jsonData), nil
|
||||
}
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 256,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
IdleConnTimeout: 10 * time.Minute,
|
||||
},
|
||||
Timeout: time.Minute,
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
type CreateUserInput struct {
|
||||
DeviceType string `json:"devicetype"`
|
||||
}
|
||||
|
||||
type CreateUserResponse struct {
|
||||
Success *struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"success"`
|
||||
Error *struct {
|
||||
Type int `json:"type"`
|
||||
Address string `json:"address"`
|
||||
Description string `json:"description"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type Overview struct {
|
||||
Lights map[int]LightData `json:"lights"`
|
||||
Config BridgeConfig `json:"config"`
|
||||
Sensors map[int]SensorData `json:"sensors"`
|
||||
}
|
||||
|
||||
type BridgeConfig struct {
|
||||
Name string `json:"name"`
|
||||
ZigbeeChannel int `json:"zigbeechannel"`
|
||||
BridgeID string `json:"bridgeid"`
|
||||
Mac string `json:"mac"`
|
||||
DHCP bool `json:"dhcp"`
|
||||
IPAddress string `json:"ipaddress"`
|
||||
NetMask string `json:"netmask"`
|
||||
Gateway string `json:"gateway"`
|
||||
ProxyAddress string `json:"proxyaddress"`
|
||||
ProxyPort int `json:"proxyport"`
|
||||
UTC string `json:"UTC"`
|
||||
LocalTime string `json:"localtime"`
|
||||
TimeZone string `json:"timezone"`
|
||||
ModelID string `json:"modelid"`
|
||||
DataStoreVersion string `json:"datastoreversion"`
|
||||
SWVersion string `json:"swversion"`
|
||||
APIVersion string `json:"apiversion"`
|
||||
LinkButton bool `json:"linkbutton"`
|
||||
PortalServices bool `json:"portalservices"`
|
||||
PortalConnection string `json:"portalconnection"`
|
||||
FactoryNew bool `json:"factorynew"`
|
||||
StarterKitID string `json:"starterkitid"`
|
||||
Whitelist map[string]WhitelistEntry `json:"whitelist"`
|
||||
}
|
||||
|
||||
type LightState struct {
|
||||
On bool `json:"on"`
|
||||
Bri int `json:"bri"`
|
||||
Hue int `json:"hue"`
|
||||
Sat int `json:"sat"`
|
||||
Effect string `json:"effect"`
|
||||
XY []float64 `json:"xy"`
|
||||
CT int `json:"ct"`
|
||||
Alert string `json:"alert"`
|
||||
ColorMode string `json:"colormode"`
|
||||
Mode string `json:"mode"`
|
||||
Reachable bool `json:"reachable"`
|
||||
}
|
||||
|
||||
type LightStateInput struct {
|
||||
On *bool `json:"on,omitempty"`
|
||||
Bri *int `json:"bri,omitempty"`
|
||||
Hue *int `json:"hue,omitempty"`
|
||||
Sat *int `json:"sat,omitempty"`
|
||||
Effect *string `json:"effect,omitempty"`
|
||||
XY *[2]float64 `json:"xy,omitempty"`
|
||||
CT *int `json:"ct,omitempty"`
|
||||
Alert *string `json:"alert,omitempty"`
|
||||
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"`
|
||||
Name string `json:"name"`
|
||||
Modelid string `json:"modelid"`
|
||||
Manufacturername string `json:"manufacturername"`
|
||||
Productname string `json:"productname"`
|
||||
Capabilities struct {
|
||||
Certified bool `json:"certified"`
|
||||
Control struct {
|
||||
Mindimlevel int `json:"mindimlevel"`
|
||||
Maxlumen int `json:"maxlumen"`
|
||||
Colorgamuttype string `json:"colorgamuttype"`
|
||||
Colorgamut [][]float64 `json:"colorgamut"`
|
||||
CT struct {
|
||||
Min int `json:"min"`
|
||||
Max int `json:"max"`
|
||||
} `json:"ct"`
|
||||
} `json:"control"`
|
||||
Streaming struct {
|
||||
Renderer bool `json:"renderer"`
|
||||
Proxy bool `json:"proxy"`
|
||||
} `json:"streaming"`
|
||||
} `json:"capabilities"`
|
||||
Config struct {
|
||||
Archetype string `json:"archetype"`
|
||||
Function string `json:"function"`
|
||||
Direction string `json:"direction"`
|
||||
Startup struct {
|
||||
Mode string `json:"mode"`
|
||||
Configured bool `json:"configured"`
|
||||
} `json:"startup"`
|
||||
} `json:"config"`
|
||||
Swupdate struct {
|
||||
State string `json:"state"`
|
||||
Lastinstall string `json:"lastinstall"`
|
||||
} `json:"swupdate"`
|
||||
Uniqueid string `json:"uniqueid"`
|
||||
Swversion string `json:"swversion"`
|
||||
Swconfigid string `json:"swconfigid"`
|
||||
Productid string `json:"productid"`
|
||||
}
|
||||
|
||||
type SensorData struct {
|
||||
State struct {
|
||||
Daylight interface{} `json:"daylight"`
|
||||
ButtonEvent int `json:"buttonevent"`
|
||||
LastUpdated string `json:"lastupdated"`
|
||||
Presence bool `json:"presence"`
|
||||
Temperature int
|
||||
} `json:"state"`
|
||||
Config struct {
|
||||
On bool `json:"on"`
|
||||
Configured bool `json:"configured"`
|
||||
Sunriseoffset int `json:"sunriseoffset"`
|
||||
Sunsetoffset int `json:"sunsetoffset"`
|
||||
} `json:"config"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Modelid string `json:"modelid"`
|
||||
Manufacturername string `json:"manufacturername"`
|
||||
Productname string `json:"productname"`
|
||||
Swversion string `json:"swversion"`
|
||||
UniqueID string `json:"uniqueid"`
|
||||
}
|
||||
|
||||
type WhitelistEntry struct {
|
||||
LastUseDate string `json:"last use date"`
|
||||
CreateDate string `json:"create date"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type BridgeDeviceInfo struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
Text string `xml:",chardata"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
SpecVersion struct {
|
||||
Text string `xml:",chardata"`
|
||||
Major string `xml:"major"`
|
||||
Minor string `xml:"minor"`
|
||||
} `xml:"specVersion"`
|
||||
URLBase string `xml:"URLBase"`
|
||||
Device struct {
|
||||
Text string `xml:",chardata"`
|
||||
DeviceType string `xml:"deviceType"`
|
||||
FriendlyName string `xml:"friendlyName"`
|
||||
Manufacturer string `xml:"manufacturer"`
|
||||
ManufacturerURL string `xml:"manufacturerURL"`
|
||||
ModelDescription string `xml:"modelDescription"`
|
||||
ModelName string `xml:"modelName"`
|
||||
ModelNumber string `xml:"modelNumber"`
|
||||
ModelURL string `xml:"modelURL"`
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
UDN string `xml:"UDN"`
|
||||
PresentationURL string `xml:"presentationURL"`
|
||||
IconList struct {
|
||||
Text string `xml:",chardata"`
|
||||
Icon struct {
|
||||
Text string `xml:",chardata"`
|
||||
Mimetype string `xml:"mimetype"`
|
||||
Height string `xml:"height"`
|
||||
Width string `xml:"width"`
|
||||
Depth string `xml:"depth"`
|
||||
URL string `xml:"url"`
|
||||
} `xml:"icon"`
|
||||
} `xml:"iconList"`
|
||||
} `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")
|
||||
@@ -0,0 +1,419 @@
|
||||
package hue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
mu sync.Mutex
|
||||
bridges []*Bridge
|
||||
}
|
||||
|
||||
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) {
|
||||
if address == "" {
|
||||
if !dryRun {
|
||||
return nil, models.ErrAddressOnlyDryRunnable
|
||||
}
|
||||
|
||||
res, err := http.Get("https://discovery.meethue.com")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
entries := make([]DiscoveryEntry, 0, 8)
|
||||
err = json.NewDecoder(res.Body).Decode(&entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bridges := make([]models.Bridge, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
bridges = append(bridges, models.Bridge{
|
||||
ID: -1,
|
||||
Name: entry.Id,
|
||||
Driver: models.DTHue,
|
||||
Address: entry.InternalIPAddress,
|
||||
Token: "",
|
||||
})
|
||||
}
|
||||
|
||||
return bridges, nil
|
||||
}
|
||||
|
||||
deviceInfo := BridgeDeviceInfo{}
|
||||
res, err := http.Get(fmt.Sprintf("http://%s/description.xml", address))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
err = xml.NewDecoder(res.Body).Decode(&deviceInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bridge := models.Bridge{
|
||||
ID: -1,
|
||||
Name: deviceInfo.Device.FriendlyName,
|
||||
Driver: models.DTHue,
|
||||
Address: address,
|
||||
Token: "",
|
||||
}
|
||||
|
||||
if !dryRun {
|
||||
b := &Bridge{host: address}
|
||||
|
||||
timeout, cancel := context.WithTimeout(ctx, time.Second*30)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
token, err := b.getToken(timeout)
|
||||
if err != nil {
|
||||
if err == errLinkButtonNotPressed {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bridge.Token = token
|
||||
b.token = token
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return []models.Bridge{bridge}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
before, err := d.ListDevices(ctx, bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if timeout.Seconds() < 10 {
|
||||
timeout = time.Second * 10
|
||||
}
|
||||
halfTime := timeout / 2
|
||||
|
||||
err = b.StartDiscovery(ctx, "sensors")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case <-time.After(halfTime):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
err = b.StartDiscovery(ctx, "lights")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case <-time.After(halfTime):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
err = b.Refresh(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
after, err := d.ListDevices(ctx, bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intersection := make([]models.Device, 0, 4)
|
||||
for _, device := range after {
|
||||
found := false
|
||||
for _, device2 := range before {
|
||||
if device2.InternalID == device.InternalID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
intersection = append(intersection, device)
|
||||
}
|
||||
}
|
||||
|
||||
return intersection, nil
|
||||
}
|
||||
|
||||
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices := make([]models.Device, 0, 8)
|
||||
|
||||
lightMap, err := b.getLights(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, lightInfo := range lightMap {
|
||||
device := models.Device{
|
||||
ID: -1,
|
||||
BridgeID: b.externalID,
|
||||
InternalID: lightInfo.Uniqueid,
|
||||
Icon: "lightbulb",
|
||||
Name: lightInfo.Name,
|
||||
Capabilities: []models.DeviceCapability{
|
||||
models.DCPower,
|
||||
},
|
||||
ButtonNames: nil,
|
||||
DriverProperties: map[string]interface{}{
|
||||
"modelId": lightInfo.Modelid,
|
||||
"productName": lightInfo.Productname,
|
||||
"swVersion": lightInfo.Swversion,
|
||||
"hueLightType": lightInfo.Type,
|
||||
},
|
||||
UserProperties: nil,
|
||||
State: models.DeviceState{},
|
||||
Tags: nil,
|
||||
}
|
||||
|
||||
hasDimming := false
|
||||
hasCT := false
|
||||
hasColor := false
|
||||
|
||||
switch lightInfo.Type {
|
||||
case "On/off light":
|
||||
// Always take DCPower for granted anyway.
|
||||
case "Dimmable light":
|
||||
hasDimming = true
|
||||
case "Color temperature light":
|
||||
hasDimming = true
|
||||
hasCT = true
|
||||
case "Color light":
|
||||
hasDimming = true
|
||||
hasColor = true
|
||||
case "Extended color light":
|
||||
hasDimming = true
|
||||
hasColor = true
|
||||
hasCT = true
|
||||
}
|
||||
|
||||
ctrl := lightInfo.Capabilities.Control
|
||||
if hasDimming {
|
||||
device.Capabilities = append(device.Capabilities, models.DCIntensity)
|
||||
}
|
||||
if hasCT {
|
||||
device.Capabilities = append(device.Capabilities, models.DCColorKelvin)
|
||||
device.DriverProperties["minKelvin"] = 1000000 / ctrl.CT.Max
|
||||
device.DriverProperties["maxKelvin"] = 1000000 / ctrl.CT.Min
|
||||
}
|
||||
if hasColor {
|
||||
device.Capabilities = append(device.Capabilities, models.DCColorHS)
|
||||
device.DriverProperties["gamutType"] = ctrl.Colorgamuttype
|
||||
device.DriverProperties["gamutData"] = ctrl.Colorgamut
|
||||
}
|
||||
device.DriverProperties["maxLumen"] = strconv.Itoa(ctrl.Maxlumen)
|
||||
|
||||
devices = append(devices, device)
|
||||
}
|
||||
|
||||
sensorMap, err := b.getSensors(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, sensorInfo := range sensorMap {
|
||||
device := models.Device{
|
||||
ID: -1,
|
||||
BridgeID: b.externalID,
|
||||
InternalID: sensorInfo.UniqueID,
|
||||
Name: sensorInfo.Name,
|
||||
Capabilities: []models.DeviceCapability{},
|
||||
ButtonNames: []string{},
|
||||
DriverProperties: map[string]interface{}{
|
||||
"modelId": sensorInfo.Modelid,
|
||||
"productName": sensorInfo.Productname,
|
||||
"swVersion": sensorInfo.Swversion,
|
||||
"hueLightType": sensorInfo.Type,
|
||||
},
|
||||
UserProperties: nil,
|
||||
State: models.DeviceState{},
|
||||
Tags: nil,
|
||||
}
|
||||
|
||||
switch sensorInfo.Type {
|
||||
case "ZLLSwitch":
|
||||
device.Capabilities = append(device.Capabilities, models.DCButtons)
|
||||
device.ButtonNames = append(buttonNames[:0:0], buttonNames...)
|
||||
device.Icon = "lightswitch"
|
||||
case "ZLLPresence":
|
||||
device.Capabilities = append(device.Capabilities, models.DCPresence)
|
||||
device.Icon = "sensor"
|
||||
case "ZLLTemperature":
|
||||
device.Capabilities = append(device.Capabilities, models.DCTemperatureSensor)
|
||||
device.Icon = "thermometer"
|
||||
case "Daylight":
|
||||
continue
|
||||
}
|
||||
|
||||
devices = append(devices, device)
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = b.Refresh(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
for _, device := range devices {
|
||||
for _, state := range b.lightStates {
|
||||
if device.InternalID == state.uniqueID {
|
||||
state.externalID = device.ID
|
||||
state.Update(device.State)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, state := range b.sensorStates {
|
||||
if device.InternalID == state.uniqueID {
|
||||
state.externalID = device.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
atomic.StoreUint32(&b.syncingPublish, 1)
|
||||
defer atomic.StoreUint32(&b.syncingPublish, 0)
|
||||
|
||||
return b.SyncStale(ctx)
|
||||
}
|
||||
|
||||
func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fastTicker := time.NewTicker(time.Second / 10)
|
||||
slowTicker := time.NewTicker(time.Second / 3)
|
||||
selectedTicker := fastTicker
|
||||
ticksUntilRefresh := 0
|
||||
ticksSinceChange := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-selectedTicker.C:
|
||||
if atomic.LoadUint32(&b.syncingPublish) == 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
if ticksUntilRefresh <= 0 {
|
||||
err := b.Refresh(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = b.SyncStale(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ticksUntilRefresh = 60
|
||||
}
|
||||
|
||||
events, err := b.SyncSensors(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
ch <- event
|
||||
ticksSinceChange = 0
|
||||
}
|
||||
|
||||
if ticksSinceChange > 30 {
|
||||
selectedTicker = slowTicker
|
||||
} else if ticksSinceChange == 0 {
|
||||
selectedTicker = fastTicker
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
ticksUntilRefresh -= 1
|
||||
ticksSinceChange += 1
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if bridge.host == info.Address {
|
||||
d.mu.Unlock()
|
||||
return bridge, nil
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
bridge := &Bridge{
|
||||
host: info.Address,
|
||||
token: info.Token,
|
||||
externalID: info.ID,
|
||||
}
|
||||
|
||||
// If this call succeeds, then the token is ok.
|
||||
lightMap, err := bridge.getLights(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("Found %d lights on bridge %d", len(lightMap), bridge.externalID)
|
||||
|
||||
// To avoid a potential duplicate, try looking for it again before inserting
|
||||
d.mu.Lock()
|
||||
for _, bridge := range d.bridges {
|
||||
if bridge.host == info.Address {
|
||||
d.mu.Unlock()
|
||||
return bridge, nil
|
||||
}
|
||||
}
|
||||
d.bridges = append(d.bridges, bridge)
|
||||
d.mu.Unlock()
|
||||
|
||||
return bridge, nil
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package hue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type hueLightState struct {
|
||||
index int
|
||||
uniqueID string
|
||||
externalID int
|
||||
info LightData
|
||||
input LightStateInput
|
||||
|
||||
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
|
||||
}
|
||||
} else {
|
||||
input.Hue = ptrInt(int(state.Color.Hue*(65536/360)) % 65536)
|
||||
if s.input.Hue == nil || *s.input.Hue != *input.Hue {
|
||||
s.stale = true
|
||||
}
|
||||
|
||||
input.Sat = ptrInt(int(state.Color.Saturation * 255))
|
||||
if *input.Sat > 254 {
|
||||
*input.Sat = 254
|
||||
}
|
||||
if *input.Sat < 0 {
|
||||
*input.Sat = 0
|
||||
}
|
||||
if s.input.Sat == nil || *s.input.Sat != *input.Sat {
|
||||
s.stale = true
|
||||
}
|
||||
}
|
||||
|
||||
input.Bri = ptrInt(int(state.Intensity * 255))
|
||||
if *input.Bri > 254 {
|
||||
*input.Bri = 254
|
||||
} else if *input.Bri < 0 {
|
||||
*input.Bri = 0
|
||||
}
|
||||
|
||||
if s.input.Bri == nil || *s.input.Bri != *input.Bri {
|
||||
s.stale = true
|
||||
}
|
||||
} else {
|
||||
input.On = ptrBool(false)
|
||||
}
|
||||
|
||||
if s.input.On == nil || *s.input.On != *input.On {
|
||||
s.stale = true
|
||||
}
|
||||
|
||||
input.TransitionTime = ptrInt(1)
|
||||
|
||||
s.input = input
|
||||
}
|
||||
|
||||
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 {
|
||||
s.input.Hue = ptrInt(state.Hue)
|
||||
s.input.Sat = ptrInt(state.Sat)
|
||||
s.input.Bri = ptrInt(state.Bri)
|
||||
}
|
||||
return
|
||||
} else if state.ColorMode == "ct" {
|
||||
if s.input.CT == nil || state.CT != *s.input.CT {
|
||||
s.stale = true
|
||||
}
|
||||
} else {
|
||||
if s.input.Hue == nil || state.Hue != *s.input.Hue || s.input.Sat == nil || state.Sat != *s.input.Sat {
|
||||
s.stale = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type hueSensorState struct {
|
||||
index int
|
||||
externalID int
|
||||
uniqueID string
|
||||
prevData *SensorData
|
||||
prevTime time.Time
|
||||
presenceCooldown int
|
||||
}
|
||||
|
||||
func (state *hueSensorState) Update(newData SensorData) *models.Event {
|
||||
stateTime, err := time.ParseInLocation("2006-01-02T15:04:05", newData.State.LastUpdated, time.UTC)
|
||||
if err != nil {
|
||||
// Invalid time is probably "none".
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
state.prevData = &newData
|
||||
state.prevTime = stateTime
|
||||
}()
|
||||
|
||||
if state.uniqueID != newData.UniqueID {
|
||||
state.uniqueID = newData.UniqueID
|
||||
}
|
||||
|
||||
if state.prevData != nil && newData.Type != state.prevData.Type {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch newData.Type {
|
||||
case "ZLLSwitch":
|
||||
{
|
||||
// Ignore old events.
|
||||
if time.Since(stateTime) > time.Second*3 {
|
||||
return nil
|
||||
}
|
||||
if state.prevData == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
pe := state.prevData.State.ButtonEvent
|
||||
ce := newData.State.ButtonEvent
|
||||
|
||||
td := stateTime.Sub(state.prevTime) >= time.Second
|
||||
pIdx := (pe / 1000) - 1
|
||||
cIdx := (ce / 1000) - 1
|
||||
pBtn := pe % 1000 / 2 // 0 = pressed, 1 = released
|
||||
cBtn := ce % 1000 / 2 // 0 = pressed, 1 = released
|
||||
|
||||
if pIdx == cIdx && cBtn == 1 && pBtn == 0 {
|
||||
// Do not allow 4002 after 4000.
|
||||
// 4000 after 4002 is fine, though.
|
||||
break
|
||||
}
|
||||
|
||||
if cBtn != pBtn || cIdx != pIdx || td {
|
||||
return &models.Event{
|
||||
Name: models.ENButtonPressed,
|
||||
Payload: map[string]string{
|
||||
"buttonIndex": strconv.Itoa(cIdx),
|
||||
"buttonName": buttonNames[cIdx],
|
||||
"deviceId": strconv.Itoa(state.externalID),
|
||||
"hueButtonEvent": strconv.Itoa(ce),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
case "ZLLPresence":
|
||||
{
|
||||
if state.prevData != nil && state.prevData.State.Presence != newData.State.Presence {
|
||||
if newData.State.Presence {
|
||||
state.presenceCooldown = -1
|
||||
|
||||
return &models.Event{
|
||||
Name: models.ENSensorPresenceStarted,
|
||||
Payload: map[string]string{
|
||||
"deviceId": strconv.Itoa(state.externalID),
|
||||
"deviceInternalId": newData.UniqueID,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
state.presenceCooldown = 0
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
case "ZLLTemperature":
|
||||
if time.Since(stateTime) > (time.Minute * 15) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !state.prevTime.Equal(stateTime) {
|
||||
return &models.Event{
|
||||
Name: models.ENSensorTemperature,
|
||||
Payload: map[string]string{
|
||||
"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),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ptrBool(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func ptrInt(v int) *int {
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package lifx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Bridge struct {
|
||||
mu sync.Mutex
|
||||
externalID int
|
||||
ip string
|
||||
states []*State
|
||||
client *Client
|
||||
}
|
||||
|
||||
func (b *Bridge) StartSearch(ctx context.Context) error {
|
||||
c := b.getClient()
|
||||
if c == nil {
|
||||
return models.ErrBridgeRunningRequired
|
||||
}
|
||||
|
||||
_, err := c.HorribleBroadcast(ctx, &GetService{})
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Bridge) Publish(devices []models.Device) {
|
||||
b.mu.Lock()
|
||||
for _, device := range devices {
|
||||
state, _ := b.ensureState(device.InternalID)
|
||||
|
||||
state.deviceState = new(models.DeviceState)
|
||||
*state.deviceState = device.State
|
||||
|
||||
b.checkAndUpdateState(state)
|
||||
|
||||
state.externalId = device.ID
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bridge) Devices() []models.Device {
|
||||
devices := make([]models.Device, 0, 8)
|
||||
|
||||
b.mu.Lock()
|
||||
for _, state := range b.states {
|
||||
if state.lightState == nil || state.firmware == nil || state.version == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
deviceState := models.DeviceState{}
|
||||
if state.deviceState != nil {
|
||||
deviceState = *state.deviceState
|
||||
}
|
||||
|
||||
device := models.Device{
|
||||
ID: state.externalId,
|
||||
BridgeID: b.externalID,
|
||||
InternalID: state.target,
|
||||
Icon: "lightbulb",
|
||||
Name: state.lightState.Label,
|
||||
Capabilities: []models.DeviceCapability{models.DCPower, models.DCIntensity},
|
||||
DriverProperties: make(map[string]interface{}),
|
||||
State: deviceState,
|
||||
}
|
||||
|
||||
device.DriverProperties["lifxVendorId"] = state.version.Vendor
|
||||
device.DriverProperties["lifxProductId"] = state.version.Product
|
||||
device.DriverProperties["lifxFirmwareMajor"] = state.firmware.Major
|
||||
device.DriverProperties["lifxFirmwareMinor"] = state.firmware.Minor
|
||||
device.DriverProperties["lifxFirmwareBuild"] = state.firmware.BuildTime
|
||||
|
||||
product := findProduct(state.version.Vendor, state.version.Product)
|
||||
if product != nil {
|
||||
device.DriverProperties["productName"] = product.Name
|
||||
|
||||
if product.Features.Color {
|
||||
device.Capabilities = append(device.Capabilities, models.DCColorHS)
|
||||
}
|
||||
if len(product.Features.TemperatureRange) >= 2 {
|
||||
device.Capabilities = append(device.Capabilities, models.DCColorHSK, models.DCColorKelvin)
|
||||
device.DriverProperties["minKelvin"] = product.Features.TemperatureRange[0]
|
||||
device.DriverProperties["maxKelvin"] = product.Features.TemperatureRange[1]
|
||||
}
|
||||
|
||||
for _, upgrade := range product.Upgrades {
|
||||
if state.firmware.Major > upgrade.Major || (state.firmware.Major >= upgrade.Major && state.firmware.Minor >= upgrade.Minor) {
|
||||
if upgrade.Features.TemperatureRange != nil {
|
||||
device.DriverProperties["minKelvin"] = upgrade.Features.TemperatureRange[0]
|
||||
device.DriverProperties["maxKelvin"] = upgrade.Features.TemperatureRange[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
devices = append(devices, device)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
func (b *Bridge) Run(ctx context.Context, debug bool) error {
|
||||
client, err := createClient(ctx, b.ip, debug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.client = client
|
||||
b.mu.Unlock()
|
||||
defer func() {
|
||||
b.mu.Lock()
|
||||
if b.client == client {
|
||||
b.client = nil
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}()
|
||||
|
||||
lastSearchTime := time.Now()
|
||||
lastServiceTime := time.Time{}
|
||||
_, err = client.Send("", &GetService{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
target, seq, payload, err := client.Recv(time.Millisecond * 200)
|
||||
if err == models.ErrInvalidPacketSize || err == models.ErrPayloadTooShort || err == models.ErrUnrecognizedPacketType {
|
||||
log.Println("LIFX udp socket received something weird:", err)
|
||||
} else if err != nil && err != models.ErrReadTimeout {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if payload != nil {
|
||||
b.mu.Lock()
|
||||
state, _ := b.ensureState(target)
|
||||
b.mu.Unlock()
|
||||
|
||||
switch p := payload.(type) {
|
||||
case *StateService:
|
||||
if p.Service == 1 {
|
||||
// Throw these messages to the wind. UDP errors will eventually be caught.
|
||||
|
||||
// Get the version only if it's missing. It should never change anyway.
|
||||
b.mu.Lock()
|
||||
if state.version == nil {
|
||||
_, _ = client.Send(target, &GetVersion{})
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
_, _ = client.Send(target, &GetHostFirmware{})
|
||||
|
||||
lastServiceTime = time.Now()
|
||||
}
|
||||
case *LightState:
|
||||
b.mu.Lock()
|
||||
state.lightState = p
|
||||
state.lightStateTime = time.Now()
|
||||
|
||||
if state.deviceState == nil {
|
||||
state.deviceState = &models.DeviceState{
|
||||
Power: p.On,
|
||||
Color: models.ColorValue{
|
||||
Hue: p.Hue,
|
||||
Saturation: p.Sat,
|
||||
Kelvin: p.Kelvin,
|
||||
},
|
||||
Intensity: p.Bri,
|
||||
}
|
||||
}
|
||||
|
||||
b.checkAndUpdateState(state)
|
||||
b.mu.Unlock()
|
||||
case *StateHostFirmware:
|
||||
b.mu.Lock()
|
||||
state.firmware = p
|
||||
b.mu.Unlock()
|
||||
case *StateVersion:
|
||||
b.mu.Lock()
|
||||
state.version = p
|
||||
b.mu.Unlock()
|
||||
case *Acknowledgement:
|
||||
b.mu.Lock()
|
||||
state.handleAck(seq)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
for _, state := range b.states {
|
||||
if time.Since(state.lightStateTime) > time.Second*10 && time.Since(state.requestTime) > time.Second*3 {
|
||||
state.requestTime = time.Now()
|
||||
_, _ = client.Send(state.target, &GetColor{})
|
||||
} else if len(state.acksPending) > 0 && time.Since(state.updateTime) > time.Second {
|
||||
state.requestTime = time.Now()
|
||||
b.checkAndUpdateState(state)
|
||||
}
|
||||
|
||||
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{})
|
||||
}
|
||||
if state.version == nil {
|
||||
_, _ = client.Send(state.target, &GetVersion{})
|
||||
}
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
if atomic.LoadUint32(&client.isHorrible) == 0 && time.Since(lastServiceTime) > time.Second*30 && time.Since(lastSearchTime) > time.Second*3 {
|
||||
lastSearchTime = time.Now()
|
||||
_, err = client.Send("", &GetService{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) checkAndUpdateState(state *State) {
|
||||
state.acksPending = state.acksPending[:0]
|
||||
|
||||
updatePayloads := state.generateUpdate()
|
||||
for _, updatePayload := range updatePayloads {
|
||||
seq, err := b.client.Send(state.target, updatePayload)
|
||||
if err != nil {
|
||||
log.Println("Error sending updates to", state.externalId, state.target, "err:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
state.updateTime = time.Now()
|
||||
state.acksPending = append(state.acksPending, seq)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) ensureState(target string) (*State, bool) {
|
||||
for _, state := range b.states {
|
||||
if state.target == target {
|
||||
return state, false
|
||||
}
|
||||
}
|
||||
|
||||
state := &State{
|
||||
target: target,
|
||||
discoveredTime: time.Now(),
|
||||
}
|
||||
|
||||
b.states = append(b.states, state)
|
||||
|
||||
return state, true
|
||||
}
|
||||
|
||||
func (b *Bridge) getClient() *Client {
|
||||
b.mu.Lock()
|
||||
client := b.client
|
||||
b.mu.Unlock()
|
||||
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package lifx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
seq uint8
|
||||
buf []byte
|
||||
lastN int
|
||||
|
||||
conn *net.UDPConn
|
||||
source uint32
|
||||
debug bool
|
||||
|
||||
isHorrible uint32
|
||||
|
||||
addrMap map[string]*net.UDPAddr
|
||||
}
|
||||
|
||||
// Send sends the payload to the hardware addr specified. If it cannot find a mapped
|
||||
// IP address from a past message, it will be broadcast and may be slow to use. If the
|
||||
// address is left blank, the package is marked as tagged and broadcast.
|
||||
//
|
||||
// ErrInvalidAddress can be returned by this, and indicates a badly configured device.
|
||||
// It should probably be logged.
|
||||
func (c *Client) Send(addr string, payload Payload) (seq uint8, err error) {
|
||||
c.mu.Lock()
|
||||
seq = c.seq
|
||||
c.seq += 1
|
||||
c.mu.Unlock()
|
||||
|
||||
packet := createPacket()
|
||||
packet.SetSource(c.source)
|
||||
packet.SetSequence(seq)
|
||||
|
||||
sendAddr := &net.UDPAddr{IP: net.IPv4(255, 255, 255, 255), Port: 56700}
|
||||
if addr == "" {
|
||||
packet.SetTagged(true)
|
||||
|
||||
if c.debug {
|
||||
log.Println("Broadcasting", payload, "seq", seq)
|
||||
}
|
||||
} else {
|
||||
err = packet.SetTarget(addr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if udpAddr, ok := c.addrMap[addr]; ok {
|
||||
sendAddr = udpAddr
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if c.debug {
|
||||
log.Println("Sending", payload, "to", addr, "seq", seq)
|
||||
}
|
||||
}
|
||||
|
||||
packet.SetPayload(payload)
|
||||
|
||||
_, err = c.conn.WriteToUDP(packet, sendAddr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// HorribleBroadcast "broadcasts" by blasting every IP address in the space with a tagged packet, because LIFX is LIFX.
|
||||
func (c *Client) HorribleBroadcast(ctx context.Context, payload Payload) (seq uint8, err error) {
|
||||
c.mu.Lock()
|
||||
seq = c.seq
|
||||
c.seq += 1
|
||||
c.mu.Unlock()
|
||||
|
||||
defer atomic.StoreUint32(&c.isHorrible, 0)
|
||||
|
||||
packet := createPacket()
|
||||
packet.SetSource(c.source)
|
||||
packet.SetSequence(seq)
|
||||
packet.SetPayload(payload)
|
||||
packet.SetTagged(true)
|
||||
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
connIP := c.conn.LocalAddr().(*net.UDPAddr).IP
|
||||
|
||||
var minIPNum uint32
|
||||
var maxIPNum uint32
|
||||
for _, iface := range ifaces {
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
naddr, ok := addr.(*net.IPNet)
|
||||
if !ok || !naddr.Contains(connIP) {
|
||||
continue
|
||||
}
|
||||
|
||||
networkAddress := naddr.IP.Mask(naddr.Mask)
|
||||
cidr, bits := naddr.Mask.Size()
|
||||
minIPNum = binary.BigEndian.Uint32(networkAddress) + 1
|
||||
maxIPNum = minIPNum + (1 << (bits - cidr)) - 2
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if minIPNum == maxIPNum {
|
||||
return
|
||||
}
|
||||
myIPNum := binary.BigEndian.Uint32(connIP)
|
||||
|
||||
if c.debug {
|
||||
ipBuf := make(net.IP, 8)
|
||||
binary.BigEndian.PutUint32(ipBuf, minIPNum)
|
||||
binary.BigEndian.PutUint32(ipBuf[4:], maxIPNum-1)
|
||||
|
||||
log.Println("Sending tagged", payload, "to range", ipBuf[:4], "-", ipBuf[4:])
|
||||
|
||||
defer func() {
|
||||
log.Println("Completed sending tagged", payload, "to range", ipBuf[:4], "-", ipBuf[4:])
|
||||
}()
|
||||
}
|
||||
|
||||
for ipNum := minIPNum; ipNum < maxIPNum; ipNum++ {
|
||||
if ipNum == myIPNum {
|
||||
continue
|
||||
}
|
||||
|
||||
atomic.StoreUint32(&c.isHorrible, 1)
|
||||
|
||||
ipBuf := make(net.IP, 4)
|
||||
binary.BigEndian.PutUint32(ipBuf, ipNum)
|
||||
addr := &net.UDPAddr{IP: ipBuf, Port: 56700}
|
||||
|
||||
_, err = c.conn.WriteToUDP(packet, addr)
|
||||
if err != nil {
|
||||
if c.debug {
|
||||
log.Println("HorribleBroadcast™ aborted by error.")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if ipNum%256 == 0 {
|
||||
if c.debug {
|
||||
log.Println("Progress:", (ipNum-minIPNum)+1, "of", maxIPNum-minIPNum)
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
if c.debug {
|
||||
log.Println("HorribleBroadcast™ aborted at call site.")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// LastPacket gets the last read packet. This data is valid until the next call to Recv. The Packet may
|
||||
// not be valid, however!
|
||||
func (c *Client) LastPacket() Packet {
|
||||
if c.lastN < 36 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.buf[:c.lastN]
|
||||
}
|
||||
|
||||
// Recv reads a message from the UDP socket. The data returned is decoded and will always be valid.
|
||||
//
|
||||
// However, these should be handled specifically:
|
||||
// - ErrPayloadTooShort, ErrInvalidPacketSize: Garbage was received, please ignore.
|
||||
// - ErrUnrecognizedPacketType: Log these and see what's up
|
||||
// - ErrReadTimeout: The connection gets a 50ms read deadline. It should be used to do other things than wait
|
||||
func (c *Client) Recv(timeout time.Duration) (target string, seq uint8, payload Payload, err error) {
|
||||
if c.buf == nil {
|
||||
c.buf = make([]byte, 2048)
|
||||
}
|
||||
|
||||
if timeout > 0 {
|
||||
err = c.conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
} else {
|
||||
err = c.conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
n, addr, err := c.conn.ReadFromUDP(c.buf)
|
||||
if n > 0 {
|
||||
c.lastN = n
|
||||
}
|
||||
if err != nil {
|
||||
if netErr, ok := err.(*net.OpError); ok && netErr.Timeout() {
|
||||
err = models.ErrReadTimeout
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
packet := Packet(c.buf[:n])
|
||||
if n < 2 || packet.Size() != n && packet.Protocol() != 1024 {
|
||||
err = models.ErrInvalidAddress
|
||||
return
|
||||
}
|
||||
|
||||
seq = packet.Sequence()
|
||||
target = packet.Target().String()
|
||||
|
||||
payload, err = packet.Payload()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Learn the IP address from state service or ack messages.
|
||||
if service, ok := payload.(*StateService); ok && service.Service == 1 {
|
||||
c.mu.Lock()
|
||||
if c.addrMap == nil {
|
||||
c.addrMap = make(map[string]*net.UDPAddr)
|
||||
}
|
||||
c.addrMap[packet.Target().String()] = addr
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
if c.debug {
|
||||
log.Println("Received", payload, "from", target, "seq", seq)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// createClient creates a client that will last as long as the context.
|
||||
func createClient(ctx context.Context, bindAddr string, debug bool) (*Client, error) {
|
||||
addr := net.ParseIP(bindAddr)
|
||||
if addr == nil {
|
||||
return nil, errors.New("invalid addr")
|
||||
}
|
||||
|
||||
source := uint32(rand.Uint64())
|
||||
if source < 2 {
|
||||
source = 2
|
||||
}
|
||||
|
||||
conn, err := net.ListenUDP("udp", &net.UDPAddr{
|
||||
IP: addr,
|
||||
Port: 0,
|
||||
Zone: "",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = conn.SetWriteBuffer(2048 * 1024)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
return &Client{conn: conn, source: source, debug: debug}, nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package lifx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
mu sync.Mutex
|
||||
bridges []*Bridge
|
||||
}
|
||||
|
||||
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, _ bool) ([]models.Bridge, error) {
|
||||
if address == "" {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bridges := make([]models.Bridge, 0, len(ifaces))
|
||||
for _, iface := range ifaces {
|
||||
if iface.Name == "lo" {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil || len(addrs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
bridges = append(bridges, models.Bridge{
|
||||
ID: -1,
|
||||
Name: fmt.Sprintf("%s (%s)", iface.Name, addr),
|
||||
Driver: models.DTLIFX,
|
||||
Address: addr.String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return bridges, nil
|
||||
}
|
||||
|
||||
ctx2, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
_, err := createClient(ctx2, address, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []models.Bridge{{
|
||||
ID: 0,
|
||||
Name: "Your network card",
|
||||
Driver: models.DTLIFX,
|
||||
Address: address,
|
||||
Token: "",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
before, err := d.ListDevices(ctx, bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = b.StartSearch(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if timeout < time.Second/10 {
|
||||
timeout = time.Second / 10
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(timeout):
|
||||
}
|
||||
|
||||
after, err := d.ListDevices(ctx, bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intersection := make([]models.Device, 0, 4)
|
||||
for _, device := range after {
|
||||
found := false
|
||||
for _, device2 := range before {
|
||||
if device2.InternalID == device.InternalID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
intersection = append(intersection, device)
|
||||
}
|
||||
}
|
||||
|
||||
return intersection, nil
|
||||
}
|
||||
|
||||
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.Devices(), nil
|
||||
}
|
||||
|
||||
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.Publish(devices)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.Run(ctx, bridge.Token == "debug")
|
||||
}
|
||||
|
||||
func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*Bridge, error) {
|
||||
d.mu.Lock()
|
||||
for _, bridge := range d.bridges {
|
||||
if bridge.ip == info.Address {
|
||||
d.mu.Unlock()
|
||||
return bridge, nil
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
bridge := &Bridge{
|
||||
ip: info.Address,
|
||||
externalID: info.ID,
|
||||
}
|
||||
|
||||
// Try to create a short-lived client to make sure it works.
|
||||
ctx2, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
_, err := createClient(ctx2, info.Address, info.Token == "debug")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// To avoid a potential duplicate, try looking for it again before inserting
|
||||
d.mu.Lock()
|
||||
for _, bridge := range d.bridges {
|
||||
if bridge.ip == info.Address {
|
||||
d.mu.Unlock()
|
||||
return bridge, nil
|
||||
}
|
||||
}
|
||||
d.bridges = append(d.bridges, bridge)
|
||||
d.mu.Unlock()
|
||||
|
||||
return bridge, nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package lifx
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"net"
|
||||
)
|
||||
|
||||
func createPacket() Packet {
|
||||
p := make(Packet, 36)
|
||||
p.Reset()
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
type Packet []byte
|
||||
|
||||
func (p *Packet) Reset() {
|
||||
p.ensureHeader()
|
||||
copy(*p, blankHeader)
|
||||
}
|
||||
|
||||
func (p *Packet) Size() int {
|
||||
return int(binary.LittleEndian.Uint16((*p)[0:2]))
|
||||
}
|
||||
|
||||
func (p *Packet) Protocol() int {
|
||||
return int(binary.LittleEndian.Uint16((*p)[2:3]) & 0b00001111_11111111)
|
||||
}
|
||||
|
||||
func (p *Packet) SetTagged(v bool) {
|
||||
if v {
|
||||
(*p)[3] |= uint8(0b00100000)
|
||||
} else {
|
||||
(*p)[3] &= ^uint8(0b00100000)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Packet) Source() uint32 {
|
||||
return binary.LittleEndian.Uint32((*p)[4:8])
|
||||
}
|
||||
|
||||
func (p *Packet) Target() net.HardwareAddr {
|
||||
return net.HardwareAddr((*p)[8:14])
|
||||
}
|
||||
|
||||
func (p *Packet) SetSource(v uint32) {
|
||||
binary.LittleEndian.PutUint32((*p)[4:8], v)
|
||||
}
|
||||
|
||||
func (p *Packet) CopyTargetFrom(other Packet) {
|
||||
copy((*p)[8:14], other[8:14])
|
||||
}
|
||||
|
||||
func (p *Packet) SetTarget(v string) error {
|
||||
addr, err := net.ParseMAC(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(addr) != 6 {
|
||||
return models.ErrInvalidAddress
|
||||
}
|
||||
|
||||
copy((*p)[8:], addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Packet) SetResRequired(v bool) {
|
||||
if v {
|
||||
(*p)[22] |= uint8(0b00000001)
|
||||
} else {
|
||||
(*p)[22] &= ^uint8(0b00000001)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Packet) SetAckRequired(v bool) {
|
||||
if v {
|
||||
(*p)[22] |= uint8(0b00000010)
|
||||
} else {
|
||||
(*p)[22] &= ^uint8(0b00000010)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Packet) Sequence() uint8 {
|
||||
return (*p)[23]
|
||||
}
|
||||
|
||||
func (p *Packet) SetSequence(v uint8) {
|
||||
(*p)[23] = v
|
||||
}
|
||||
|
||||
func (p *Packet) PacketType() uint16 {
|
||||
return binary.LittleEndian.Uint16((*p)[32:34])
|
||||
}
|
||||
|
||||
func (p *Packet) SetRawPayload(packetType uint16, data []byte) {
|
||||
p.ensureSize(36 + len(data))
|
||||
copy((*p)[36:], data)
|
||||
binary.LittleEndian.PutUint16(*p, uint16(36+len(data)))
|
||||
binary.LittleEndian.PutUint16((*p)[32:34], packetType)
|
||||
}
|
||||
|
||||
func (p *Packet) Payload() (res Payload, err error) {
|
||||
switch p.PacketType() {
|
||||
case 2:
|
||||
res = &GetService{}
|
||||
case 3:
|
||||
res = &StateService{}
|
||||
err = res.Decode((*p)[36:])
|
||||
case 14:
|
||||
res = &GetHostFirmware{}
|
||||
case 15:
|
||||
res = &StateHostFirmware{}
|
||||
err = res.Decode((*p)[36:])
|
||||
case 32:
|
||||
res = &GetVersion{}
|
||||
case 33:
|
||||
res = &StateVersion{}
|
||||
err = res.Decode((*p)[36:])
|
||||
case 45:
|
||||
res = &Acknowledgement{}
|
||||
case 101:
|
||||
res = &GetColor{}
|
||||
case 102:
|
||||
res = &SetColor{}
|
||||
err = res.Decode((*p)[36:])
|
||||
case 107:
|
||||
res = &LightState{}
|
||||
err = res.Decode((*p)[36:])
|
||||
case 117:
|
||||
res = &SetLightPower{}
|
||||
err = res.Decode((*p)[36:])
|
||||
default:
|
||||
err = models.ErrUnrecognizedPacketType
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
res = nil
|
||||
}
|
||||
|
||||
if res != nil && res.PacketType() != p.PacketType() {
|
||||
log.Println("BUG Incorrect packet type used in Packet.Payload")
|
||||
log.Println("BUG Payload Type:", p.PacketType())
|
||||
log.Println("BUG Panic Packet Type:", res.PacketType())
|
||||
log.Println("BUG Panic Payload:", res.String())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (p *Packet) SetPayload(payload Payload) {
|
||||
ackRequired, resRequired := payload.Flags()
|
||||
|
||||
p.SetRawPayload(payload.PacketType(), payload.Encode())
|
||||
p.SetAckRequired(ackRequired)
|
||||
p.SetResRequired(resRequired)
|
||||
}
|
||||
|
||||
func (p *Packet) ensureHeader() {
|
||||
p.ensureSize(36)
|
||||
}
|
||||
|
||||
func (p *Packet) ensureSize(size int) {
|
||||
if len(*p) < size {
|
||||
newData := make([]byte, size)
|
||||
copy(newData, *p)
|
||||
*p = newData
|
||||
}
|
||||
}
|
||||
|
||||
type Payload interface {
|
||||
fmt.Stringer
|
||||
Decode(data []byte) error
|
||||
Encode() []byte
|
||||
PacketType() uint16
|
||||
Flags() (ackRequired bool, resRequired bool)
|
||||
}
|
||||
|
||||
var blankHeader = []byte{
|
||||
0x24, 0x00, 0x00, 0x14,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package lifx
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GetService struct{}
|
||||
|
||||
func (p *GetService) Decode([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GetService) Encode() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func (p *GetService) PacketType() uint16 {
|
||||
return 2
|
||||
}
|
||||
|
||||
func (p *GetService) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
func (p *GetService) String() string {
|
||||
return "GetService()"
|
||||
}
|
||||
|
||||
type GetVersion struct{}
|
||||
|
||||
func (p *GetVersion) Decode([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GetVersion) Encode() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func (p *GetVersion) PacketType() uint16 {
|
||||
return 32
|
||||
}
|
||||
|
||||
func (p *GetVersion) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
func (p *GetVersion) String() string {
|
||||
return "GetVersion()"
|
||||
}
|
||||
|
||||
type GetHostFirmware struct{}
|
||||
|
||||
func (p *GetHostFirmware) Decode([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GetHostFirmware) Encode() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func (p *GetHostFirmware) PacketType() uint16 {
|
||||
return 14
|
||||
}
|
||||
|
||||
func (p *GetHostFirmware) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
func (p *GetHostFirmware) String() string {
|
||||
return "GetHostFirmware()"
|
||||
}
|
||||
|
||||
type StateHostFirmware struct {
|
||||
BuildTime time.Time
|
||||
Major uint16
|
||||
Minor uint16
|
||||
}
|
||||
|
||||
func (p *StateHostFirmware) Decode(data []byte) error {
|
||||
if len(data) < 20 {
|
||||
return models.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
ts := int64(binary.LittleEndian.Uint64(data[0:8]))
|
||||
|
||||
p.BuildTime = time.Unix(ts/1000000000, ts%1000000000)
|
||||
p.Major = binary.LittleEndian.Uint16(data[16:18])
|
||||
p.Minor = binary.LittleEndian.Uint16(data[18:20])
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StateHostFirmware) Encode() []byte {
|
||||
var data [20]byte
|
||||
|
||||
binary.LittleEndian.PutUint64(data[0:8], uint64(p.BuildTime.UnixNano()))
|
||||
binary.LittleEndian.PutUint16(data[16:18], p.Major)
|
||||
binary.LittleEndian.PutUint16(data[18:20], p.Minor)
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (p *StateHostFirmware) PacketType() uint16 {
|
||||
return 15
|
||||
}
|
||||
|
||||
func (p *StateHostFirmware) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (p *StateHostFirmware) String() string {
|
||||
return fmt.Sprintf("StateHostFirmware(major=%d, minor=%d, build=%s)", p.Major, p.Minor, p.BuildTime.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
type StateVersion struct {
|
||||
Vendor uint32
|
||||
Product uint32
|
||||
}
|
||||
|
||||
func (p *StateVersion) Decode(data []byte) error {
|
||||
if len(data) < 8 {
|
||||
return models.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
p.Vendor = binary.LittleEndian.Uint32(data[0:4])
|
||||
p.Product = binary.LittleEndian.Uint32(data[4:8])
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StateVersion) Encode() []byte {
|
||||
var data [12]byte
|
||||
|
||||
binary.LittleEndian.PutUint32(data[0:4], p.Vendor)
|
||||
binary.LittleEndian.PutUint32(data[4:8], p.Product)
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (p *StateVersion) PacketType() uint16 {
|
||||
return 33
|
||||
}
|
||||
|
||||
func (p *StateVersion) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (p *StateVersion) String() string {
|
||||
return fmt.Sprintf("StateVersion(vendor=%d, product=%d)", p.Vendor, p.Product)
|
||||
}
|
||||
|
||||
type GetColor struct{}
|
||||
|
||||
func (p *GetColor) Decode([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GetColor) Encode() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func (p *GetColor) PacketType() uint16 {
|
||||
return 101
|
||||
}
|
||||
|
||||
func (p *GetColor) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
func (p *GetColor) String() string {
|
||||
return "GetColor()"
|
||||
}
|
||||
|
||||
type Acknowledgement struct{}
|
||||
|
||||
func (p *Acknowledgement) Decode([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Acknowledgement) Encode() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func (p *Acknowledgement) PacketType() uint16 {
|
||||
return 45
|
||||
}
|
||||
|
||||
func (p *Acknowledgement) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
func (p *Acknowledgement) String() string {
|
||||
return "Acknowledgement()"
|
||||
}
|
||||
|
||||
type SetColor struct {
|
||||
Hue float64
|
||||
Sat float64
|
||||
Bri float64
|
||||
Kelvin int
|
||||
TransitionTime time.Duration
|
||||
}
|
||||
|
||||
func (p *SetColor) Decode(data []byte) error {
|
||||
if len(data) < 13 {
|
||||
return models.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
hue := binary.LittleEndian.Uint16(data[1:3])
|
||||
sat := binary.LittleEndian.Uint16(data[3:5])
|
||||
bri := binary.LittleEndian.Uint16(data[5:7])
|
||||
kelvin := binary.LittleEndian.Uint16(data[7:9])
|
||||
transitionMs := binary.LittleEndian.Uint32(data[9:13])
|
||||
|
||||
p.Hue = float64(hue) / (65536 / 360)
|
||||
p.Sat = float64(sat) / 65535
|
||||
p.Bri = float64(bri) / 65535
|
||||
p.Kelvin = int(kelvin)
|
||||
p.TransitionTime = time.Duration(transitionMs) * time.Millisecond
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *SetColor) Encode() []byte {
|
||||
var data [13]byte
|
||||
|
||||
data[0] = 0x00 // reserved
|
||||
binary.LittleEndian.PutUint16(data[1:3], uint16(math.Mod(p.Hue, 360)*(65536/360)))
|
||||
binary.LittleEndian.PutUint16(data[3:5], uint16(p.Sat*65535))
|
||||
binary.LittleEndian.PutUint16(data[5:7], uint16(p.Bri*65535))
|
||||
binary.LittleEndian.PutUint16(data[7:9], uint16(p.Kelvin))
|
||||
binary.LittleEndian.PutUint32(data[9:13], uint32(p.TransitionTime.Milliseconds()))
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (p *SetColor) PacketType() uint16 {
|
||||
return 102
|
||||
}
|
||||
|
||||
func (p *SetColor) Flags() (ackRequired bool, resRequired bool) {
|
||||
return true, false
|
||||
}
|
||||
|
||||
func (p *SetColor) String() string {
|
||||
return fmt.Sprintf("SetColor(hsvk={%f, %f, %f, %d}, ttime=%s)",
|
||||
p.Hue,
|
||||
p.Sat,
|
||||
p.Bri,
|
||||
p.Kelvin,
|
||||
p.TransitionTime,
|
||||
)
|
||||
}
|
||||
|
||||
type SetLightPower struct {
|
||||
On bool
|
||||
TransitionTime time.Duration
|
||||
}
|
||||
|
||||
func (p *SetLightPower) Decode(data []byte) error {
|
||||
if len(data) < 6 {
|
||||
return models.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
level := binary.LittleEndian.Uint16(data[0:2])
|
||||
transitionMs := binary.LittleEndian.Uint32(data[2:6])
|
||||
|
||||
p.On = level > 0
|
||||
p.TransitionTime = time.Duration(transitionMs) * time.Millisecond
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *SetLightPower) Encode() []byte {
|
||||
var data [6]byte
|
||||
|
||||
if p.On {
|
||||
data[0], data[1] = 0xff, 0xff
|
||||
} else {
|
||||
data[0], data[1] = 0x00, 0x00
|
||||
}
|
||||
binary.LittleEndian.PutUint32(data[2:6], uint32(p.TransitionTime.Milliseconds()))
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (p *SetLightPower) PacketType() uint16 {
|
||||
return 117
|
||||
}
|
||||
|
||||
func (p *SetLightPower) Flags() (ackRequired bool, resRequired bool) {
|
||||
return true, false
|
||||
}
|
||||
|
||||
func (p *SetLightPower) String() string {
|
||||
return fmt.Sprintf("SetLightPower(on=%t, ttime=%s)",
|
||||
p.On,
|
||||
p.TransitionTime,
|
||||
)
|
||||
}
|
||||
|
||||
type StateService struct {
|
||||
Service int
|
||||
Port int
|
||||
}
|
||||
|
||||
func (p *StateService) String() string {
|
||||
return fmt.Sprintf("StateService(service=%d, port=%d)", p.Service, p.Port)
|
||||
}
|
||||
|
||||
func (p *StateService) Decode(data []byte) error {
|
||||
if len(data) < 5 {
|
||||
return models.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
p.Service = int(data[0])
|
||||
p.Port = int(binary.LittleEndian.Uint32(data[1:5]))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StateService) Encode() []byte {
|
||||
var data [5]byte
|
||||
data[0] = uint8(p.Service)
|
||||
binary.LittleEndian.PutUint32(data[1:5], uint32(p.Port))
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (p *StateService) PacketType() uint16 {
|
||||
return 3
|
||||
}
|
||||
|
||||
func (p *StateService) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, false
|
||||
}
|
||||
|
||||
type LightState struct {
|
||||
Hue float64
|
||||
Sat float64
|
||||
Bri float64
|
||||
Kelvin int
|
||||
On bool
|
||||
Label string
|
||||
}
|
||||
|
||||
func (p *LightState) String() string {
|
||||
return fmt.Sprintf("LightState(hsvk=(%f, %f, %f, %d), on=%t, label=%+v)",
|
||||
p.Hue, p.Sat, p.Bri, p.Kelvin,
|
||||
p.On, p.Label,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *LightState) Decode(data []byte) error {
|
||||
if len(data) < 52 {
|
||||
return models.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
hue := binary.LittleEndian.Uint16(data[0:2])
|
||||
sat := binary.LittleEndian.Uint16(data[2:4])
|
||||
bri := binary.LittleEndian.Uint16(data[4:6])
|
||||
kelvin := binary.LittleEndian.Uint16(data[6:8])
|
||||
power := binary.LittleEndian.Uint16(data[10:12])
|
||||
|
||||
p.Hue = float64(hue) / (65536 / 360)
|
||||
p.Sat = float64(sat) / 65535
|
||||
p.Bri = float64(bri) / 65535
|
||||
p.Kelvin = int(kelvin)
|
||||
p.On = power > 32767
|
||||
|
||||
p.Label = ""
|
||||
for i, v := range data[14:46] {
|
||||
if v == 0 {
|
||||
p.Label = string(data[14 : 14+i])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LightState) Encode() []byte {
|
||||
var data [52]byte
|
||||
|
||||
power := uint16(0)
|
||||
if p.On {
|
||||
power = 0xffff
|
||||
}
|
||||
|
||||
binary.LittleEndian.PutUint16(data[0:2], uint16(math.Mod(p.Hue, 360)*(65536/360)))
|
||||
binary.LittleEndian.PutUint16(data[2:4], uint16(p.Sat*65535))
|
||||
binary.LittleEndian.PutUint16(data[4:6], uint16(p.Bri*65535))
|
||||
binary.LittleEndian.PutUint16(data[6:8], uint16(p.Kelvin))
|
||||
binary.LittleEndian.PutUint16(data[10:12], power)
|
||||
|
||||
label := p.Label
|
||||
labelBytes := []byte(label)
|
||||
for len(labelBytes) > 31 {
|
||||
label = label[:len(label)-1]
|
||||
labelBytes = []byte(label)
|
||||
}
|
||||
copy(data[14:], labelBytes)
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (p *LightState) PacketType() uint16 {
|
||||
return 107
|
||||
}
|
||||
|
||||
func (p *LightState) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, false
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,88 @@
|
||||
package lifx
|
||||
|
||||
import (
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
target string
|
||||
externalId int
|
||||
lightState *LightState
|
||||
firmware *StateHostFirmware
|
||||
version *StateVersion
|
||||
|
||||
deviceState *models.DeviceState
|
||||
discoveredTime time.Time
|
||||
lightStateTime time.Time
|
||||
requestTime time.Time
|
||||
updateTime time.Time
|
||||
fwSpamTime time.Time
|
||||
acksPending []uint8
|
||||
}
|
||||
|
||||
func (s *State) generateUpdate() []Payload {
|
||||
if s.deviceState == nil || s.lightState == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
results := make([]Payload, 0, 0)
|
||||
if s.deviceState.Power != s.lightState.On {
|
||||
results = append(results, &SetLightPower{On: s.deviceState.Power, TransitionTime: time.Millisecond * 100})
|
||||
}
|
||||
|
||||
if !s.deviceState.Power {
|
||||
return results
|
||||
}
|
||||
|
||||
c := s.deviceState.Color
|
||||
l := s.lightState
|
||||
di := s.deviceState.Intensity
|
||||
k := c.Kelvin
|
||||
if k == 0 {
|
||||
k = 4000
|
||||
}
|
||||
if !equalish(c.Hue, l.Hue) || !equalish(c.Saturation, l.Sat) || !equalish(di, l.Bri) || k != l.Kelvin {
|
||||
results = append(results, &SetColor{
|
||||
Hue: c.Hue,
|
||||
Sat: c.Saturation,
|
||||
Bri: di,
|
||||
Kelvin: k,
|
||||
TransitionTime: time.Millisecond * 150,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *State) handleAck(seq uint8) {
|
||||
for i, pendingAck := range s.acksPending {
|
||||
if pendingAck == seq {
|
||||
s.acksPending = append(s.acksPending[:i], s.acksPending[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.acksPending) == 0 {
|
||||
s.lightStateTime = time.Now()
|
||||
|
||||
prevLabel := ""
|
||||
if s.lightState != nil {
|
||||
prevLabel = s.lightState.Label
|
||||
}
|
||||
|
||||
s.lightState = &LightState{
|
||||
Hue: s.deviceState.Color.Hue,
|
||||
Sat: s.deviceState.Color.Saturation,
|
||||
Bri: s.deviceState.Intensity,
|
||||
Kelvin: s.deviceState.Color.Kelvin,
|
||||
On: s.deviceState.Power,
|
||||
Label: prevLabel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func equalish(a, b float64) bool {
|
||||
return math.Abs(a-b) < 0.005
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
@@ -50,12 +60,12 @@ func (b *bridge) Devices() []models.Device {
|
||||
models.DCButtons,
|
||||
},
|
||||
ButtonNames: []string{"Touch"},
|
||||
DriverProperties: map[string]string{
|
||||
"x": strconv.Itoa(panel.X),
|
||||
"y": strconv.Itoa(panel.Y),
|
||||
"o": strconv.Itoa(panel.O),
|
||||
DriverProperties: map[string]interface{}{
|
||||
"x": panel.X,
|
||||
"y": panel.Y,
|
||||
"o": panel.O,
|
||||
"shapeType": shapeTypeMap[panel.ShapeType],
|
||||
"shapeWidth": strconv.Itoa(shapeWidthMap[panel.ShapeType]),
|
||||
"shapeWidth": shapeWidthMap[panel.ShapeType],
|
||||
},
|
||||
UserProperties: nil,
|
||||
State: models.DeviceState{
|
||||
@@ -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())
|
||||
@@ -195,9 +205,9 @@ func (b *bridge) Run(ctx context.Context, info models.Bridge, ch chan<- models.E
|
||||
defer conn.Close()
|
||||
|
||||
// Notify connections and disconnections
|
||||
ch <- models.Event{Name: "BridgeConnected", Payload: map[string]string{"bridgeId": strconv.Itoa(info.ID)}}
|
||||
ch <- models.BridgeConnectedEvent(info)
|
||||
defer func() {
|
||||
ch <- models.Event{Name: "BridgeDisconnected", Payload: map[string]string{"bridgeId": strconv.Itoa(info.ID)}}
|
||||
ch <- models.BridgeDisconnectedEvent(info)
|
||||
}()
|
||||
|
||||
// Start touch listener. This one should go down together with this one, though, so it needs a new context.
|
||||
@@ -207,15 +217,29 @@ func (b *bridge) Run(ctx context.Context, info models.Bridge, ch chan<- models.E
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second * 5)
|
||||
hadErrors := false
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-ctx2.Done():
|
||||
}
|
||||
|
||||
err := b.updateEffect(ctx)
|
||||
reqTimeout, cancel := context.WithTimeout(ctx2, time.Second*4)
|
||||
err := b.updateEffect(reqTimeout)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Println("Failed to update effects:", err, "This error is non-fatal, and it will be retried shortly.")
|
||||
hadErrors = true
|
||||
} else if hadErrors {
|
||||
b.mu.Lock()
|
||||
for _, panel := range b.panels {
|
||||
panel.Stale = true
|
||||
panel.SlowUpdates = 3
|
||||
panel.TicksUntilSlowUpdate = 10
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
hadErrors = false
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -300,6 +324,12 @@ func (b *bridge) updateEffect(ctx context.Context) error {
|
||||
return models.ErrUnexpectedResponse
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
for _, panel := range b.panels {
|
||||
panel.Stale = true
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -373,7 +403,7 @@ func (b *bridge) runTouchListener(ctx context.Context, host, apiKey string, ch c
|
||||
}
|
||||
|
||||
event := models.Event{
|
||||
Name: "ButtonPressed",
|
||||
Name: models.ENButtonPressed,
|
||||
Payload: map[string]string{
|
||||
"buttonIndex": "0",
|
||||
"buttonName": "Touch",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package drivers
|
||||
|
||||
import "git.aiterp.net/lucifer/new-server/models"
|
||||
|
||||
type DriverMap map[models.DriverKind]models.Driver
|
||||
|
||||
func (m DriverMap) Provide(kind models.DriverKind) (models.Driver, error) {
|
||||
if m[kind] == nil {
|
||||
return nil, models.ErrUnknownDriver
|
||||
}
|
||||
|
||||
return m[kind], nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type BridgeRepo struct {
|
||||
DBX *sqlx.DB
|
||||
}
|
||||
|
||||
func (b *BridgeRepo) Find(ctx context.Context, id int) (models.Bridge, error) {
|
||||
var bridge models.Bridge
|
||||
err := b.DBX.GetContext(ctx, &bridge, "SELECT * FROM bridge WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return models.Bridge{}, dbErr(err)
|
||||
}
|
||||
|
||||
return bridge, nil
|
||||
}
|
||||
|
||||
func (b *BridgeRepo) FetchAll(ctx context.Context) ([]models.Bridge, error) {
|
||||
bridges := make([]models.Bridge, 0, 8)
|
||||
err := b.DBX.SelectContext(ctx, &bridges, "SELECT * FROM bridge")
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
return bridges, nil
|
||||
}
|
||||
|
||||
func (b *BridgeRepo) Save(ctx context.Context, bridge *models.Bridge) error {
|
||||
if bridge.ID > 0 {
|
||||
_, err := b.DBX.ExecContext(
|
||||
ctx,
|
||||
"UPDATE bridge SET name = ?, address = ?, token = ? WHERE id = ?",
|
||||
bridge.Name, bridge.Address, bridge.Token, bridge.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
} else {
|
||||
rs, err := b.DBX.ExecContext(
|
||||
ctx,
|
||||
"INSERT INTO bridge (name, driver, address, token) VALUES (?, ?, ?, ?)",
|
||||
bridge.Name, bridge.Driver, bridge.Address, bridge.Token,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
id, err := rs.LastInsertId()
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
bridge.ID = int(id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BridgeRepo) Delete(ctx context.Context, bridge *models.Bridge) error {
|
||||
_, err := b.DBX.ExecContext(ctx, "DELETE FROM bridge WHERE id = ?", bridge.ID)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
_, err = b.DBX.ExecContext(ctx, "DELETE FROM device WHERE bridge_id = ?", bridge.ID)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
bridge.ID = 0
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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"`
|
||||
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"`
|
||||
Temperature int `db:"temperature"`
|
||||
}
|
||||
|
||||
type devicePropertyRecord struct {
|
||||
DeviceID int `db:"device_id"`
|
||||
Key string `db:"prop_key"`
|
||||
Value string `db:"prop_value"`
|
||||
IsUser bool `db:"is_user"`
|
||||
}
|
||||
|
||||
type deviceTagRecord struct {
|
||||
DeviceID int `db:"device_id"`
|
||||
TagName string `db:"tag_name"`
|
||||
}
|
||||
|
||||
type DeviceRepo struct {
|
||||
DBX *sqlx.DB
|
||||
}
|
||||
|
||||
func (r *DeviceRepo) Find(ctx context.Context, id int) (*models.Device, error) {
|
||||
var device deviceRecord
|
||||
err := r.DBX.GetContext(ctx, &device, "SELECT * FROM device WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
return r.populateOne(ctx, device)
|
||||
}
|
||||
|
||||
func (r *DeviceRepo) FetchByReference(ctx context.Context, kind models.ReferenceKind, value string) ([]models.Device, error) {
|
||||
q := sq.Select("device.*").From("device")
|
||||
|
||||
var requiredTags []string
|
||||
var unwantedTags []string
|
||||
|
||||
switch kind {
|
||||
case models.RKDeviceID:
|
||||
q = q.Where(sq.Eq{"id": strings.Split(value, ",")})
|
||||
case models.RKBridgeID:
|
||||
q = q.Where(sq.Eq{"bridge_id": strings.Split(value, ",")})
|
||||
case models.RKName:
|
||||
value = strings.ReplaceAll(value, "*", "%")
|
||||
q = q.Where(sq.Like{"name": value})
|
||||
case models.RKTag:
|
||||
allTags := strings.Split(strings.ReplaceAll(strings.ReplaceAll(value, "-", ",-"), "+", ",+"), ",")
|
||||
optionalTags := make([]string, 0, len(allTags))
|
||||
|
||||
for _, tag := range allTags {
|
||||
if tag == "" || tag == "+" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(tag, "+") {
|
||||
requiredTags = append(requiredTags, tag[1:])
|
||||
} else if strings.HasPrefix(tag, "-") {
|
||||
unwantedTags = append(unwantedTags, tag[1:])
|
||||
} else {
|
||||
optionalTags = append(optionalTags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
q = q.Join("device_tag dt ON device.id=dt.device_id").Where(sq.Eq{"dt.tag_name": optionalTags})
|
||||
case models.RKAll:
|
||||
default:
|
||||
log.Println("Unknown reference kind used for device fetch:", kind)
|
||||
return []models.Device{}, nil
|
||||
}
|
||||
|
||||
query, args, err := q.OrderBy("name", "id").ToSql()
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return []models.Device{}, nil
|
||||
}
|
||||
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
records := make([]deviceRecord, 0, 8)
|
||||
err = r.DBX.SelectContext(ctx, &records, query, args...)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
if len(requiredTags) > 0 || len(unwantedTags) > 0 {
|
||||
return r.populateFiltered(ctx, records, requiredTags, unwantedTags)
|
||||
}
|
||||
|
||||
return r.populate(ctx, records)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
for i, device := range devices {
|
||||
scenesJSON, err := json.Marshal(device.SceneAssignments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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,
|
||||
scene_assignments = :scene_assignments
|
||||
WHERE id=:id
|
||||
`, record)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
// Let's just be lazy for now, optimize later if need be.
|
||||
if mode == 0 || mode&models.SMTags != 0 {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
if mode == 0 || mode&models.SMProperties != 0 {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM device_property WHERE device_id=?", record.ID)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
res, err := tx.NamedExecContext(ctx, `
|
||||
INSERT INTO device (bridge_id, internal_id, icon, name, capabilities, button_names)
|
||||
VALUES (:bridge_id, :internal_id, :icon, :name, :capabilities, :button_names)
|
||||
`, record)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
lastID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
record.ID = int(lastID)
|
||||
devices[i].ID = int(lastID)
|
||||
}
|
||||
|
||||
if mode == 0 || mode&models.SMTags != 0 {
|
||||
for _, tag := range device.Tags {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO device_tag (device_id, tag_name) VALUES (?, ?)", record.ID, tag)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mode == 0 || mode&models.SMProperties != 0 {
|
||||
for key, value := range device.UserProperties {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 1)",
|
||||
record.ID, key, value,
|
||||
)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range device.DriverProperties {
|
||||
j, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
// Eh, it'll get filled by the driver anyway
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 0)",
|
||||
record.ID, key, string(j),
|
||||
)
|
||||
if err != nil {
|
||||
// Return err here anyway, it might put the tx in a bad state to ignore it.
|
||||
return dbErr(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mode == 0 || mode&models.SMState != 0 {
|
||||
_, err = tx.NamedExecContext(ctx, `
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DeviceRepo) populateOne(ctx context.Context, record deviceRecord) (*models.Device, error) {
|
||||
records, err := r.populate(ctx, []deviceRecord{record})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &records[0], nil
|
||||
}
|
||||
|
||||
func (r *DeviceRepo) populateFiltered(ctx context.Context, records []deviceRecord, requiredTags []string, unwantedTags []string) ([]models.Device, error) {
|
||||
devices, err := r.populate(ctx, records)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filteredDevices := make([]models.Device, 0, len(devices))
|
||||
|
||||
for _, device := range devices {
|
||||
if device.HasTag(unwantedTags...) {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(requiredTags) == 0 || device.HasTag(requiredTags...) {
|
||||
filteredDevices = append(filteredDevices, device)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredDevices, nil
|
||||
}
|
||||
|
||||
func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]models.Device, error) {
|
||||
if len(records) == 0 {
|
||||
return []models.Device{}, nil
|
||||
}
|
||||
|
||||
ids := make([]int, 0, len(records))
|
||||
for _, record := range records {
|
||||
ids = append(ids, record.ID)
|
||||
}
|
||||
|
||||
tagsQuery, tagsArgs, err := sq.Select("*").From("device_tag").Where(sq.Eq{"device_id": ids}).ToSql()
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
propsQuery, propsArgs, err := sq.Select("*").From("device_property").Where(sq.Eq{"device_id": ids}).ToSql()
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
stateQuery, stateArgs, err := sq.Select("*").From("device_state").Where(sq.Eq{"device_id": ids}).ToSql()
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
states := make([]deviceStateRecord, 0, len(records))
|
||||
props := make([]devicePropertyRecord, 0, len(records)*8)
|
||||
tags := make([]deviceTagRecord, 0, len(records)*4)
|
||||
|
||||
err = r.DBX.SelectContext(ctx, &states, stateQuery, stateArgs...)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
err = r.DBX.SelectContext(ctx, &props, propsQuery, propsArgs...)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
err = r.DBX.SelectContext(ctx, &tags, tagsQuery, tagsArgs...)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
hasAdded := make(map[int]bool, len(records))
|
||||
devices := make([]models.Device, 0, len(records))
|
||||
for _, record := range records {
|
||||
if hasAdded[record.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
device := models.Device{
|
||||
ID: record.ID,
|
||||
BridgeID: record.BridgeID,
|
||||
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]
|
||||
}
|
||||
|
||||
caps := make([]models.DeviceCapability, 0, 16)
|
||||
for _, capStr := range strings.Split(record.Capabilities, ",") {
|
||||
caps = append(caps, models.DeviceCapability(capStr))
|
||||
}
|
||||
device.Capabilities = caps
|
||||
|
||||
for _, state := range states {
|
||||
if state.DeviceID == record.ID {
|
||||
device.State = models.DeviceState{
|
||||
Power: state.Power,
|
||||
Color: models.ColorValue{
|
||||
Hue: state.Hue,
|
||||
Saturation: state.Saturation,
|
||||
Kelvin: state.Kelvin,
|
||||
},
|
||||
Intensity: state.Intensity,
|
||||
Temperature: state.Temperature,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
driverProps := make(map[string]json.RawMessage, 8)
|
||||
for _, prop := range props {
|
||||
if prop.DeviceID == record.ID {
|
||||
if prop.IsUser {
|
||||
device.UserProperties[prop.Key] = prop.Value
|
||||
} else {
|
||||
driverProps[prop.Key] = json.RawMessage(prop.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(driverProps) > 0 {
|
||||
j, err := json.Marshal(driverProps)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
err = json.Unmarshal(j, &device.DriverProperties)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if tag.DeviceID == record.ID {
|
||||
device.Tags = append(device.Tags, tag.TagName)
|
||||
}
|
||||
}
|
||||
|
||||
hasAdded[record.ID] = true
|
||||
devices = append(devices, device)
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type eventHandlerRecord struct {
|
||||
ID int `db:"id"`
|
||||
EventName string `db:"event_name"`
|
||||
OneShot bool `db:"one_shot"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type EventHandlerRepo struct {
|
||||
DBX *sqlx.DB
|
||||
}
|
||||
|
||||
func (r *EventHandlerRepo) FindByID(ctx context.Context, id int) (*models.EventHandler, error) {
|
||||
var record eventHandlerRecord
|
||||
err := r.DBX.GetContext(ctx, &record, "SELECT * FROM event_handler WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
return r.populateOne(&record)
|
||||
}
|
||||
|
||||
func (r *EventHandlerRepo) FetchAll(ctx context.Context) ([]models.EventHandler, error) {
|
||||
var records []eventHandlerRecord
|
||||
err := r.DBX.SelectContext(ctx, &records, "SELECT * FROM event_handler ORDER BY priority DESC, id")
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
return r.populate(records)
|
||||
}
|
||||
|
||||
func (r *EventHandlerRepo) Save(ctx context.Context, handler *models.EventHandler) error {
|
||||
conditionsJson, err := json.Marshal(handler.Conditions)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
actionsJson, err := json.Marshal(handler.Actions)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
record := eventHandlerRecord{
|
||||
ID: handler.ID,
|
||||
EventName: handler.EventName,
|
||||
OneShot: handler.OneShot,
|
||||
Priority: handler.Priority,
|
||||
TargetKind: string(handler.TargetKind),
|
||||
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, 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)
|
||||
}
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
handler.ID = int(id)
|
||||
} else {
|
||||
_, err := r.DBX.NamedExecContext(ctx, `
|
||||
UPDATE event_handler SET
|
||||
event_name = :event_name,
|
||||
one_shot = :one_shot,
|
||||
priority = :priority,
|
||||
target_kind = :target_kind,
|
||||
target_value = :target_value,
|
||||
conditions = :conditions,
|
||||
from_tod = :from_tod,
|
||||
to_tod = :to_tod,
|
||||
actions = :actions
|
||||
WHERE id = :id
|
||||
`, record)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *EventHandlerRepo) Delete(ctx context.Context, handler *models.EventHandler) error {
|
||||
_, err := r.DBX.ExecContext(ctx, "DELETE FROM event_handler WHERE id=?", handler.ID)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *EventHandlerRepo) populateOne(record *eventHandlerRecord) (*models.EventHandler, error) {
|
||||
records, err := r.populate([]eventHandlerRecord{*record})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &records[0], nil
|
||||
}
|
||||
|
||||
func (r *EventHandlerRepo) populate(records []eventHandlerRecord) ([]models.EventHandler, error) {
|
||||
res := make([]models.EventHandler, 0, len(records))
|
||||
|
||||
for _, record := range records {
|
||||
handler := models.EventHandler{
|
||||
ID: record.ID,
|
||||
EventName: record.EventName,
|
||||
OneShot: record.OneShot,
|
||||
Priority: record.Priority,
|
||||
TargetKind: models.ReferenceKind(record.TargetKind),
|
||||
TargetValue: record.TargetValue,
|
||||
Conditions: make(map[string]models.EventCondition),
|
||||
}
|
||||
|
||||
err := json.Unmarshal(record.ConditionsJSON, &handler.Conditions)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
err = json.Unmarshal(record.ActionsJSON, &handler.Actions)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type presetRecord struct {
|
||||
ID int `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Hue float64 `db:"hue"`
|
||||
Saturation float64 `db:"saturation"`
|
||||
Kelvin int `db:"kelvin"`
|
||||
}
|
||||
|
||||
type ColorPresetRepo struct {
|
||||
DBX *sqlx.DB
|
||||
}
|
||||
|
||||
func (c *ColorPresetRepo) Find(ctx context.Context, id int) (models.ColorPreset, error) {
|
||||
var record presetRecord
|
||||
err := c.DBX.GetContext(ctx, &record, "SELECT * FROM color_preset WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return models.ColorPreset{}, dbErr(err)
|
||||
}
|
||||
|
||||
return c.fromRecords(record)[0], nil
|
||||
}
|
||||
|
||||
func (c *ColorPresetRepo) FetchAll(ctx context.Context) ([]models.ColorPreset, error) {
|
||||
records := make([]presetRecord, 0, 16)
|
||||
err := c.DBX.SelectContext(ctx, &records, "SELECT * FROM color_preset")
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
return c.fromRecords(records...), nil
|
||||
}
|
||||
|
||||
func (c *ColorPresetRepo) Save(ctx context.Context, preset *models.ColorPreset) error {
|
||||
if preset.ID > 0 {
|
||||
_, err := c.DBX.ExecContext(
|
||||
ctx,
|
||||
"UPDATE color_preset SET name = ?, hue = ?, saturation = ?, kelvin = ? WHERE id = ?",
|
||||
preset.Name, preset.Value.Hue, preset.Value.Saturation, preset.Value.Kelvin, preset.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
} else {
|
||||
rs, err := c.DBX.ExecContext(
|
||||
ctx,
|
||||
"INSERT INTO color_preset (name, hue, saturation, kelvin) VALUES (?, ?, ?, ?)",
|
||||
preset.Name, preset.Value.Hue, preset.Value.Saturation, preset.Value.Kelvin,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
id, err := rs.LastInsertId()
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
preset.ID = int(id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ColorPresetRepo) Delete(ctx context.Context, preset *models.ColorPreset) error {
|
||||
_, err := c.DBX.ExecContext(ctx, "DELETE FROM color_preset WHERE id = ?", preset.ID)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
preset.ID = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ColorPresetRepo) fromRecords(records ...presetRecord) []models.ColorPreset {
|
||||
newList := make([]models.ColorPreset, len(records), len(records))
|
||||
for i, record := range records {
|
||||
newList[i] = models.ColorPreset{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
Value: models.ColorValue{
|
||||
Hue: record.Hue,
|
||||
Saturation: record.Saturation,
|
||||
Kelvin: record.Kelvin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return newList
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type sceneRecord struct {
|
||||
ID int `db:"id"`
|
||||
Name string `db:"name"`
|
||||
IntervalMS int64 `db:"interval_ms"`
|
||||
RoleJSON json.RawMessage `db:"roles"`
|
||||
}
|
||||
|
||||
type SceneRepo struct {
|
||||
DBX *sqlx.DB
|
||||
}
|
||||
|
||||
func (r *SceneRepo) Find(ctx context.Context, id int) (*models.Scene, error) {
|
||||
var scene sceneRecord
|
||||
err := r.DBX.GetContext(ctx, &scene, "SELECT * FROM scene WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
return r.populateOne(&scene)
|
||||
}
|
||||
|
||||
func (r *SceneRepo) FetchAll(ctx context.Context) ([]models.Scene, error) {
|
||||
scenes := make([]sceneRecord, 0, 8)
|
||||
err := r.DBX.SelectContext(ctx, &scenes, "SELECT * FROM scene")
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return []models.Scene{}, nil
|
||||
}
|
||||
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
return r.populate(scenes)
|
||||
}
|
||||
|
||||
func (r *SceneRepo) Save(ctx context.Context, scene *models.Scene) error {
|
||||
j, err := json.Marshal(scene.Roles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if scene.ID > 0 {
|
||||
_, err := r.DBX.ExecContext(
|
||||
ctx,
|
||||
"UPDATE scene SET name = ?, interval_ms = ?, roles = ? WHERE id = ?",
|
||||
scene.Name, scene.IntervalMS, j, scene.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
} else {
|
||||
rs, err := r.DBX.ExecContext(
|
||||
ctx,
|
||||
"INSERT INTO scene (name, interval_ms, roles) VALUES (?, ?, ?)",
|
||||
scene.Name, scene.IntervalMS, j,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
id, err := rs.LastInsertId()
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
scene.ID = int(id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SceneRepo) Delete(ctx context.Context, scene *models.Scene) error {
|
||||
_, err := r.DBX.ExecContext(ctx, "DELETE FROM scene WHERE id = ?", scene.ID)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
scene.ID = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SceneRepo) populateOne(record *sceneRecord) (*models.Scene, error) {
|
||||
records, err := r.populate([]sceneRecord{*record})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &records[0], nil
|
||||
}
|
||||
|
||||
func (r *SceneRepo) populate(records []sceneRecord) ([]models.Scene, error) {
|
||||
res := make([]models.Scene, 0, len(records))
|
||||
|
||||
for _, record := range records {
|
||||
scene := models.Scene{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
IntervalMS: record.IntervalMS,
|
||||
Roles: make([]models.SceneRole, 0, 8),
|
||||
}
|
||||
|
||||
err := json.Unmarshal(record.RoleJSON, &scene.Roles)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
res = append(res, scene)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
)
|
||||
|
||||
func dbErr(err error) error {
|
||||
if err == sql.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
} else if err != nil {
|
||||
log.Printf("Internal error: %s", err.Error())
|
||||
return models.ErrInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+17
-6
@@ -1,18 +1,29 @@
|
||||
package models
|
||||
|
||||
import "context"
|
||||
|
||||
type Bridge struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Driver DriverKind `json:"driver"`
|
||||
Address string `json:"address"`
|
||||
Token string `json:"token"`
|
||||
ID int `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Driver DriverKind `json:"driver" db:"driver"`
|
||||
Address string `json:"address" db:"address"`
|
||||
Token string `json:"-" db:"token"`
|
||||
}
|
||||
|
||||
type BridgeRepository interface {
|
||||
Find(ctx context.Context, id int) (Bridge, error)
|
||||
FetchAll(ctx context.Context) ([]Bridge, error)
|
||||
Save(ctx context.Context, bridge *Bridge) error
|
||||
Delete(ctx context.Context, bridge *Bridge) error
|
||||
}
|
||||
|
||||
type DriverKind string
|
||||
|
||||
var (
|
||||
DTHue DriverKind = "Hue"
|
||||
DTNanoLeaf DriverKind = "NanoLeaf"
|
||||
DTNanoLeaf DriverKind = "Nanoleaf"
|
||||
DTLIFX DriverKind = "LIFX"
|
||||
DTMill DriverKind = "Mill"
|
||||
)
|
||||
|
||||
var ValidDriverKinds = []DriverKind{
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ColorPreset struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value ColorValue `json:"value"`
|
||||
}
|
||||
|
||||
type ColorPresetRepository interface {
|
||||
Find(ctx context.Context, id int) (ColorPreset, error)
|
||||
FetchAll(ctx context.Context) ([]ColorPreset, error)
|
||||
Save(ctx context.Context, preset *ColorPreset) error
|
||||
Delete(ctx context.Context, preset *ColorPreset) error
|
||||
}
|
||||
|
||||
func (c *ColorPreset) Validate() {
|
||||
c.Name = strings.Trim(c.Name, " \t\n")
|
||||
if len(c.Name) == 0 {
|
||||
c.Name = c.Value.String()
|
||||
}
|
||||
}
|
||||
+25
-3
@@ -23,19 +23,21 @@ func (c *ColorValue) IsKelvin() bool {
|
||||
|
||||
func (c *ColorValue) String() string {
|
||||
if c.Kelvin > 0 {
|
||||
return fmt.Sprintf("kelvin:%d", c.Kelvin)
|
||||
return fmt.Sprintf("k:%d", c.Kelvin)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("hsv:%f,%f", 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 {
|
||||
return ColorValue{}, ErrBadInput
|
||||
}
|
||||
|
||||
if tokens[0] == "kelvin" {
|
||||
if tokens[0] == "kelvin" || tokens[0] == "k" {
|
||||
parsedPart, err := strconv.Atoi(tokens[1])
|
||||
if err != nil {
|
||||
return ColorValue{}, ErrBadInput
|
||||
@@ -59,5 +61,25 @@ func ParseColorValue(raw string) (ColorValue, error) {
|
||||
return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil
|
||||
}
|
||||
|
||||
if tokens[0] == "hsk" {
|
||||
parts := strings.Split(tokens[1], ",")
|
||||
if len(parts) < 3 {
|
||||
return ColorValue{}, ErrUnknownColorFormat
|
||||
}
|
||||
|
||||
part1, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
part2, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
part3, err3 := strconv.Atoi(parts[2])
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return ColorValue{}, ErrBadInput
|
||||
}
|
||||
|
||||
return ColorValue{
|
||||
Hue: math.Mod(part1, 360),
|
||||
Saturation: math.Min(math.Max(part2, 0), 1),
|
||||
Kelvin: part3,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return ColorValue{}, ErrUnknownColorFormat
|
||||
}
|
||||
|
||||
+172
-29
@@ -3,20 +3,29 @@ 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]string `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"`
|
||||
UserProperties map[string]*string `json:"userProperties"`
|
||||
}
|
||||
|
||||
// DeviceState contains optional state values that
|
||||
@@ -28,32 +37,60 @@ 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 {
|
||||
Power *bool `json:"power"`
|
||||
Color *string `json:"color"`
|
||||
Intensity int `json:"intensity"`
|
||||
Temperature int `json:"temperature"`
|
||||
Power *bool `json:"power"`
|
||||
Color *string `json:"color"`
|
||||
Intensity *float64 `json:"intensity"`
|
||||
Temperature *int `json:"temperature"`
|
||||
}
|
||||
|
||||
type DeviceCapability string
|
||||
|
||||
type SaveMode int
|
||||
|
||||
const (
|
||||
SMState SaveMode = 1
|
||||
SMProperties SaveMode = 2
|
||||
SMTags SaveMode = 4
|
||||
)
|
||||
|
||||
type DeviceRepository interface {
|
||||
FindByID(ctx context.Context, id int) (*Device, error)
|
||||
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
|
||||
}
|
||||
|
||||
func DeviceCapabilitiesToStrings(caps []DeviceCapability) []string {
|
||||
res := make([]string, 0, len(caps))
|
||||
for _, cap := range caps {
|
||||
res = append(res, string(cap))
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
var (
|
||||
DCPower DeviceCapability = "Power"
|
||||
DCColorHS DeviceCapability = "ColorHS"
|
||||
DCColorKelvin DeviceCapability = "ColorKelvin"
|
||||
DCButtons DeviceCapability = "Buttons"
|
||||
DCIntensity DeviceCapability = "Intensity"
|
||||
DCTemperature DeviceCapability = "Temperature"
|
||||
DCPower DeviceCapability = "Power"
|
||||
DCColorHS DeviceCapability = "ColorHS"
|
||||
DCColorHSK DeviceCapability = "ColorHSK"
|
||||
DCColorKelvin DeviceCapability = "ColorKelvin"
|
||||
DCButtons DeviceCapability = "Buttons"
|
||||
DCPresence DeviceCapability = "Presence"
|
||||
DCIntensity DeviceCapability = "Intensity"
|
||||
DCTemperatureControl DeviceCapability = "TemperatureControl"
|
||||
DCTemperatureSensor DeviceCapability = "TemperatureSensor"
|
||||
)
|
||||
|
||||
var Capabilities = []DeviceCapability{
|
||||
@@ -61,8 +98,29 @@ var Capabilities = []DeviceCapability{
|
||||
DCColorHS,
|
||||
DCColorKelvin,
|
||||
DCButtons,
|
||||
DCPresence,
|
||||
DCIntensity,
|
||||
DCTemperature,
|
||||
DCTemperatureControl,
|
||||
DCTemperatureSensor,
|
||||
}
|
||||
|
||||
func (d *Device) ApplyUpdate(update DeviceUpdate) {
|
||||
if update.Name != nil {
|
||||
d.Name = *update.Name
|
||||
}
|
||||
if update.Icon != nil {
|
||||
d.Icon = *update.Icon
|
||||
}
|
||||
if d.UserProperties == nil {
|
||||
d.UserProperties = make(map[string]string)
|
||||
}
|
||||
for key, value := range update.UserProperties {
|
||||
if value != nil {
|
||||
d.UserProperties[key] = *value
|
||||
} else {
|
||||
delete(d.UserProperties, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Device) Validate() error {
|
||||
@@ -85,10 +143,35 @@ func (d *Device) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Device) HasCapability(capacity DeviceCapability) bool {
|
||||
func (d *Device) HasTag(tags ...string) bool {
|
||||
for _, c := range d.Tags {
|
||||
for _, c2 := range tags {
|
||||
if c == c2 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if c == capacity {
|
||||
return true
|
||||
for _, c2 := range capabilities {
|
||||
if c == c2 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,10 +189,70 @@ func (d *Device) SetState(newState NewDeviceState) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if (parsed.IsKelvin() && d.HasCapability(DCColorKelvin)) || (parsed.IsHueSat() && d.HasCapability(DCColorHS)) {
|
||||
if (parsed.IsKelvin() && d.HasCapability(DCColorKelvin, DCColorHSK)) || (parsed.IsHueSat() && d.HasCapability(DCColorHS)) {
|
||||
d.State.Color = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if newState.Intensity != nil && d.HasCapability(DCIntensity) {
|
||||
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
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,12 +2,32 @@ package models
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
var ErrInvalidName = errors.New("invalid name")
|
||||
var ErrBadInput = errors.New("bad input")
|
||||
var ErrBadColor = errors.New("bad color")
|
||||
var ErrInternal = errors.New("internal")
|
||||
var ErrUnknownColorFormat = errors.New("unknown color format")
|
||||
var ErrUnknownDriver = errors.New("unknown driver")
|
||||
|
||||
var ErrMissingToken = errors.New("driver is missing authentication information")
|
||||
var ErrIncorrectToken = errors.New("driver is not accepting authentication information")
|
||||
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")
|
||||
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,5 +1,7 @@
|
||||
package models
|
||||
|
||||
import "strconv"
|
||||
|
||||
type Event struct {
|
||||
Name string `json:"name"`
|
||||
Payload map[string]string `json:"payload,omitempty"`
|
||||
@@ -16,3 +18,26 @@ func (e *Event) AddPayload(key, value string) {
|
||||
func (e *Event) HasPayload(key string) bool {
|
||||
return e.Payload != nil && e.Payload[key] != ""
|
||||
}
|
||||
|
||||
var (
|
||||
ENBridgeConnected = "BridgeConnected"
|
||||
ENBridgeDisconnected = "BridgeDisconnected"
|
||||
ENButtonPressed = "ButtonPressed"
|
||||
ENSensorPresenceStarted = "SensorPresenceStarted"
|
||||
ENSensorPresenceEnded = "SensorPresenceEnded"
|
||||
ENSensorTemperature = "SensorTemperature"
|
||||
)
|
||||
|
||||
func BridgeConnectedEvent(bridge Bridge) Event {
|
||||
e := Event{Name: ENBridgeConnected}
|
||||
e.AddPayload("bridgeId", strconv.Itoa(bridge.ID))
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func BridgeDisconnectedEvent(bridge Bridge) Event {
|
||||
e := Event{Name: ENBridgeDisconnected}
|
||||
e.AddPayload("bridgeId", strconv.Itoa(bridge.ID))
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
+183
-23
@@ -5,21 +5,27 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EventHandler struct {
|
||||
ID int `json:"id"`
|
||||
EventName string `json:"eventName"`
|
||||
Conditions map[string]EventCondition `json:"conditions"`
|
||||
TargetKind ReferenceKind `json:"targetType"`
|
||||
OneShot bool `json:"oneShot"`
|
||||
Priority int `json:"priority"`
|
||||
TargetKind ReferenceKind `json:"targetKind"`
|
||||
TargetValue string `json:"targetValue"`
|
||||
Actions EventAction `json:"actions"`
|
||||
From TimeOfDay `json:"from"`
|
||||
To TimeOfDay `json:"to"`
|
||||
}
|
||||
|
||||
type EventHandlerRepository interface {
|
||||
FindByID(ctx context.Context, id int) (EventHandler, error)
|
||||
FindByID(ctx context.Context, id int) (*EventHandler, error)
|
||||
FetchAll(ctx context.Context) ([]EventHandler, error)
|
||||
Save(ctx context.Context, handler *EventHandler)
|
||||
Delete(ctx context.Context, handler *EventHandler)
|
||||
Save(ctx context.Context, handler *EventHandler) error
|
||||
Delete(ctx context.Context, handler *EventHandler) error
|
||||
}
|
||||
|
||||
type EventCondition struct {
|
||||
@@ -30,13 +36,70 @@ type EventCondition struct {
|
||||
LTE string `json:"lte,omitempty"`
|
||||
}
|
||||
|
||||
type EventHandlerUpdate struct {
|
||||
SetEventName *string `json:"setEventName"`
|
||||
SetPriority *int `json:"setPriority"`
|
||||
SetTargetKind *ReferenceKind `json:"setTargetKind"`
|
||||
SetTargetValue *string `json:"setTargetValue"`
|
||||
SetOneShot *bool `json:"setOneShot"`
|
||||
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) {
|
||||
if update.SetEventName != nil {
|
||||
h.EventName = *update.SetEventName
|
||||
}
|
||||
if update.SetPriority != nil {
|
||||
h.Priority = *update.SetPriority
|
||||
}
|
||||
if update.SetTargetKind != nil {
|
||||
h.TargetKind = *update.SetTargetKind
|
||||
}
|
||||
if update.SetTargetValue != nil {
|
||||
h.TargetValue = *update.SetTargetValue
|
||||
}
|
||||
if update.SetActions != nil {
|
||||
h.Actions = *update.SetActions
|
||||
}
|
||||
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
|
||||
}
|
||||
if update.PatchConditions != nil {
|
||||
if h.Conditions == nil {
|
||||
h.Conditions = make(map[string]EventCondition)
|
||||
}
|
||||
|
||||
for key, value := range update.PatchConditions {
|
||||
if value == nil {
|
||||
delete(h.Conditions, key)
|
||||
} else {
|
||||
h.Conditions[key] = *value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *EventHandler) MatchesEvent(event Event, targets []Device) bool {
|
||||
if event.Name != h.EventName {
|
||||
return false
|
||||
}
|
||||
|
||||
for key, condition := range h.Conditions {
|
||||
if !event.HasPayload(key) {
|
||||
if !strings.ContainsRune(key, '.') && !event.HasPayload(key) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -48,43 +111,84 @@ 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.Index(key, "any.") == 0
|
||||
all := strings.Index(key, "all.") == 0
|
||||
if any || all && len(key) > 4 {
|
||||
any := strings.HasPrefix(key, "any.")
|
||||
all := strings.HasPrefix(key, "all.")
|
||||
if any || all {
|
||||
count := 0
|
||||
total := 0
|
||||
for _, target := range targets {
|
||||
if c.checkDevice(key[4:], target) {
|
||||
matches, skip := c.checkDevice(key[4:], target)
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
|
||||
if matches {
|
||||
count++
|
||||
}
|
||||
total++
|
||||
}
|
||||
|
||||
return (any && count > 0) || (all && count == len(targets))
|
||||
return (any && count > 0) || (all && count == total)
|
||||
}
|
||||
|
||||
return c.matches(value)
|
||||
}
|
||||
|
||||
func (c *EventCondition) checkDevice(key string, device Device) bool {
|
||||
func (c *EventCondition) checkDevice(key string, device Device) (matches bool, skip bool) {
|
||||
switch key {
|
||||
case "power":
|
||||
return c.matches(strconv.FormatBool(device.State.Power))
|
||||
case "color":
|
||||
return c.matches(device.State.Color.String())
|
||||
case "intensity":
|
||||
return c.matches(strconv.FormatFloat(device.State.Intensity, 'f', -1, 64))
|
||||
case "temperature":
|
||||
return c.matches(strconv.FormatFloat(device.State.Temperature, 'f', -1, 64))
|
||||
}
|
||||
if !device.HasCapability(DCPower) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
return false
|
||||
return c.matches(strconv.FormatBool(device.State.Power)), false
|
||||
case "color":
|
||||
if !device.HasCapability(DCColorKelvin, DCColorHS, DCColorHSK) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
return c.matches(device.State.Color.String()), false
|
||||
case "intensity":
|
||||
if !device.HasCapability(DCIntensity) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
return c.matches(strconv.FormatFloat(device.State.Intensity, 'f', -1, 64)), false
|
||||
case "temperature":
|
||||
if !device.HasCapability(DCTemperatureControl, DCTemperatureSensor) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
@@ -104,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 != "" {
|
||||
@@ -119,3 +223,59 @@ func (c *EventCondition) matches(value string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type EventAction struct {
|
||||
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) {
|
||||
if action.SetPower == nil {
|
||||
action.SetPower = other.SetPower
|
||||
}
|
||||
if action.SetColor == nil {
|
||||
action.SetColor = other.SetColor
|
||||
}
|
||||
if action.SetIntensity == nil && other.SetIntensity != nil {
|
||||
action.SetIntensity = other.SetIntensity
|
||||
}
|
||||
if action.AddIntensity == nil && action.SetIntensity == nil {
|
||||
action.AddIntensity = other.AddIntensity
|
||||
}
|
||||
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
@@ -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
|
||||
}
|
||||
+79
-3
@@ -1,11 +1,87 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ReferenceKind string
|
||||
|
||||
var (
|
||||
RTDeviceID ReferenceKind = "DeviceID"
|
||||
RTBridgeID ReferenceKind = "BridgeID"
|
||||
RTTag ReferenceKind = "Tag"
|
||||
RKDeviceID ReferenceKind = "DeviceID"
|
||||
RKBridgeID ReferenceKind = "BridgeID"
|
||||
RKTag ReferenceKind = "Tag"
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Evening
|
||||
interval: 1500
|
||||
roles:
|
||||
- effect: WalkingGradient
|
||||
power_mode: Device
|
||||
target_kind: Tag
|
||||
target_value: Hexagon
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: -name
|
||||
states:
|
||||
- color: 'hs:30,1'
|
||||
intensity: 0.20
|
||||
- color: 'hs:30,1'
|
||||
intensity: 0.15
|
||||
|
||||
- effect: Gradient
|
||||
power_mode: Device
|
||||
target_kind: Tag
|
||||
target_value: SquareLeft
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'hs:25,1'
|
||||
intensity: 0.075
|
||||
- color: 'hs:50,1'
|
||||
intensity: 0.15
|
||||
|
||||
- effect: Random
|
||||
power_mode: Device
|
||||
target_kind: Tag
|
||||
target_value: SquareRight
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'hs:220,0.7'
|
||||
intensity: 0.22
|
||||
- color: 'hs:220,0.6'
|
||||
intensity: 0.25
|
||||
|
||||
- effect: Static
|
||||
power_mode: Device
|
||||
target_kind: Tag
|
||||
target_value: Accent
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'hs:250,0.7'
|
||||
intensity: 0.25
|
||||
|
||||
- effect: Static
|
||||
power_mode: Device
|
||||
target_kind: All
|
||||
target_value: ""
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'k:2000'
|
||||
intensity: 0.3
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Flash
|
||||
interval: 0
|
||||
roles:
|
||||
- effect: Static
|
||||
power_mode: Device
|
||||
target_kind: Tag
|
||||
target_value: Nanoleaf
|
||||
states:
|
||||
- intensity: 1
|
||||
color: hs:220,0.1
|
||||
|
||||
- effect: Static
|
||||
power_mode: Device
|
||||
target_kind: Tag
|
||||
target_value: Hue
|
||||
states:
|
||||
- intensity: 1
|
||||
color: k:6500
|
||||
|
||||
- effect: Static
|
||||
power_mode: Device
|
||||
target_kind: All
|
||||
target_value: ""
|
||||
states:
|
||||
- intensity: 1
|
||||
color: hs:0,0
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Late
|
||||
interval: 60000
|
||||
roles:
|
||||
- effect: Random
|
||||
power_mode: Device
|
||||
target_kind: Tag
|
||||
target_value: Hexagon
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'hs:35,1'
|
||||
intensity: 0.08
|
||||
- color: 'hs:35,1'
|
||||
intensity: 0.085
|
||||
- color: 'hs:35,1'
|
||||
intensity: 0.09
|
||||
- color: 'hs:35,1'
|
||||
intensity: 0.12
|
||||
|
||||
- effect: Gradient
|
||||
power_mode: Both
|
||||
target_kind: Tag
|
||||
target_value: Square
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'hs:25,1'
|
||||
intensity: 0.05
|
||||
power: off
|
||||
- color: 'hs:30,1'
|
||||
intensity: 0.06
|
||||
power: off
|
||||
- color: 'hs:35,1'
|
||||
intensity: 0.07
|
||||
power: on
|
||||
|
||||
- effect: Static
|
||||
power_mode: Device
|
||||
target_kind: Tag
|
||||
target_value: Nightstand
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'k:2000'
|
||||
intensity: 0.15
|
||||
|
||||
- effect: Static
|
||||
power_mode: Scene
|
||||
target_kind: All
|
||||
target_value: ''
|
||||
interpolate: true
|
||||
relative: false
|
||||
states:
|
||||
- power: false
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Morning
|
||||
interval: 1000
|
||||
roles:
|
||||
- effect: WalkingGradient
|
||||
power_mode: Device
|
||||
target_kind: Tag
|
||||
target_value: Hexagon
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'hs:220,0.4'
|
||||
intensity: 0.65
|
||||
- color: 'hs:220,0.5'
|
||||
intensity: 0.50
|
||||
|
||||
- effect: Random
|
||||
power_mode: Device
|
||||
target_kind: Tag
|
||||
target_value: Square
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'hs:220,0.6'
|
||||
intensity: 0.30
|
||||
- color: 'hs:220,0.5'
|
||||
intensity: 0.35
|
||||
|
||||
- effect: Static
|
||||
power_mode: Device
|
||||
target_kind: All
|
||||
target_value: ''
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'hs:250,0.6'
|
||||
intensity: 0.3
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Saturday
|
||||
interval: 700
|
||||
roles:
|
||||
- effect: WalkingGradient
|
||||
power_mode: Device
|
||||
target_kind: Tag
|
||||
target_value: Hexagon
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'hs:220,0.5'
|
||||
intensity: 0.25
|
||||
- color: 'hs:220,0.5'
|
||||
intensity: 0.30
|
||||
|
||||
- effect: Random
|
||||
power_mode: Device
|
||||
target_kind: Tag
|
||||
target_value: Square
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'hs:220,0.6'
|
||||
intensity: 0.15
|
||||
- color: 'hs:220,0.55'
|
||||
intensity: 0.16
|
||||
- color: 'hs:220,0.5'
|
||||
intensity: 0.20
|
||||
|
||||
- effect: Static
|
||||
power_mode: Device
|
||||
target_kind: All
|
||||
target_value: ''
|
||||
interpolate: true
|
||||
relative: false
|
||||
order: +name
|
||||
states:
|
||||
- color: 'hs:250,0.7'
|
||||
intensity: 0.3
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE device
|
||||
(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
bridge_id INT NOT NULL,
|
||||
internal_id VARCHAR(255) NOT NULL,
|
||||
icon VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
capabilities VARCHAR(255) NOT NULL,
|
||||
button_names VARCHAR(255) NOT NULL,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE (bridge_id, internal_id)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE device;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE device_property
|
||||
(
|
||||
device_id INT NOT NULL,
|
||||
prop_key VARCHAR(255) NOT NULL,
|
||||
prop_value VARCHAR(255) NOT NULL,
|
||||
is_user TINYINT NOT NULL DEFAULT 0,
|
||||
|
||||
PRIMARY KEY (device_id, prop_key)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE device_property;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE device_state
|
||||
(
|
||||
device_id INT NOT NULL,
|
||||
hue DOUBLE NOT NULL,
|
||||
saturation DOUBLE NOT NULL,
|
||||
kelvin INT NOT NULL,
|
||||
power TINYINT NOT NULL,
|
||||
intensity DOUBLE NOT NULL,
|
||||
|
||||
PRIMARY KEY (device_id)
|
||||
);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE device_state;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE color_preset
|
||||
(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
hue DOUBLE NOT NULL,
|
||||
saturation DOUBLE NOT NULL,
|
||||
kelvin INT NOT NULL,
|
||||
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE color_preset;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE color_preset
|
||||
ADD COLUMN name VARCHAR(255) NOT NULL DEFAULT 'Unnamed';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE color_preset
|
||||
DROP COLUMN name;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE device_tag
|
||||
(
|
||||
device_id INT NOT NULL,
|
||||
tag_name VARCHAR(255) NOT NULL,
|
||||
|
||||
PRIMARY KEY (device_id, tag_name),
|
||||
INDEX (tag_name)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE device_tag;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,21 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE event_handler
|
||||
(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
event_name VARCHAR(255) NOT NULL,
|
||||
one_shot TINYINT NOT NULL DEFAULT 0,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
target_kind VARCHAR(255) NOT NULL,
|
||||
target_value VARCHAR(255) NOT NULL,
|
||||
conditions JSON NOT NULL,
|
||||
actions JSON NOT NULL,
|
||||
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE event_handler;
|
||||
-- +goose StatementEnd
|
||||
@@ -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
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
node_modules/
|
||||
node_modules/
|
||||
yarn.error.log
|
||||
|
||||
+56
-43
@@ -1,43 +1,56 @@
|
||||
{
|
||||
"name": "webui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"typescript": "^4.1.2",
|
||||
"web-vitals": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "webui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@iconify-icons/mdi": "^1.1.15",
|
||||
"@iconify/react": "^1.1.4",
|
||||
"@jaames/iro": "^5.5.1",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"axios": "^0.21.1",
|
||||
"hookrouter": "^1.2.5",
|
||||
"kelvin-to-rgb": "^1.0.2",
|
||||
"node-sass": "4.14.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-modal": "^3.14.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"react-semantic-ui-range": "^0.7.1",
|
||||
"typescript": "^4.1.2",
|
||||
"web-vitals": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/hookrouter": "^2.2.5",
|
||||
"@types/react-modal": "^3.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
+72
-26
@@ -1,26 +1,72 @@
|
||||
import React from 'react';
|
||||
import logo from './logo.svg';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
import React, {useContext, useLayoutEffect} from 'react';
|
||||
import {HookRouter, navigate, usePath, useRoutes} from "hookrouter";
|
||||
import {Page, Tabs} from "./primitives/Layout";
|
||||
import {IconElement} from "./primitives/Elements";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import {LuciferIcon} from "./models/Icons";
|
||||
import ReactModal from "react-modal";
|
||||
import DialogContext, {DialogContextProvider, makeDialog} from "./contexts/DialogContext";
|
||||
|
||||
const routeObj: HookRouter.RouteObject = {
|
||||
"/": () => (
|
||||
<Page>
|
||||
<h1> </h1>
|
||||
<h1>Enheter</h1>
|
||||
</Page>
|
||||
),
|
||||
"/devices": () => (
|
||||
<div>
|
||||
<IconElement icon={LuciferIcon.Hexagon} caption="Grønn" color={{h: 125, s: 0.5}}/>
|
||||
<IconElement icon={LuciferIcon.Square} caption="Kelvin 1k" color={{kelvin: 1000}}/>
|
||||
<IconElement icon={LuciferIcon.Bulb} caption="Kelvin 2k" color={{kelvin: 2000}}/>
|
||||
<IconElement icon={LuciferIcon.BulbGroup} caption="Kelvin 3k" color={{kelvin: 3000}}/>
|
||||
<IconElement icon={LuciferIcon.Bridge} caption="Kelvin 4k" color={{kelvin: 4000}}/>
|
||||
<IconElement icon={LuciferIcon.Cog} caption="Kelvin 5k" color={{kelvin: 5000}}/>
|
||||
<IconElement icon={LuciferIcon.Switch} caption="Kelvin 6k" color={{kelvin: 6000}}/>
|
||||
<IconElement icon={LuciferIcon.Triangle} caption="Kelvin 7k" color={{kelvin: 7000}}/>
|
||||
<IconElement icon={LuciferIcon.HexagonGroup} caption="Kelvin 8k" color={{kelvin: 8000}}/>
|
||||
<IconElement icon={LuciferIcon.Router} caption="Kelvin 9k" color={{kelvin: 9000}}/>
|
||||
<IconElement icon={LuciferIcon.Check} caption="Kelvin 10k" color={{kelvin: 10000}}/>
|
||||
<IconElement icon={LuciferIcon.Cross} caption="Kelvin 10k" color={{kelvin: 10000}}/>
|
||||
<IconElement caption="Mer" color="hs-gradient"/>
|
||||
<IconElement caption="Mer" color="k-gradient"/>
|
||||
</div>
|
||||
),
|
||||
"/settings": () => <SettingsPage/>,
|
||||
}
|
||||
|
||||
const routeList = ["/", "/devices", "/settings"];
|
||||
|
||||
const tabNames = ["Lucifer", "Enheter", "Oppsett"];
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DialogContextProvider>
|
||||
<AppInContext/>
|
||||
</DialogContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppInContext() {
|
||||
const route = useRoutes(routeObj);
|
||||
const path = usePath();
|
||||
const {dialog} = useContext(DialogContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ReactModal.setAppElement("#root");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<Tabs tabNames={tabNames}
|
||||
index={routeList.indexOf(path)}
|
||||
onChange={i => navigate(routeList[i])}
|
||||
large boldIndex={0}
|
||||
/>
|
||||
{route || <Page>404</Page>}
|
||||
{makeDialog(dialog)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {ColorPreset} from "../models/Colors";
|
||||
import {deleteRequest, getRequest, postRequest, putRequest} from "../helpers/rest";
|
||||
|
||||
export function listColorPresets(): Promise<ColorPreset[]> {
|
||||
return getRequest("/color-presets");
|
||||
}
|
||||
|
||||
export function addColorPreset(name: string, colorString: string) {
|
||||
return postRequest("/color-presets", {name, colorString});
|
||||
}
|
||||
|
||||
export function editColorPreset(id: number, name: string, colorString: string) {
|
||||
return putRequest(`/color-presets/${id}`, {name, colorString});
|
||||
}
|
||||
|
||||
export function deleteColorPreset(id: number) {
|
||||
return deleteRequest(`/color-presets/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, {createContext, useCallback, useEffect, useState} from "react";
|
||||
import {ColorPreset} from "../models/Colors";
|
||||
import {DataTarget} from "../models/Shared";
|
||||
import {unimplemented} from "../helpers/misc";
|
||||
import {listColorPresets} from "../actions/ColorPresets";
|
||||
|
||||
interface DataContextValue {
|
||||
colorPresets: ColorPreset[]
|
||||
|
||||
ready(target?: DataTarget): boolean
|
||||
|
||||
refresh(target?: DataTarget): void
|
||||
}
|
||||
|
||||
const DataContext = createContext<DataContextValue>({
|
||||
colorPresets: [],
|
||||
ready: unimplemented,
|
||||
refresh: unimplemented,
|
||||
});
|
||||
|
||||
export const DataContextProvider: React.FC = ({children}) => {
|
||||
const [colorPresets, setColorPresets] = useState<ColorPreset[]>([]);
|
||||
|
||||
const [readyMap, setReadyMap] = useState<{ [key: string]: boolean }>({
|
||||
[DataTarget.Default]: false,
|
||||
[DataTarget.ColorPresets]: false,
|
||||
});
|
||||
|
||||
const ready = useCallback((target: DataTarget = DataTarget.Default) => {
|
||||
return readyMap[target] || false;
|
||||
}, [readyMap]);
|
||||
|
||||
const refresh = useCallback((target: DataTarget = DataTarget.Default) => {
|
||||
const refreshing: Promise<any>[] = [];
|
||||
if ([DataTarget.Default, DataTarget.ColorPresets].includes(target)) {
|
||||
refreshing.push(listColorPresets().then(setColorPresets));
|
||||
}
|
||||
|
||||
Promise.all(refreshing).then(() => setReadyMap(prev => ({...prev, [target]: true})));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={{
|
||||
colorPresets,
|
||||
ready, refresh,
|
||||
}}>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataContext;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, {createContext, useCallback, useState} from "react";
|
||||
import {DialogConfig, DialogType} from "../models/Dialog";
|
||||
import {unimplemented} from "../helpers/misc";
|
||||
import ColorDialog from "../dialogs/ColorDialog";
|
||||
|
||||
interface DialogContextValue {
|
||||
dialog: DialogConfig,
|
||||
setDialog: (dialog: DialogConfig) => void,
|
||||
clearDialog: () => void,
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextValue>({
|
||||
dialog: {type: DialogType.None},
|
||||
setDialog: unimplemented,
|
||||
clearDialog: unimplemented,
|
||||
});
|
||||
|
||||
export const DialogContextProvider: React.FC = ({children}) => {
|
||||
const [dialog, setDialog] = useState<DialogConfig>({type: DialogType.None});
|
||||
|
||||
const clearDialog = useCallback(() => {
|
||||
setDialog({type: DialogType.None});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{dialog, setDialog, clearDialog}}>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function makeDialog(dialog: DialogConfig) {
|
||||
switch (dialog.type) {
|
||||
case DialogType.None:
|
||||
return undefined;
|
||||
case DialogType.AddColor:
|
||||
return <ColorDialog/>;
|
||||
}
|
||||
}
|
||||
|
||||
export default DialogContext;
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, {useCallback, useContext, useEffect, useState} from "react";
|
||||
import {ColorPreset} from "../models/Colors";
|
||||
import DialogContext from "../contexts/DialogContext";
|
||||
import {DialogType} from "../models/Dialog";
|
||||
import {Dialog} from "../primitives/Layout";
|
||||
import {LuciferIcon} from "../models/Icons";
|
||||
import {HSColorPicker, Input, KelvinColorPicker} from "../primitives/Forms";
|
||||
|
||||
interface ColorDialogProps {
|
||||
data?: ColorPreset
|
||||
}
|
||||
|
||||
enum ColorType {
|
||||
HueSat,
|
||||
Kelvin,
|
||||
HueSatKelvin,
|
||||
}
|
||||
|
||||
const COLOR_TYPE_OPTIONS = [
|
||||
{value: ColorType.HueSat, name: "Bare Nyanse (H) og metning (S)"},
|
||||
{value: ColorType.Kelvin, name: "Bare temperatur (K)"},
|
||||
{value: ColorType.HueSatKelvin, name: "Nyanse (H), metning (K) og kelvin (S)"},
|
||||
];
|
||||
|
||||
export default function ColorDialog({data}: ColorDialogProps) {
|
||||
const {setDialog, clearDialog} = useContext(DialogContext);
|
||||
|
||||
const [name, setName] = useState(data?.name || "");
|
||||
const [hue, setHue] = useState(data?.value?.h || 0);
|
||||
const [sat, setSat] = useState(data?.value?.s || 0);
|
||||
const [kelvin, setKelvin] = useState(data?.value?.kelvin || 0);
|
||||
|
||||
const [colorType, setColorType] = useState(COLOR_TYPE_OPTIONS[0].value);
|
||||
const [isInit, setIsInit] = useState(false);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
setDialog({type: DialogType.None});
|
||||
}, [setDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInit && data) {
|
||||
const v = data.value;
|
||||
|
||||
if (v.kelvin !== undefined && v.kelvin > 0) {
|
||||
setColorType((v.h || v.s) ? ColorType.HueSatKelvin : ColorType.Kelvin);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInit) {
|
||||
setIsInit(true);
|
||||
}
|
||||
}, [isInit, data, colorType]);
|
||||
|
||||
return (
|
||||
<Dialog title={data ? "Endre farge" : "Ny farge"} buttons={[
|
||||
{icon: LuciferIcon.Check, text: "Lagre", onClick: onSave},
|
||||
{icon: LuciferIcon.Cross, text: "Avbryt", onClick: clearDialog},
|
||||
]}>
|
||||
<Input label="Navn" value={name} onChange={setName}/>
|
||||
{colorType !== ColorType.Kelvin && (
|
||||
<HSColorPicker h={hue} s={sat} onChange={(h, s) => {
|
||||
setHue(h);
|
||||
setSat(s);
|
||||
}}/>
|
||||
)}
|
||||
{colorType !== ColorType.HueSat && (
|
||||
<KelvinColorPicker kelvin={kelvin} onChange={setKelvin}/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user