52 Commits
Author SHA1 Message Date
gisle ba22187e6e feature-scenesystem (#1)
continuous-integration/drone/push Build is passing
Co-authored-by: Gisle Aune <dev@gisle.me>
Reviewed-on: #1
Co-authored-by: gisle <gisle@hidden-email>
Co-committed-by: gisle <gisle@hidden-email>
2021-10-06 18:13:16 +00:00
Stian Fredrik Aune d91bd472dd Merge branch 'asmodeus' of git.aiterp.net:lucifer/new-server into asmodeus
continuous-integration/drone/push Build is failing
continuous-integration/drone Build is passing
continuous-integration/drone/tag Build is passing
2021-10-03 19:10:06 +02:00
Stian Fredrik Aune 251687b85d Edit. 2021-10-03 19:10:02 +02:00
gisle ade49589a9 add another test and fix local.
continuous-integration/drone/push Build is failing
2021-10-03 18:35:04 +02:00
gisle ad2f912a6f change presence events, fix non-eq conditions.
continuous-integration/drone/push Build is failing
2021-10-02 17:34:44 +02:00
gisle 2abb4b1024 add time-of-day limitation to events.
continuous-integration/drone/push Build is failing
2021-10-02 14:43:15 +02:00
gisle b83673b2cc change nanoleaf default names to start at 1 instead of 0.
continuous-integration/drone/push Build is failing
2021-09-30 23:08:33 +02:00
gisle 6fe0d8b864 adapt Nanoleaf driver to Canvas.
continuous-integration/drone/push Build is failing
2021-09-30 23:07:11 +02:00
Stian Fredrik Aune c7d7e264ca Lucy handler add 2021-09-28 21:54:03 +02:00
gisle 0cd325e8db add SaveMode for device which can be 0 (All) or a bitmask for what to save. It did not improve performance
continuous-integration/drone/push Build is passing
2021-09-26 10:50:54 +02:00
Stian Fredrik Aune b1fa52e53f lucy handler delete
continuous-integration/drone/push Build is passing
2021-09-25 19:03:11 +02:00
Stian Fredrik Aune b28496fd6b less focus on oneshot
continuous-integration/drone/push Build is passing
2021-09-25 17:27:56 +02:00
Stian Fredrik Aune 35c3a387c9 lucy handler list
continuous-integration/drone/push Build is passing
2021-09-25 17:26:27 +02:00
Stian Fredrik Aune b7b6b06748 .
continuous-integration/drone/push Build is passing
2021-09-24 19:51:56 +02:00
gisle 27f8c6b91d fix not being able to setOneShot in event updates. 2021-09-24 18:05:51 +02:00
gisle 0846290047 >_> 2021-09-24 17:14:27 +02:00
gisle 9b9e5e9da8 add lucy config. 2021-09-24 17:12:27 +02:00
gisle 3c2aa7212d add replace of * to % in fetch. 2021-09-24 17:03:25 +02:00
gisle d6c38d647f fix cli app help. 2021-09-24 16:59:14 +02:00
gisle 06fa73b916 add beginning of CLI app and some backend fixes. 2021-09-24 16:58:24 +02:00
gisle 7a6ee3f87b add device search endpoint. >_> 2021-09-21 20:55:13 +02:00
gisle d623647e21 fix device fetch returning duplicate results if overlapping tags are supplied. 2021-09-21 17:54:10 +02:00
gisle 4fd49d0480 add filtering to tags. E.g. Hue,Namrom+Gisle will return all with either of the former AND the latter. - will do the opposite. 2021-09-21 17:51:28 +02:00
gisle 38d777404b fix 'all.*' conditions counting 'incapable' devices like sensors. 2021-09-20 22:37:41 +02:00
gisle 2f9bd3c007 fixed hue updating too often, and change logic for fetchbyreference to support multiple tags, ids or bridges. 2021-09-20 22:09:41 +02:00
gisle 56a0525b9d add event handler API and logic. 2021-09-20 21:05:24 +02:00
gisle 1cf8c019e5 add EventHandlerRepo (probably has bugs) 2021-09-20 18:31:32 +02:00
gisle b2c9cca400 finish EventHandler model for now. 2021-09-20 18:10:18 +02:00
gisle 4109d41bec fix bug with empty buttons array being mistaken as [], and add /api/devices/batch. 2021-09-18 18:01:16 +02:00
gisle 616adc14a4 cancel 2021-09-18 16:14:12 +02:00
gisle 26fcc1f314 small fixed, device repo and some device api 2021-09-18 16:08:57 +02:00
gisle 4e734c86a5 Merge branch 'asmodeus' of git.aiterp.net:lucifer/new-server into asmodeus 2021-07-12 20:26:55 +02:00
gisle 766c534fcb add HorribleBroadcast™ functionality to LifX driver. 2021-07-12 20:26:50 +02:00
Stian Fredrik Aune 79fc2a319c wip form 2021-07-03 23:24:09 +02:00
Stian Fredrik Aune e9bfbfbec7 wip: colors 2021-07-02 08:58:09 +02:00
Stian Fredrik Aune b2375e5864 wip colors 2021-06-13 12:47:08 +02:00
Stian Fredrik Aune 22c80a90a1 dialogs sorta work now 2021-06-10 21:48:09 +02:00
gisle 95ec918ed1 add lifx driver. It works with the bulb, but the code is a bit smelly. 2021-06-07 19:46:02 +02:00
gisle fff01befda fixed bug in hue driver where it would update either all or no bulbs. 2021-06-06 15:22:56 +02:00
Stian Fredrik Aune ea3037b03a started on icons 2021-06-05 18:31:21 +02:00
gisle 677d9aa9b1 add support for hue temp sensor, fix saturation not working for hue bulbs. 2021-06-04 09:57:21 +02:00
gisle 5cdb4f3cf5 fix hue light/sensor search and add a separate SensorPresenceEnding event. 2021-06-03 23:30:23 +02:00
gisle 74fb2c6b5a fix oops in bridgetest. 2021-05-29 16:58:44 +02:00
gisle 2aa2757828 add rest of hue driver functionality. 2021-05-29 16:29:10 +02:00
Stian Fredrik Aune 38d370894e webui stuff 2021-05-24 13:02:52 +02:00
Stian Fredrik Aune a2e719def9 Bridge events 2021-05-24 10:31:31 +02:00
gisle 883115bf8b Merge branch 'asmodeus' of git.aiterp.net:lucifer/new-server into asmodeus 2021-05-24 08:30:36 +02:00
gisle 954d6bd3e1 add state restore after Nanoleaf controller reboots. 2021-05-24 08:30:32 +02:00
Stian Fredrik Aune e1f38e3dcd color picker component 2021-05-23 23:51:31 +02:00
Stian Fredrik Aune f045bc0f4d color api 2021-05-23 22:22:15 +02:00
Stian Fredrik Aune e4078de845 driver in bridgetest 2021-05-23 15:08:37 +02:00
gisle d6fdb48023 mark Nanoleaf panels for update if updateEffect changes th effect. 2021-05-23 14:44:38 +02:00
104 changed files with 8631 additions and 12176 deletions
+40
View File
@@ -0,0 +1,40 @@
name: lucifer3
kind: pipeline
type: docker
steps:
- name: go-build
image: golang:1.17
commands:
- go test -v ./...
when:
event:
exclude:
- tag
- name: go-test
image: golang:1.17
commands:
- 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
+7
View File
@@ -2,3 +2,10 @@
.env .env
.idea/ .idea/
*.iml *.iml
/bridgetest
/server
/goose
/bridgetest.exe
/server.exe
/goose.exe
/10.24.4.1
+58 -3
View File
@@ -1,10 +1,12 @@
package api package api
import ( import (
"fmt"
"git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/models" "git.aiterp.net/lucifer/new-server/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"log" "log"
"time"
) )
func Bridges(r gin.IRoutes) { func Bridges(r gin.IRoutes) {
@@ -12,11 +14,17 @@ func Bridges(r gin.IRoutes) {
return config.BridgeRepository().FetchAll(ctxOf(c)) 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) { r.POST("", handler(func(c *gin.Context) (interface{}, error) {
var body struct { var body struct {
Driver models.DriverKind `json:"driver"` Driver models.DriverKind `json:"driver"`
Address string `json:"address"` Address string `json:"address"`
DryRun bool `json:"dryRun"` Token string `json:"token"`
ForceSave bool `json:"forceSave"`
DryRun bool `json:"dryRun"`
} }
err := parseBody(c, &body) err := parseBody(c, &body)
if err != nil { if err != nil {
@@ -28,6 +36,22 @@ func Bridges(r gin.IRoutes) {
return nil, err 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.DryRun) bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.DryRun)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -73,6 +97,37 @@ func Bridges(r gin.IRoutes) {
return bridge, nil 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) { r.DELETE("/:id", handler(func(c *gin.Context) (interface{}, error) {
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id")) bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
if err != nil { if err != nil {
+280
View File
@@ -0,0 +1,280 @@
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"
"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
go func() {
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
}))
}
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
}
+82
View File
@@ -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
}))
}
+97
View File
@@ -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
}))
}
+68
View File
@@ -0,0 +1,68 @@
package api
import (
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/app/services/publisher"
"git.aiterp.net/lucifer/new-server/models"
"github.com/gin-gonic/gin"
)
func Scenes(r gin.IRoutes) {
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
return config.SceneRepository().FetchAll(ctxOf(c))
}))
r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) {
return config.SceneRepository().Find(ctxOf(c), intParam(c, "id"))
}))
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
var body models.Scene
err := parseBody(c, &body)
if err != nil {
return nil, err
}
err = body.Validate()
if err != nil {
return nil, err
}
err = config.SceneRepository().Save(ctxOf(c), &body)
if err != nil {
return nil, err
}
publisher.Global().UpdateScene(body)
return body, nil
}))
r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) {
var body models.Scene
err := parseBody(c, &body)
if err != nil {
return nil, err
}
scene, err := config.SceneRepository().Find(ctxOf(c), intParam(c, "id"))
if err != nil {
return nil, err
}
body.ID = scene.ID
err = body.Validate()
if err != nil {
return nil, err
}
err = config.SceneRepository().Save(ctxOf(c), &body)
if err != nil {
return nil, err
}
publisher.Global().UpdateScene(body)
return body, nil
}))
}
+7
View File
@@ -15,6 +15,13 @@ var errorMap = map[error]int{
models.ErrBadColor: 400, models.ErrBadColor: 400,
models.ErrInternal: 500, models.ErrInternal: 500,
models.ErrUnknownColorFormat: 400, models.ErrUnknownColorFormat: 400,
models.ErrSceneInvalidInterval: 400,
models.ErrSceneNoRoles: 400,
models.ErrSceneRoleNoStates: 400,
models.ErrSceneRoleUnsupportedOrdering: 422,
models.ErrSceneRoleUnknownEffect: 422,
models.ErrSceneRoleUnknownPowerMode: 422,
} }
type response struct { type response struct {
+161
View File
@@ -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,
}
+57
View File
@@ -0,0 +1,57 @@
package client
import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
)
func (client *Client) GetHandlers(ctx context.Context) ([]models.EventHandler, error) {
handlers := make([]models.EventHandler, 0, 16)
err := client.Fetch(ctx, "GET", "/api/event-handlers", &handlers, nil)
if err != nil {
return nil, err
}
return handlers, nil
}
func (client *Client) GetHandler(ctx context.Context, id int) (*models.EventHandler, error) {
var handler models.EventHandler
err := client.Fetch(ctx, "GET", fmt.Sprintf("/api/event-handlers/%d", id), &handler, nil)
if err != nil {
return nil, err
}
return &handler, nil
}
func (client *Client) PutHandler(ctx context.Context, handler *models.EventHandler) (*models.EventHandler, error) {
var response models.EventHandler
err := client.Fetch(ctx, "PUT", fmt.Sprintf("/api/event-handlers/%d?hard=true", handler.ID), &response, handler)
if err != nil {
return nil, err
}
return &response, nil
}
func (client *Client) AddHandler(ctx context.Context, handler models.EventHandler) (*models.EventHandler, error) {
var result models.EventHandler
err := client.Fetch(ctx, "POST", "/api/event-handlers", &result, handler)
if err != nil {
return nil, err
}
return &result, nil
}
func (client *Client) DeleteHandler(ctx context.Context, id int) (*models.EventHandler, error) {
var handler models.EventHandler
err := client.Fetch(ctx, "DELETE", fmt.Sprintf("/api/event-handlers/%d", id), &handler, nil)
if err != nil {
return nil, err
}
return &handler, nil
}
+16
View File
@@ -0,0 +1,16 @@
package client
import (
"context"
"git.aiterp.net/lucifer/new-server/models"
)
func (client *Client) GetScenes(ctx context.Context) ([]models.Scene, error) {
scenes := make([]models.Scene, 0, 16)
err := client.Fetch(ctx, "GET", "/api/scenes", &scenes, nil)
if err != nil {
return nil, err
}
return scenes, nil
}
+4
View File
@@ -23,6 +23,10 @@ func DBX() *sqlx.DB {
MySqlPort(), MySqlPort(),
MySqlSchema(), MySqlSchema(),
)) ))
dbx.SetMaxIdleConns(20)
dbx.SetMaxOpenConns(40)
dbx.SetConnMaxLifetime(0)
} }
return dbx return dbx
+16 -4
View File
@@ -1,13 +1,25 @@
package config 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/nanoleaf"
"git.aiterp.net/lucifer/new-server/models"
"sync"
)
var dp models.DriverProvider var dp models.DriverProvider
var dpOnce sync.Once
func DriverProvider() models.DriverProvider { func DriverProvider() models.DriverProvider {
if dp == nil { dpOnce.Do(func() {
panic("not implemented yet") dp = drivers.DriverMap{
} models.DTNanoLeaf: &nanoleaf.Driver{},
models.DTHue: &hue.Driver{},
models.DTLIFX: &lifx.Driver{},
}
})
return dp return dp
} }
+9 -19
View File
@@ -5,32 +5,22 @@ import (
"git.aiterp.net/lucifer/new-server/models" "git.aiterp.net/lucifer/new-server/models"
) )
var (
bRepo models.BridgeRepository
dRepo models.DeviceRepository
ehRepo models.EventHandlerRepository
)
func BridgeRepository() models.BridgeRepository { func BridgeRepository() models.BridgeRepository {
if bRepo == nil { return &mysql.BridgeRepo{DBX: DBX()}
bRepo = &mysql.BridgeRepo{DBX: DBX()} }
}
return bRepo func ColorPresetRepository() models.ColorPresetRepository {
return &mysql.ColorPresetRepo{DBX: DBX()}
} }
func DeviceRepository() models.DeviceRepository { func DeviceRepository() models.DeviceRepository {
if dRepo == nil { return &mysql.DeviceRepo{DBX: DBX()}
panic("panik")
}
return dRepo
} }
func EventHandlerRepository() models.EventHandlerRepository { func EventHandlerRepository() models.EventHandlerRepository {
if ehRepo == nil { return &mysql.EventHandlerRepo{DBX: DBX()}
panic("panik") }
}
return ehRepo func SceneRepository() models.SceneRepository {
return &mysql.SceneRepo{DBX: DBX()}
} }
+18
View File
@@ -1,24 +1,42 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"git.aiterp.net/lucifer/new-server/app/api" "git.aiterp.net/lucifer/new-server/app/api"
"git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/app/services" "git.aiterp.net/lucifer/new-server/app/services"
"git.aiterp.net/lucifer/new-server/app/services/publisher"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"log" "log"
"time"
) )
func StartServer() { 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.StartEventHandler()
services.ConnectToBridges()
services.CheckNewDevices()
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
ginny := gin.New() ginny := gin.New()
apiGin := ginny.Group("/api") apiGin := ginny.Group("/api")
api.Bridges(apiGin.Group("/bridges")) api.Bridges(apiGin.Group("/bridges"))
api.Devices(apiGin.Group("/devices"))
api.ColorPresets(apiGin.Group("/color-presets"))
api.DriverKinds(apiGin.Group("/driver-kinds")) api.DriverKinds(apiGin.Group("/driver-kinds"))
api.Events(apiGin.Group("/events")) 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()))) log.Fatal(ginny.Run(fmt.Sprintf("0.0.0.0:%d", config.ServerPort())))
} }
+66
View File
@@ -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
}
+171 -11
View File
@@ -2,37 +2,60 @@ package services
import ( import (
"context" "context"
"fmt"
"git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/models" "git.aiterp.net/lucifer/new-server/models"
"log" "log"
"math"
"sort"
"strconv"
"strings"
"sync"
"time" "time"
) )
func StartEventHandler() { func StartEventHandler() {
config.EventChannel<-models.Event{Name: "LuciferStarted"} config.EventChannel <- models.Event{Name: "LuciferStarted"}
go func() { go func() {
for event := range config.EventChannel { 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() { go func() {
drift := time.Now().Add(time.Minute).Truncate(time.Minute).Sub(time.Now()) 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 { 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 loc, _ = time.LoadLocation("Europe/Oslo")
var ctx = context.Background() 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") { if !event.HasPayload("hour") {
event.AddPayload("hour", time.Now().In(loc).Format("15")) event.AddPayload("hour", time.Now().In(loc).Format("15"))
} }
@@ -40,21 +63,43 @@ func handleEvent(event models.Event) {
event.AddPayload("minute", time.Now().In(loc).Format("04")) event.AddPayload("minute", time.Now().In(loc).Format("04"))
} }
if !X { err := handleSpecial(event)
log.Println("Unhandled event: " + event.Name) if err != nil {
log.Printf("Special event handler error (%s): %v", event.Name, err)
return return
} }
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) handlers, err := config.EventHandlerRepository().FetchAll(ctx)
if err != nil { if err != nil {
log.Printf("Error fetchin event halders: %d", err) log.Printf("Error fetchin' event halders: %s", err)
return 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 { for _, handler := range handlers {
if handler.EventName != event.Name {
continue
}
if !handler.IsWithinTimeRange() {
continue
}
devices, err := config.DeviceRepository().FetchByReference(ctx, handler.TargetKind, handler.TargetValue) devices, err := config.DeviceRepository().FetchByReference(ctx, handler.TargetKind, handler.TargetValue)
if err != nil { if err != nil {
log.Printf("Error fetchin event halders: %d", err) log.Printf("Error fetchin' event devices: %s", err)
return return
} }
@@ -62,5 +107,120 @@ func handleEvent(event models.Event) {
continue 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
}
}
}
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)
default:
return nil
} }
} }
-47
View File
@@ -1,47 +0,0 @@
package services
import (
"context"
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/models"
"log"
)
func StartPublisher() {
ctx := context.Background()
go func() {
for devices := range config.PublishChannel {
if len(devices) == 0 {
continue
}
// Emergency solution! Please avoid!
// Send devices not belonging to the first channel separately
bridgeID := devices[0].BridgeID
for _, device := range devices {
if device.BridgeID != bridgeID {
config.PublishChannel<-[]models.Device{device}
}
}
bridge, err := config.BridgeRepository().Find(ctx, devices[0].BridgeID)
if err != nil {
log.Println("Publishing error (1): " + err.Error())
continue
}
driver, err := config.DriverProvider().Provide(bridge.Driver)
if err != nil {
log.Println("Publishing error (2): " + err.Error())
continue
}
err = driver.Publish(ctx, bridge, devices)
if err != nil {
log.Println("Publishing error (3): " + err.Error())
continue
}
}
}()
}
+315
View File
@@ -0,0 +1,315 @@
package publisher
import (
"context"
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/models"
"log"
"sync"
"time"
)
type Publisher struct {
mu sync.Mutex
sceneData map[int]*models.Scene
scenes []*Scene
sceneAssignment map[int]*Scene
started map[int]bool
pending map[int][]models.Device
waiting map[int]chan struct{}
}
func (p *Publisher) SceneState(deviceID int) *models.DeviceState {
p.mu.Lock()
defer p.mu.Unlock()
if s := p.sceneAssignment[deviceID]; s != nil {
return s.LastState(deviceID)
}
return nil
}
func (p *Publisher) UpdateScene(data models.Scene) {
p.mu.Lock()
p.sceneData[data.ID] = &data
for _, scene := range p.scenes {
if scene.data.ID == data.ID {
scene.UpdateScene(data)
}
}
p.mu.Unlock()
}
func (p *Publisher) ReloadScenes(ctx context.Context) error {
scenes, err := config.SceneRepository().FetchAll(ctx)
if err != nil {
return err
}
p.mu.Lock()
for i, scene := range scenes {
p.sceneData[scene.ID] = &scenes[i]
}
for _, scene := range p.scenes {
scene.UpdateScene(*p.sceneData[scene.data.ID])
}
p.mu.Unlock()
return nil
}
func (p *Publisher) ReloadDevices(ctx context.Context) error {
devices, err := config.DeviceRepository().FetchByReference(ctx, models.RKAll, "")
if err != nil {
return err
}
p.Publish(devices...)
return nil
}
func (p *Publisher) Publish(devices ...models.Device) {
if len(devices) == 0 {
return
}
p.mu.Lock()
defer p.mu.Unlock()
for _, device := range devices {
if !p.started[device.BridgeID] {
p.started[device.BridgeID] = true
go p.runBridge(device.BridgeID)
}
p.reassignDevice(device)
if p.sceneAssignment[device.ID] != nil {
p.sceneAssignment[device.ID].UpsertDevice(device)
} else {
p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device)
if p.waiting[device.BridgeID] != nil {
close(p.waiting[device.BridgeID])
p.waiting[device.BridgeID] = nil
}
}
}
}
func (p *Publisher) PublishChannel(ch <-chan []models.Device) {
for list := range ch {
p.Publish(list...)
}
}
func (p *Publisher) Run() {
ticker := time.NewTicker(time.Millisecond * 100)
deleteList := make([]int, 0, 8)
updatedList := make([]models.Device, 0, 16)
for range ticker.C {
deleteList = deleteList[:0]
updatedList = updatedList[:0]
p.mu.Lock()
for i, scene := range p.scenes {
if (!scene.endTime.IsZero() && time.Now().After(scene.endTime)) || scene.Empty() {
deleteList = append(deleteList, i-len(deleteList))
updatedList = append(updatedList, scene.AllDevices()...)
log.Printf("Removing scene instance for %s (%d)", scene.data.Name, scene.data.ID)
for _, device := range scene.AllDevices() {
p.sceneAssignment[device.ID] = nil
}
continue
}
if scene.Due() {
updatedList = append(updatedList, scene.Run()...)
updatedList = append(updatedList, scene.UnaffectedDevices()...)
}
}
for _, i := range deleteList {
p.scenes = append(p.scenes[:i], p.scenes[i+1:]...)
}
for _, device := range updatedList {
if !p.started[device.BridgeID] {
p.started[device.BridgeID] = true
go p.runBridge(device.BridgeID)
}
p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device)
if p.waiting[device.BridgeID] != nil {
close(p.waiting[device.BridgeID])
p.waiting[device.BridgeID] = nil
}
}
p.mu.Unlock()
}
}
// reassignDevice re-evaluates the device's scene assignment config. It will return whether the scene changed, which
// should trigger an update.
func (p *Publisher) reassignDevice(device models.Device) bool {
var selectedAssignment *models.DeviceSceneAssignment
for _, assignment := range device.SceneAssignments {
duration := time.Duration(assignment.DurationMS) * time.Millisecond
if duration <= 0 || time.Now().Before(assignment.StartTime.Add(duration)) {
selectedAssignment = &assignment
}
}
if selectedAssignment == nil {
if p.sceneAssignment[device.ID] != nil {
p.sceneAssignment[device.ID].RemoveDevice(device)
delete(p.sceneAssignment, device.ID)
// Scene changed
return true
}
// Stop here, no scene should be assigned.
return false
} else {
if p.sceneData[selectedAssignment.SceneID] == nil {
// Freeze until scene becomes available.
return false
}
}
if p.sceneAssignment[device.ID] != nil {
scene := p.sceneAssignment[device.ID]
if scene.data.ID == selectedAssignment.SceneID && scene.group == selectedAssignment.Group {
// Current assignment is good.
return false
}
p.sceneAssignment[device.ID].RemoveDevice(device)
delete(p.sceneAssignment, device.ID)
}
for _, scene := range p.scenes {
if scene.data.ID == selectedAssignment.SceneID && scene.group == selectedAssignment.Group {
p.sceneAssignment[device.ID] = scene
return true
}
}
newScene := &Scene{
data: p.sceneData[selectedAssignment.SceneID],
group: selectedAssignment.Group,
startTime: selectedAssignment.StartTime,
endTime: selectedAssignment.StartTime.Add(time.Duration(selectedAssignment.DurationMS) * time.Millisecond),
roleMap: make(map[int]int, 16),
roleList: make(map[int][]models.Device, 16),
lastStates: make(map[int]models.DeviceState, 16),
due: true,
}
p.sceneAssignment[device.ID] = newScene
p.scenes = append(p.scenes, newScene)
if selectedAssignment.DurationMS <= 0 {
newScene.endTime = time.Time{}
}
return true
}
func (p *Publisher) runBridge(id int) {
defer func() {
p.mu.Lock()
p.started[id] = false
p.mu.Unlock()
}()
bridge, err := config.BridgeRepository().Find(context.Background(), id)
if err != nil {
log.Println("Failed to get bridge data:", err)
return
}
driver, err := config.DriverProvider().Provide(bridge.Driver)
if err != nil {
log.Println("Failed to get bridge driver:", err)
log.Println("Maybe Lucifer needs to be updated.")
return
}
devices := make(map[int]models.Device)
for {
p.mu.Lock()
if len(p.pending[id]) == 0 {
if p.waiting[id] == nil {
p.waiting[id] = make(chan struct{})
}
waitCh := p.waiting[id]
p.mu.Unlock()
<-waitCh
p.mu.Lock()
}
updates := p.pending[id]
p.pending[id] = p.pending[id][:0:0]
p.mu.Unlock()
// Only allow the latest update per device (this avoids slow bridges causing a backlog of cations).
for key := range devices {
delete(devices, key)
}
for _, update := range updates {
devices[update.ID] = update
}
updates = updates[:0]
for _, value := range devices {
updates = append(updates, value)
}
err := driver.Publish(context.Background(), bridge, updates)
if err != nil {
log.Println("Failed to publish to driver:", err)
p.mu.Lock()
p.pending[id] = append(updates, p.pending[id]...)
p.mu.Unlock()
return
}
}
}
var publisher = Publisher{
sceneData: make(map[int]*models.Scene),
scenes: make([]*Scene, 0, 16),
sceneAssignment: make(map[int]*Scene, 16),
started: make(map[int]bool),
pending: make(map[int][]models.Device),
waiting: make(map[int]chan struct{}),
}
func Global() *Publisher {
return &publisher
}
func Initialize(ctx context.Context) error {
err := publisher.ReloadScenes(ctx)
if err != nil {
return err
}
err = publisher.ReloadDevices(ctx)
if err != nil {
return err
}
go publisher.Run()
time.Sleep(time.Millisecond * 50)
go publisher.PublishChannel(config.PublishChannel)
return nil
}
+186
View File
@@ -0,0 +1,186 @@
package publisher
import (
"git.aiterp.net/lucifer/new-server/models"
"time"
)
type Scene struct {
data *models.Scene
group string
startTime time.Time
endTime time.Time
roleMap map[int]int
roleList map[int][]models.Device
lastStates map[int]models.DeviceState
due bool
lastInterval int64
}
// UpdateScene updates the scene data and re-seats all devices.
func (s *Scene) UpdateScene(data models.Scene) {
devices := make([]models.Device, 0, 16)
// Collect all devices into the undefined role (-1)
for _, list := range s.roleList {
for _, device := range list {
devices = append(devices, device)
s.roleMap[device.ID] = -1
}
}
s.roleList = map[int][]models.Device{-1: devices}
// Update data and reset devices.
s.data = &data
for _, device := range append(devices[:0:0], devices...) {
s.UpsertDevice(device)
}
}
// UpsertDevice moves the device if necessary and updates its state.
func (s *Scene) UpsertDevice(device models.Device) {
if s.data == nil {
s.roleMap[device.ID] = -1
s.roleList[-1] = append(s.roleList[-1], device)
return
}
oldIndex, hasOldIndex := s.roleMap[device.ID]
newIndex := s.data.RoleIndex(&device)
s.roleMap[device.ID] = newIndex
if hasOldIndex {
for i, device2 := range s.roleList[oldIndex] {
if device2.ID == device.ID {
s.roleList[oldIndex] = append(s.roleList[oldIndex][:i], s.roleList[oldIndex][i+1:]...)
break
}
}
}
s.due = true
s.roleList[newIndex] = append(s.roleList[newIndex], device)
if newIndex != -1 {
s.data.Roles[newIndex].ApplyOrder(s.roleList[newIndex])
}
}
// RemoveDevice finds and remove a device. It's a noop if the device does not exist in this scene.
func (s *Scene) RemoveDevice(device models.Device) {
roleIndex, hasRoleIndex := s.roleMap[device.ID]
if !hasRoleIndex {
return
}
for i, device2 := range s.roleList[roleIndex] {
if device2.ID == device.ID {
s.roleList[roleIndex] = append(s.roleList[roleIndex][:i], s.roleList[roleIndex][i+1:]...)
break
}
}
s.due = true
delete(s.roleMap, device.ID)
delete(s.lastStates, device.ID)
}
func (s *Scene) Empty() bool {
for _, list := range s.roleList {
if len(list) > 0 {
return false
}
}
return true
}
func (s *Scene) Due() bool {
if s.due {
return true
}
if s.data.IntervalMS > 0 {
interval := time.Duration(s.data.IntervalMS) * time.Millisecond
return int64(time.Since(s.startTime)/interval) != s.lastInterval
}
return false
}
func (s *Scene) UnaffectedDevices() []models.Device {
return append(s.roleList[-1][:0:0], s.roleList[-1]...)
}
func (s *Scene) AllDevices() []models.Device {
res := make([]models.Device, 0, 16)
for _, list := range s.roleList {
res = append(res, list...)
}
return res
}
// Run runs the scene
func (s *Scene) Run() []models.Device {
if s.data == nil {
return []models.Device{}
}
intervalNumber := int64(0)
intervalMax := int64(1)
if s.data.IntervalMS > 0 {
interval := time.Duration(s.data.IntervalMS) * time.Millisecond
intervalNumber = int64(time.Since(s.startTime) / interval)
if !s.endTime.IsZero() {
intervalMax = int64(s.endTime.Sub(s.startTime) / interval)
} else {
intervalMax = intervalNumber + 1
}
}
updatedDevices := make([]models.Device, 0, 16)
for i, list := range s.roleList {
if i == -1 {
continue
}
role := s.data.Roles[i]
for j, device := range list {
newState := role.ApplyEffect(&device, models.SceneRunContext{
Index: j,
Length: len(list),
IntervalNumber: intervalNumber,
IntervalMax: intervalMax,
})
err := device.SetState(newState)
if err != nil {
continue
}
s.lastStates[device.ID] = device.State
updatedDevices = append(updatedDevices, device)
}
}
s.due = false
s.lastInterval = intervalNumber
return updatedDevices
}
func (s *Scene) LastState(id int) *models.DeviceState {
lastState, ok := s.lastStates[id]
if !ok {
return nil
}
return &lastState
}
+95
View File
@@ -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
}
+58 -21
View File
@@ -3,9 +3,10 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/json"
"flag" "flag"
"fmt" "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" "git.aiterp.net/lucifer/new-server/models"
"log" "log"
"os" "os"
@@ -15,7 +16,7 @@ import (
"time" "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 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 flagToken = flag.String("token", "", "The bridge's access token / api key / login")
var flagPair = flag.Bool("pair", false, "Try to pair with the bridge.") var flagPair = flag.Bool("pair", false, "Try to pair with the bridge.")
@@ -25,8 +26,11 @@ var flagSearchTimeout = flag.Duration("search-timeout", time.Second*3, "Timeout
func main() { func main() {
flag.Parse() flag.Parse()
// TODO: Select driver // Find drivers
driver := nanoleaf.Driver{} driver, err := config.DriverProvider().Provide(models.DriverKind(*flagDriver))
if err != nil {
log.Fatalln("Failed to find driver:", err)
}
// Find bridge // Find bridge
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, !*flagPair) bridges, err := driver.SearchBridge(context.Background(), *flagAddress, !*flagPair)
@@ -43,6 +47,15 @@ func main() {
log.Println("New token:", bridge.Token) 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 // List devices
var devices []models.Device var devices []models.Device
if *flagSearch { if *flagSearch {
@@ -56,20 +69,14 @@ func main() {
log.Fatalln("Failed to list devices:", err) log.Fatalln("Failed to list devices:", err)
} }
} }
idMap := make(map[string]int)
nextId := len(devices) + 1
for i := range devices { for i := range devices {
devices[i].ID = i + 1 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() { go func() {
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
@@ -83,6 +90,35 @@ func main() {
text, _ := reader.ReadString('\n') text, _ := reader.ReadString('\n')
text = strings.Trim(text, "\t  \r\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, " ") tokens := strings.Split(text, " ")
if len(tokens) < 4 { if len(tokens) < 4 {
continue continue
@@ -120,14 +156,15 @@ func main() {
if id == -1 || id == device.ID { if id == -1 || id == device.ID {
if (color.IsKelvin() && device.HasCapability(models.DCColorKelvin)) || (color.IsHueSat() && device.HasCapability(models.DCColorHS)) { if (color.IsKelvin() && device.HasCapability(models.DCColorKelvin)) || (color.IsHueSat() && device.HasCapability(models.DCColorHS)) {
device.State.Color = color 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)
} }
} }
} }
+9 -1
View File
@@ -4,11 +4,14 @@ import (
"git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/app/config"
"github.com/pressly/goose" "github.com/pressly/goose"
"log" "log"
"os"
"time"
) )
func main() { func main() {
db := config.DBX().DB db := config.DBX().DB
log.Printf("Target version: %s",time.Now().Format("20060102150405"))
log.Printf("Database: %s:%d/%s", config.MySqlHost(), config.MySqlPort(), config.MySqlSchema()) log.Printf("Database: %s:%d/%s", config.MySqlHost(), config.MySqlPort(), config.MySqlSchema())
log.Printf("Authenticating as: %s", config.MySqlUsername()) log.Printf("Authenticating as: %s", config.MySqlUsername())
@@ -17,7 +20,12 @@ func main() {
log.Fatal(err) 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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
+277
View File
@@ -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] = &param.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
}
+31
View File
@@ -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
}
+185
View File
@@ -0,0 +1,185 @@
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":
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
// 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
}
+39
View File
@@ -0,0 +1,39 @@
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 COMMANDS
run <event name> <*=S>
NOTE: You may have to surround arguments including < or > with quotation marks!
`
+129
View File
@@ -0,0 +1,129 @@
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 "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:])
}
}
+165
View File
@@ -0,0 +1,165 @@
package main
import (
"context"
"git.aiterp.net/lucifer/new-server/app/client"
"git.aiterp.net/lucifer/new-server/models"
"gopkg.in/yaml.v2"
"log"
"os"
"strconv"
"strings"
"unicode"
)
func sceneCmd(
ctx context.Context,
c client.Client,
) {
cmd := parseCommand(os.Args[2:])
switch cmd.Name {
case "create", "update":
{
fileName := cmd.Params.Get(0).String()
if fileName == nil {
log.Fatalln("Missing filename")
}
file, err := os.Open(*fileName)
if err != nil {
log.Fatalln("Failed to open file:", err)
}
defer file.Close()
yamlData := make(map[string]interface{})
err = yaml.NewDecoder(file).Decode(yamlData)
if err != nil {
log.Fatalln("Failed to decode file:", err)
return
}
yamlData = camelCasify(yamlData)
name, nameOk := yamlData["name"]
if !nameOk {
log.Fatalln("Missing name in yaml data.")
}
var scene models.Scene
if cmd.Name == "create" {
err := c.Fetch(ctx, "POST", "/api/scenes", &scene, yamlData)
if err != nil {
log.Fatalln("Failed to create scene:", err)
return
}
} else {
scenes, err := c.GetScenes(ctx)
if err != nil {
log.Fatalln("Failed to fetch existing scenes:", err)
return
}
id := -1
for _, scene := range scenes {
if scene.Name == name {
id = scene.ID
break
}
}
if id == -1 {
log.Fatalln("Could not find scene with name", name)
return
}
err = c.Fetch(ctx, "PUT", "/api/scenes/"+strconv.Itoa(id), &scene, yamlData)
if err != nil {
log.Fatalln("Failed to update scene:", err)
return
}
}
}
case "push", "assign":
{
fetch := cmd.Params.Get(0).String()
id := cmd.Params.Get(1).Int()
if fetch == nil || id == nil {
log.Println("Usage: lucy scene assign <fetch> <id> <group=S> <duration=I>")
}
devices, err := c.AssignDevice(ctx, *fetch, cmd.Name == "push", models.DeviceSceneAssignment{
SceneID: *id,
Group: cmd.Params.Get("group").StringOr(*fetch),
DurationMS: int64(cmd.Params.Get("duration").IntOr(0)),
})
if err != nil {
log.Println("Could not assign devices:", err)
return
}
WriteDeviceInfoTable(os.Stdout, devices)
}
case "clear":
fetch := cmd.Params.Get(0).String()
if fetch == nil {
log.Println("Usage: lucy scene clear <fetch>")
}
devices, err := c.ClearDevice(ctx, *fetch)
if err != nil {
log.Println("Could not clear devices:", err)
return
}
WriteDeviceInfoTable(os.Stdout, devices)
}
}
func camelCasify(m map[string]interface{}) map[string]interface{} {
m2 := make(map[string]interface{}, len(m))
for key, value := range m {
b := strings.Builder{}
snake := false
for _, ch := range key {
if ch == '_' {
snake = true
} else if snake {
b.WriteRune(unicode.ToUpper(ch))
snake = false
} else {
b.WriteRune(ch)
}
}
switch value := value.(type) {
case []interface{}:
valueCopy := make([]interface{}, len(value))
for i, elem := range value {
switch elem := elem.(type) {
case map[interface{}]interface{}:
m3 := make(map[string]interface{})
for k, v := range elem {
if kStr, ok := k.(string); ok {
m3[kStr] = v
}
}
valueCopy[i] = camelCasify(m3)
case map[string]interface{}:
valueCopy[i] = camelCasify(elem)
default:
valueCopy[i] = elem
}
}
m2[b.String()] = valueCopy
case map[string]interface{}:
m2[b.String()] = camelCasify(value)
default:
m2[b.String()] = value
}
}
return m2
}
+155
View File
@@ -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 = strconv.FormatFloat(v.State.Temperature, 'f', -1, 64)
}
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()
}
+27
View File
@@ -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
+6 -2
View File
@@ -3,13 +3,17 @@ module git.aiterp.net/lucifer/new-server
go 1.16 go 1.16
require ( require (
github.com/Masterminds/squirrel v1.5.0
github.com/gin-gonic/gin v1.7.1 github.com/gin-gonic/gin v1.7.1
github.com/go-sql-driver/mysql v1.6.0 github.com/go-sql-driver/mysql v1.6.0
github.com/jmoiron/sqlx v1.3.4 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/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect 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/pkg/errors v0.9.1 // indirect
github.com/pressly/goose v2.7.0+incompatible 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
) )
+25 -5
View File
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 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/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 h1:wv+0IJZfL5z0uZoUjlpKgHkgaFSYD+r9CfrXjEXsO7w=
github.com/jmoiron/sqlx v1.3.4/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= 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 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 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 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 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.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 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 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 h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 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/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 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:PWejVEv07LCerQEzMMeAtjuyCKbyprZ/LBa6K5P0OCQ=
github.com/pressly/goose v2.7.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8= 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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 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= 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-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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc= golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 h1:3erb+vDS8lU1sxfDHF4/hhWyaXnhIaO+7RgL4fDZORA=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 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-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/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-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-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-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-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/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.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.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/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= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
printf "Building lucy...\n"
go mod download || exit 1
go build -ldflags "-w -s" -o lucy ./cmd/lucy || exit 1
printf "Removing old lucy...\n"
sudo rm /usr/local/bin/lucy > /dev/null 2>&1
printf "Installing...\n"
sudo mv ./lucy /usr/local/bin/lucy
+319
View File
@@ -0,0 +1,319 @@
package hue
import (
"bytes"
"context"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"golang.org/x/sync/errgroup"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
)
type Bridge struct {
mu sync.Mutex
host string
token string
externalID int
lightStates []*hueLightState
sensorStates []*hueSensorState
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 {
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 {
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) 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) 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) 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) 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,
}
+197
View File
@@ -0,0 +1,197 @@
package hue
import (
"encoding/xml"
"errors"
)
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"`
}
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"`
}
var buttonNames = []string{"On", "DimUp", "DimDown", "Off"}
var errLinkButtonNotPressed = errors.New("link button not pressed")
+410
View File
@@ -0,0 +1,410 @@
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) 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
}
+219
View File
@@ -0,0 +1,219 @@
package hue
import (
"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) 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 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 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),
},
}
}
}
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,
},
}
}
}
return nil
}
func ptrBool(v bool) *bool {
return &v
}
func ptrInt(v int) *int {
return &v
}
+269
View File
@@ -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
}
+285
View File
@@ -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
}
+177
View File
@@ -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
}
+191
View File
@@ -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,
}
+417
View File
@@ -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
+88
View File
@@ -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
}
+43 -13
View File
@@ -37,12 +37,22 @@ func (b *bridge) Devices() []models.Device {
hue, sat, value := colorful.LinearRgb(red, green, blue).Hsv() 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{ results = append(results, models.Device{
ID: -1, ID: -1,
BridgeID: b.externalID, BridgeID: b.externalID,
InternalID: strconv.Itoa(int(panel.ID)), InternalID: strconv.Itoa(int(panel.ID)),
Icon: "hexagon", Icon: shapeIcon,
Name: fmt.Sprintf("Hexagon %d", i), Name: fmt.Sprintf("%s %d", shapeType, i+1),
Capabilities: []models.DeviceCapability{ Capabilities: []models.DeviceCapability{
models.DCPower, models.DCPower,
models.DCColorHS, models.DCColorHS,
@@ -50,12 +60,12 @@ func (b *bridge) Devices() []models.Device {
models.DCButtons, models.DCButtons,
}, },
ButtonNames: []string{"Touch"}, ButtonNames: []string{"Touch"},
DriverProperties: map[string]string{ DriverProperties: map[string]interface{}{
"x": strconv.Itoa(panel.X), "x": panel.X,
"y": strconv.Itoa(panel.Y), "y": panel.Y,
"o": strconv.Itoa(panel.O), "o": panel.O,
"shapeType": shapeTypeMap[panel.ShapeType], "shapeType": shapeTypeMap[panel.ShapeType],
"shapeWidth": strconv.Itoa(shapeWidthMap[panel.ShapeType]), "shapeWidth": shapeWidthMap[panel.ShapeType],
}, },
UserProperties: nil, UserProperties: nil,
State: models.DeviceState{ State: models.DeviceState{
@@ -83,7 +93,7 @@ func (b *bridge) Refresh(ctx context.Context) error {
b.mu.Lock() b.mu.Lock()
PanelLoop: PanelLoop:
for _, panelInfo := range overview.PanelLayout.Data.PositionData { for _, panelInfo := range overview.PanelLayout.Data.PositionData {
if panelInfo.PanelID == 0 { if shapeTypeMap[panelInfo.ShapeType] == "Shapes Controller" {
continue continue
} }
@@ -167,7 +177,7 @@ func (b *bridge) Update(devices []models.Device) {
red, green, blue := color.RGB255() red, green, blue := color.RGB255()
newColor := [4]byte{red, green, blue, 255} newColor := [4]byte{red, green, blue, 255}
if newColor != panel.ColorRGBA { if newColor != panel.ColorRGBA {
panel.update(newColor, time.Now().Add(time.Millisecond*120)) panel.update(newColor, time.Now().Add(time.Millisecond*220))
} }
} else { } else {
panel.update([4]byte{0, 0, 0, 0}, time.Now()) 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() defer conn.Close()
// Notify connections and disconnections // Notify connections and disconnections
ch <- models.Event{Name: "BridgeConnected", Payload: map[string]string{"bridgeId": strconv.Itoa(info.ID)}} ch <- models.BridgeConnectedEvent(info)
defer func() { 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. // 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() { go func() {
ticker := time.NewTicker(time.Second * 5) ticker := time.NewTicker(time.Second * 5)
hadErrors := false
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
case <-ctx2.Done(): case <-ctx2.Done():
} }
err := b.updateEffect(ctx) reqTimeout, cancel := context.WithTimeout(ctx2, time.Second*4)
err := b.updateEffect(reqTimeout)
cancel()
if err != nil { if err != nil {
log.Println("Failed to update effects:", err, "This error is non-fatal, and it will be retried shortly.") 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 return models.ErrUnexpectedResponse
} }
b.mu.Lock()
for _, panel := range b.panels {
panel.Stale = true
}
b.mu.Unlock()
return nil return nil
} }
@@ -373,7 +403,7 @@ func (b *bridge) runTouchListener(ctx context.Context, host, apiKey string, ch c
} }
event := models.Event{ event := models.Event{
Name: "ButtonPressed", Name: models.ENButtonPressed,
Payload: map[string]string{ Payload: map[string]string{
"buttonIndex": "0", "buttonIndex": "0",
"buttonName": "Touch", "buttonName": "Touch",
+12
View File
@@ -135,6 +135,18 @@ var shapeTypeMap = map[int]string{
12: "Shapes Controller", 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{ var shapeWidthMap = map[int]int{
0: 150, 0: 150,
1: -1, 1: -1,
+14
View File
@@ -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
}
+4 -4
View File
@@ -11,10 +11,10 @@ type BridgeRepo struct {
} }
func (b *BridgeRepo) Find(ctx context.Context, id int) (models.Bridge, error) { func (b *BridgeRepo) Find(ctx context.Context, id int) (models.Bridge, error) {
var bridge models.Bridge var bridge models.Bridge
err := b.DBX.GetContext(ctx, &bridge, "SELECT * FROM bridge WHERE id = ?", id) err := b.DBX.GetContext(ctx, &bridge, "SELECT * FROM bridge WHERE id = ?", id)
if err != nil { if err != nil {
return models.Bridge{}, dbErr(err) return models.Bridge{}, dbErr(err)
} }
return bridge, nil return bridge, nil
@@ -22,7 +22,7 @@ func (b *BridgeRepo) Find(ctx context.Context, id int) (models.Bridge, error) {
func (b *BridgeRepo) FetchAll(ctx context.Context) ([]models.Bridge, error) { func (b *BridgeRepo) FetchAll(ctx context.Context) ([]models.Bridge, error) {
bridges := make([]models.Bridge, 0, 8) bridges := make([]models.Bridge, 0, 8)
err := b.DBX.GetContext(ctx, bridges, "SELECT * FROM bridge") err := b.DBX.SelectContext(ctx, &bridges, "SELECT * FROM bridge")
if err != nil { if err != nil {
return nil, dbErr(err) return nil, dbErr(err)
} }
+418
View File
@@ -0,0 +1,418 @@
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"`
}
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)
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity)
`, deviceStateRecord{
DeviceID: record.ID,
Hue: device.State.Color.Hue,
Saturation: device.State.Color.Saturation,
Kelvin: device.State.Color.Kelvin,
Power: device.State.Power,
Intensity: device.State.Intensity,
})
if err != nil {
return dbErr(err)
}
}
}
return tx.Commit()
}
func (r *DeviceRepo) Save(ctx context.Context, device *models.Device, mode models.SaveMode) error {
devices := []models.Device{*device}
err := r.SaveMany(ctx, mode, devices)
if err != nil {
return err
}
*device = devices[0]
return nil
}
func (r *DeviceRepo) Delete(ctx context.Context, device *models.Device) error {
_, err := r.DBX.ExecContext(ctx, "DELETE FROM device WHERE Id=?", device.ID)
if err != nil {
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,
}
}
}
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
}
+161
View File
@@ -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
}
+99
View File
@@ -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
}
+123
View File
@@ -0,0 +1,123 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"git.aiterp.net/lucifer/new-server/models"
"github.com/jmoiron/sqlx"
)
type sceneRecord struct {
ID int `db:"id"`
Name string `db:"name"`
IntervalMS int64 `db:"interval_ms"`
RoleJSON json.RawMessage `db:"roles"`
}
type SceneRepo struct {
DBX *sqlx.DB
}
func (r *SceneRepo) Find(ctx context.Context, id int) (*models.Scene, error) {
var scene sceneRecord
err := r.DBX.GetContext(ctx, &scene, "SELECT * FROM scene WHERE id = ?", id)
if err != nil {
return nil, dbErr(err)
}
return r.populateOne(&scene)
}
func (r *SceneRepo) FetchAll(ctx context.Context) ([]models.Scene, error) {
scenes := make([]sceneRecord, 0, 8)
err := r.DBX.SelectContext(ctx, &scenes, "SELECT * FROM scene")
if err != nil {
if err == sql.ErrNoRows {
return []models.Scene{}, nil
}
return nil, dbErr(err)
}
return r.populate(scenes)
}
func (r *SceneRepo) Save(ctx context.Context, scene *models.Scene) error {
j, err := json.Marshal(scene.Roles)
if err != nil {
return err
}
if scene.ID > 0 {
_, err := r.DBX.ExecContext(
ctx,
"UPDATE scene SET name = ?, interval_ms = ?, roles = ? WHERE id = ?",
scene.Name, scene.IntervalMS, j, scene.ID,
)
if err != nil {
return dbErr(err)
}
} else {
rs, err := r.DBX.ExecContext(
ctx,
"INSERT INTO scene (name, interval_ms, roles) VALUES (?, ?, ?)",
scene.Name, scene.IntervalMS, j,
)
if err != nil {
return dbErr(err)
}
id, err := rs.LastInsertId()
if err != nil {
return dbErr(err)
}
scene.ID = int(id)
}
return nil
}
func (r *SceneRepo) Delete(ctx context.Context, scene *models.Scene) error {
_, err := r.DBX.ExecContext(ctx, "DELETE FROM scene WHERE id = ?", scene.ID)
if err != nil {
return dbErr(err)
}
scene.ID = 0
return nil
}
func (r *SceneRepo) populateOne(record *sceneRecord) (*models.Scene, error) {
records, err := r.populate([]sceneRecord{*record})
if err != nil {
return nil, err
}
return &records[0], nil
}
func (r *SceneRepo) populate(records []sceneRecord) ([]models.Scene, error) {
res := make([]models.Scene, 0, len(records))
for _, record := range records {
scene := models.Scene{
ID: record.ID,
Name: record.Name,
IntervalMS: record.IntervalMS,
Roles: make([]models.SceneRole, 0, 8),
}
err := json.Unmarshal(record.RoleJSON, &scene.Roles)
if err != nil {
return nil, dbErr(err)
}
res = append(res, scene)
}
return res, nil
}
+2 -1
View File
@@ -21,7 +21,8 @@ type DriverKind string
var ( var (
DTHue DriverKind = "Hue" DTHue DriverKind = "Hue"
DTNanoLeaf DriverKind = "NanoLeaf" DTNanoLeaf DriverKind = "Nanoleaf"
DTLIFX DriverKind = "LIFX"
) )
var ValidDriverKinds = []DriverKind{ var ValidDriverKinds = []DriverKind{
+26
View File
@@ -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
View File
@@ -23,19 +23,21 @@ func (c *ColorValue) IsKelvin() bool {
func (c *ColorValue) String() string { func (c *ColorValue) String() string {
if c.Kelvin > 0 { 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) { func ParseColorValue(raw string) (ColorValue, error) {
tokens := strings.SplitN(raw, ":", 2) tokens := strings.SplitN(raw, ":", 2)
if len(tokens) != 2 { if len(tokens) != 2 {
return ColorValue{}, ErrBadInput return ColorValue{}, ErrBadInput
} }
if tokens[0] == "kelvin" { if tokens[0] == "kelvin" || tokens[0] == "k" {
parsedPart, err := strconv.Atoi(tokens[1]) parsedPart, err := strconv.Atoi(tokens[1])
if err != nil { if err != nil {
return ColorValue{}, ErrBadInput 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 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 return ColorValue{}, ErrUnknownColorFormat
} }
+165 -28
View File
@@ -2,21 +2,31 @@ package models
import ( import (
"context" "context"
"math"
"strings" "strings"
"time"
) )
type Device struct { type Device struct {
ID int `json:"id"` ID int `json:"id"`
BridgeID int `json:"bridgeID"` BridgeID int `json:"bridgeID"`
InternalID string `json:"internalId"` InternalID string `json:"internalId"`
Icon string `json:"icon"` Icon string `json:"icon"`
Name string `json:"name"` Name string `json:"name"`
Capabilities []DeviceCapability `json:"capabilities"` Capabilities []DeviceCapability `json:"capabilities"`
ButtonNames []string `json:"buttonNames"` ButtonNames []string `json:"buttonNames"`
DriverProperties map[string]string `json:"driverProperties"` DriverProperties map[string]interface{} `json:"driverProperties"`
UserProperties map[string]string `json:"userProperties"` UserProperties map[string]string `json:"userProperties"`
State DeviceState `json:"state"` SceneAssignments []DeviceSceneAssignment `json:"sceneAssignments"`
Tags []string `json:"tags"` 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 // DeviceState contains optional state values that
@@ -31,29 +41,57 @@ type DeviceState struct {
Temperature float64 `json:"temperature"` Temperature float64 `json:"temperature"`
} }
type DeviceScene struct {
SceneID int `json:"sceneId"`
Time time.Time `json:"time"`
DurationMS int64 `json:"duration"`
Tag string `json:"tag"`
}
type NewDeviceState struct { type NewDeviceState struct {
Power *bool `json:"power"` Power *bool `json:"power"`
Color *string `json:"color"` Color *string `json:"color"`
Intensity int `json:"intensity"` Intensity *float64 `json:"intensity"`
Temperature int `json:"temperature"` Temperature *int `json:"temperature"`
} }
type DeviceCapability string type DeviceCapability string
type SaveMode int
const (
SMState SaveMode = 1
SMProperties SaveMode = 2
SMTags SaveMode = 4
)
type DeviceRepository interface { 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) 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 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 ( var (
DCPower DeviceCapability = "Power" DCPower DeviceCapability = "Power"
DCColorHS DeviceCapability = "ColorHS" DCColorHS DeviceCapability = "ColorHS"
DCColorKelvin DeviceCapability = "ColorKelvin" DCColorHSK DeviceCapability = "ColorHSK"
DCButtons DeviceCapability = "Buttons" DCColorKelvin DeviceCapability = "ColorKelvin"
DCIntensity DeviceCapability = "Intensity" DCButtons DeviceCapability = "Buttons"
DCTemperature DeviceCapability = "Temperature" DCPresence DeviceCapability = "Presence"
DCIntensity DeviceCapability = "Intensity"
DCTemperatureControl DeviceCapability = "TemperatureControl"
DCTemperatureSensor DeviceCapability = "TemperatureSensor"
) )
var Capabilities = []DeviceCapability{ var Capabilities = []DeviceCapability{
@@ -61,8 +99,29 @@ var Capabilities = []DeviceCapability{
DCColorHS, DCColorHS,
DCColorKelvin, DCColorKelvin,
DCButtons, DCButtons,
DCPresence,
DCIntensity, 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 { func (d *Device) Validate() error {
@@ -85,10 +144,24 @@ func (d *Device) Validate() error {
return nil 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) HasCapability(capabilities ...DeviceCapability) bool {
for _, c := range d.Capabilities { for _, c := range d.Capabilities {
if c == capacity { for _, c2 := range capabilities {
return true if c == c2 {
return true
}
} }
} }
@@ -106,10 +179,74 @@ func (d *Device) SetState(newState NewDeviceState) error {
return err 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 d.State.Color = parsed
} }
} }
if newState.Intensity != nil && d.HasCapability(DCIntensity) {
d.State.Intensity = *newState.Intensity
}
return nil return nil
} }
func (s *NewDeviceState) RelativeTo(device Device) NewDeviceState {
n := NewDeviceState{}
if s.Intensity != nil {
intensity := device.State.Intensity * *s.Intensity
n.Intensity = &intensity
}
if s.Color != nil {
c, err := ParseColorValue(*s.Color)
if err == nil {
c.Hue = math.Mod(device.State.Color.Hue+c.Hue, 360)
c.Saturation *= device.State.Color.Saturation
c.Kelvin += device.State.Color.Kelvin
}
}
return n
}
func (s *NewDeviceState) Interpolate(other NewDeviceState, fac float64) NewDeviceState {
n := NewDeviceState{}
if s.Power != nil && other.Power != nil {
if fac >= 0.5 {
n.Power = other.Power
} else {
n.Power = s.Power
}
}
if s.Color != nil && other.Color != nil {
sc, err := ParseColorValue(*s.Color)
oc, err2 := ParseColorValue(*other.Color)
if err == nil && err2 == nil {
rc := ColorValue{}
rc.Hue = interpolateFloat(sc.Hue, oc.Hue, fac)
rc.Saturation = interpolateFloat(sc.Saturation, oc.Saturation, fac)
rc.Kelvin = interpolateInt(sc.Kelvin, oc.Kelvin, fac)
rcStr := rc.String()
n.Color = &rcStr
}
}
if s.Intensity != nil && other.Intensity != nil {
n.Intensity = new(float64)
*n.Intensity = interpolateFloat(*s.Intensity, *other.Intensity, fac)
}
return n
}
func interpolateFloat(a, b, fac float64) float64 {
return (a * (1 - fac)) + (b * fac)
}
func interpolateInt(a, b int, fac float64) int {
return int((float64(a) * (1 - fac)) + (float64(b) * fac))
}
+16
View File
@@ -8,8 +8,24 @@ var ErrBadInput = errors.New("bad input")
var ErrBadColor = errors.New("bad color") var ErrBadColor = errors.New("bad color")
var ErrInternal = errors.New("internal") var ErrInternal = errors.New("internal")
var ErrUnknownColorFormat = errors.New("unknown color format") var ErrUnknownColorFormat = errors.New("unknown color format")
var ErrUnknownDriver = errors.New("unknown driver")
var ErrMissingToken = errors.New("driver is missing authentication information") var ErrMissingToken = errors.New("driver is missing authentication information")
var ErrIncorrectToken = errors.New("driver is not accepting 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 ErrUnexpectedResponse = errors.New("driver api returned unexpected response (wrong driver selected?)")
var ErrBridgeSearchFailed = errors.New("bridge search failed") var ErrBridgeSearchFailed = errors.New("bridge search failed")
var ErrAddressOnlyDryRunnable = errors.New("this address may only be used for a dry run")
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")
+19 -2
View File
@@ -19,9 +19,26 @@ func (e *Event) HasPayload(key string) bool {
return e.Payload != nil && e.Payload[key] != "" return e.Payload != nil && e.Payload[key] != ""
} }
var (
ENBridgeConnected = "BridgeConnected"
ENBridgeDisconnected = "BridgeDisconnected"
ENButtonPressed = "ButtonPressed"
ENSensorPresenceStarted = "SensorPresenceStarted"
ENSensorPresenceEnding = "SensorPresenceEnding"
ENSensorPresenceEnded = "SensorPresenceEnded"
ENSensorTemperature = "SensorTemperature"
)
func BridgeConnectedEvent(bridge Bridge) Event { func BridgeConnectedEvent(bridge Bridge) Event {
e := Event{Name: "BridgeConnected"} e := Event{Name: ENBridgeConnected}
e.AddPayload("id", strconv.Itoa(bridge.ID)) 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 return e
} }
+187 -23
View File
@@ -5,21 +5,27 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type EventHandler struct { type EventHandler struct {
ID int `json:"id"` ID int `json:"id"`
EventName string `json:"eventName"` EventName string `json:"eventName"`
Conditions map[string]EventCondition `json:"conditions"` 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"` TargetValue string `json:"targetValue"`
Actions EventAction `json:"actions"`
From TimeOfDay `json:"from"`
To TimeOfDay `json:"to"`
} }
type EventHandlerRepository interface { 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) FetchAll(ctx context.Context) ([]EventHandler, error)
Save(ctx context.Context, handler *EventHandler) Save(ctx context.Context, handler *EventHandler) error
Delete(ctx context.Context, handler *EventHandler) Delete(ctx context.Context, handler *EventHandler) error
} }
type EventCondition struct { type EventCondition struct {
@@ -30,13 +36,70 @@ type EventCondition struct {
LTE string `json:"lte,omitempty"` 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 { func (h *EventHandler) MatchesEvent(event Event, targets []Device) bool {
if event.Name != h.EventName { if event.Name != h.EventName {
return false return false
} }
for key, condition := range h.Conditions { for key, condition := range h.Conditions {
if !event.HasPayload(key) { if !strings.ContainsRune(key, '.') && !event.HasPayload(key) {
return false return false
} }
@@ -48,43 +111,88 @@ func (h *EventHandler) MatchesEvent(event Event, targets []Device) bool {
return true 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 { func (c *EventCondition) check(key, value string, targets []Device) bool {
any := strings.Index(key, "any.") == 0 any := strings.HasPrefix(key, "any.")
all := strings.Index(key, "all.") == 0 all := strings.HasPrefix(key, "all.")
if any || all && len(key) > 4 { if any || all {
count := 0 count := 0
total := 0
for _, target := range targets { for _, target := range targets {
if c.checkDevice(key[4:], target) { matches, skip := c.checkDevice(key[4:], target)
if skip {
continue
}
if matches {
count++ count++
} }
total++
} }
return (any && count > 0) || (all && count == len(targets)) return (any && count > 0) || (all && count == total)
} }
return c.matches(value) 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 { switch key {
case "power": case "power":
return c.matches(strconv.FormatBool(device.State.Power)) if !device.HasCapability(DCPower) {
case "color": return false, true
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))
}
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.FormatFloat(device.State.Temperature, 'f', -1, 64)), false
case "scene":
if len(device.SceneAssignments) == 0 {
return false, false
}
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 { func (c *EventCondition) matches(value string) bool {
if numRegex.MatchString(value) { if numRegex.MatchString(value) {
numValue, _ := strconv.ParseFloat(c.LT, 64) numValue, _ := strconv.ParseFloat(value, 64)
stillAlive := true stillAlive := true
if c.LT != "" { if c.LT != "" {
@@ -104,7 +212,7 @@ func (c *EventCondition) matches(value string) bool {
if stillAlive && c.GTE != "" { if stillAlive && c.GTE != "" {
gte, _ := strconv.ParseFloat(c.GTE, 64) gte, _ := strconv.ParseFloat(c.GTE, 64)
stillAlive = numValue == gte stillAlive = numValue >= gte
} }
if stillAlive && c.GT != "" { if stillAlive && c.GT != "" {
@@ -119,3 +227,59 @@ func (c *EventCondition) matches(value string) bool {
return false 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, ";")
}
+251
View File
@@ -0,0 +1,251 @@
package models
import (
"context"
"math"
"math/rand"
"sort"
"strings"
"time"
)
type Scene struct {
ID int `json:"id"`
Name string `json:"name"`
IntervalMS int64 `json:"interval"`
Roles []SceneRole `json:"roles"`
}
func (s *Scene) Validate() error {
if s.IntervalMS < 0 {
return ErrSceneInvalidInterval
}
if len(s.Roles) == 0 {
return ErrSceneNoRoles
}
for _, role := range s.Roles {
err := role.Validate()
if err != nil {
return err
}
}
return nil
}
func (s *Scene) Role(device *Device) *SceneRole {
index := s.RoleIndex(device)
if index == -1 {
return nil
}
return &s.Roles[index]
}
func (s *Scene) RoleIndex(device *Device) int {
for i, role := range s.Roles {
if role.TargetKind.Matches(device, role.TargetValue) {
return i
}
}
return -1
}
type SceneEffect string
const (
SEStatic SceneEffect = "Static"
SERandom SceneEffect = "Random"
SEGradient SceneEffect = "Gradient"
SEWalkingGradient SceneEffect = "WalkingGradient"
SETransition SceneEffect = "Transition"
)
type SceneRole struct {
Effect SceneEffect `json:"effect"`
PowerMode ScenePowerMode `json:"powerMode"`
TargetKind ReferenceKind `json:"targetKind"`
TargetValue string `json:"targetValue"`
Interpolate bool `json:"interpolate"`
Relative bool `json:"relative"`
Order string `json:"order"`
States []NewDeviceState `json:"states"`
}
type SceneRunContext struct {
Index int
Length int
IntervalNumber int64
IntervalMax int64
}
func (d *SceneRunContext) PositionFacShifted() float64 {
shiftFac := float64(d.IntervalNumber%int64(d.Length)) / float64(d.Length)
return math.Mod(d.PositionFac()+shiftFac, 1)
}
func (d *SceneRunContext) PositionFac() float64 {
return float64(d.Index) / float64(d.Length)
}
func (d *SceneRunContext) IntervalFac() float64 {
fac := float64(d.IntervalNumber) / float64(d.IntervalMax)
if fac > 1 {
return 1
} else if fac < 0 {
return 0
} else {
return fac
}
}
func (r *SceneRole) Validate() error {
if len(r.States) == 0 {
return ErrSceneRoleNoStates
}
switch r.TargetKind {
case RKTag, RKBridgeID, RKDeviceID, RKName, RKAll:
default:
return ErrBadInput
}
switch r.PowerMode {
case SPScene, SPDevice, SPBoth:
default:
return ErrSceneRoleUnknownPowerMode
}
switch r.Effect {
case SEStatic, SERandom, SEGradient, SEWalkingGradient, SETransition:
default:
return ErrSceneRoleUnknownEffect
}
switch r.Order {
case "", "-name", "name", "+name", "-id", "id", "+id":
default:
return ErrSceneRoleUnsupportedOrdering
}
return nil
}
func (r *SceneRole) ApplyOrder(devices []Device) {
if r.Order == "" {
return
}
desc := strings.HasPrefix(r.Order, "-")
orderKey := r.Order
if desc || strings.HasPrefix(r.Order, "+") {
orderKey = orderKey[1:]
}
switch orderKey {
case "id":
sort.Slice(devices, func(i, j int) bool {
if desc {
return devices[i].ID > devices[j].ID
}
return devices[i].ID < devices[j].ID
})
case "name":
sort.Slice(devices, func(i, j int) bool {
if desc {
return strings.Compare(devices[i].Name, devices[j].Name) < 0
}
return strings.Compare(devices[i].Name, devices[j].Name) > 0
})
}
}
func (r *SceneRole) ApplyEffect(device *Device, c SceneRunContext) (newState NewDeviceState) {
switch r.Effect {
case SEStatic:
newState = r.States[0]
case SERandom:
newState = r.State(rand.Float64())
case SEGradient:
newState = r.State(c.PositionFac())
case SEWalkingGradient:
newState = r.State(c.PositionFacShifted())
case SETransition:
newState = r.State(c.IntervalFac())
}
if r.Relative {
newState = newState.RelativeTo(*device)
}
switch r.PowerMode {
case SPDevice:
newState.Power = nil
case SPScene:
// Do nothing
case SPBoth:
if newState.Power != nil {
powerIntersection := *newState.Power && device.State.Power
newState.Power = &powerIntersection
}
}
return
}
func (r *SceneRole) State(fac float64) NewDeviceState {
if len(r.States) == 0 {
return NewDeviceState{}
} else if len(r.States) == 1 {
return r.States[0]
}
if r.Interpolate {
if len(r.States) == 2 {
return r.States[0].Interpolate(r.States[1], fac)
} else {
pos := fac * float64(len(r.States)-1)
start := math.Floor(pos)
localFac := pos - start
return r.States[int(start)].Interpolate(r.States[int(start)+1], localFac)
}
} else {
index := int(fac * float64(len(r.States)))
if index == len(r.States) {
index = len(r.States) - 1
}
return r.States[index]
}
}
type ScenePowerMode string
const (
SPDevice ScenePowerMode = "Device" // Device state decides power. Scene state may only power off.
SPScene ScenePowerMode = "Scene" // Scene state decide power.
SPBoth ScenePowerMode = "Both" // Power is only on if both Device and Scene says it is.
)
// DeviceSceneAssignment is an entry on the device stack. StartTime and DurationMS are only respected when the scene
// instance is created for the group.
type DeviceSceneAssignment struct {
SceneID int `json:"sceneId"`
Group string `json:"group"`
StartTime time.Time `json:"start"`
DurationMS int64 `json:"durationMs"`
}
type SceneRepository interface {
Find(ctx context.Context, id int) (*Scene, error)
FetchAll(ctx context.Context) ([]Scene, error)
Save(ctx context.Context, bridge *Scene) error
Delete(ctx context.Context, bridge *Scene) error
}
+79 -3
View File
@@ -1,11 +1,87 @@
package models package models
import (
"strconv"
"strings"
)
type ReferenceKind string type ReferenceKind string
var ( var (
RTDeviceID ReferenceKind = "DeviceID" RKDeviceID ReferenceKind = "DeviceID"
RTBridgeID ReferenceKind = "BridgeID" RKBridgeID ReferenceKind = "BridgeID"
RTTag ReferenceKind = "Tag" 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
}
}
+138
View File
@@ -0,0 +1,138 @@
package models
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
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().Local())
}
func TimeOfDayFromDate(date time.Time) TimeOfDay {
return NewTimeOfDay(date.Hour(), date.Minute(), date.Second())
}
func NewTimeOfDay(hours, minutes, seconds int) TimeOfDay {
return TimeOfDay(((hours % 24) * 3600) + ((minutes % 60) * 60) + seconds)
}
func ParseTimeOfDayPtr(str *string) (TimeOfDay, error) {
if str == nil {
return Never, nil
}
return ParseTimeOfDay(*str)
}
func ParseTimeOfDay(str string) (TimeOfDay, error) {
if str == "" || str == "n/a" || str == "N/A" {
return -1, nil
}
split := strings.SplitN(str, ":", 4)
res := 0
n, err := strconv.Atoi(split[0])
if err != nil {
return Never, err
}
res += n * 3600
if len(split) >= 2 {
n, err := strconv.Atoi(split[1])
if err != nil {
return Never, err
}
res += n * 60
}
if len(split) >= 3 {
n, err := strconv.Atoi(split[2])
if err != nil {
return Never, err
}
res += n
}
if res >= 86400 || res < 0 {
return Never, fmt.Errorf("invalid time of day string %s (=%d)", str, res)
}
return TimeOfDay(res), nil
}
+42
View File
@@ -0,0 +1,42 @@
package models
import (
"fmt"
"testing"
)
func tod(s string) TimeOfDay {
tod, err := ParseTimeOfDay(s)
if err != nil {
panic(err)
}
return tod
}
func TestTimeOfDay_IsBetween(t *testing.T) {
table := []struct{
Value TimeOfDay
From TimeOfDay
To TimeOfDay
Expected bool
}{
{ tod("16:13:11"), tod("07"), tod("21:30"), true },
{ tod("16:13:31"), tod("21:30"), tod("07"), false },
{ tod("23:15:32"), tod("21:30"), tod("07"), true },
{ tod("04:13:57"), tod("21:30"), tod("07"), true },
{ tod("16:14:43"), tod("15:30"), tod("16:00"), false },
{ tod("16:14:43"), tod("15:30"), tod("16:15"), true },
{ tod("08:45:43"), tod("07:00:00"), tod("21:30:00"), true },
{ tod("07:00:00"), tod("07:00:00"), tod("21:30:00"), true },
}
for i, row := range table {
t.Run(fmt.Sprintf("row_%d", i), func (t *testing.T) {
if row.Value.IsBetween(row.From, row.To) != row.Expected {
t.Log(row.Value, row.From, row.To)
t.Fail()
}
})
}
}
+63
View File
@@ -0,0 +1,63 @@
name: Evening
interval: 1500
roles:
- effect: WalkingGradient
power_mode: Device
target_kind: Tag
target_value: Hexagon
interpolate: true
relative: false
order: -name
states:
- color: 'hs:30,1'
intensity: 0.20
- color: 'hs:30,1'
intensity: 0.15
- effect: Gradient
power_mode: Device
target_kind: Tag
target_value: SquareLeft
interpolate: true
relative: false
order: +name
states:
- color: 'hs:25,1'
intensity: 0.075
- color: 'hs:50,1'
intensity: 0.15
- effect: Random
power_mode: Device
target_kind: Tag
target_value: SquareRight
interpolate: true
relative: false
order: +name
states:
- color: 'hs:220,0.7'
intensity: 0.22
- color: 'hs:220,0.6'
intensity: 0.25
- effect: Static
power_mode: Device
target_kind: Tag
target_value: Accent
interpolate: true
relative: false
order: +name
states:
- color: 'hs:250,0.7'
intensity: 0.25
- effect: Static
power_mode: Device
target_kind: All
target_value: ""
interpolate: true
relative: false
order: +name
states:
- color: 'k:2000'
intensity: 0.3
+26
View File
@@ -0,0 +1,26 @@
name: Flash
interval: 0
roles:
- effect: Static
power_mode: Device
target_kind: Tag
target_value: Nanoleaf
states:
- intensity: 1
color: hs:220,0.1
- effect: Static
power_mode: Device
target_kind: Tag
target_value: Hue
states:
- intensity: 1
color: k:6500
- effect: Static
power_mode: Device
target_kind: All
target_value: ""
states:
- intensity: 1
color: hs:0,0
+57
View File
@@ -0,0 +1,57 @@
name: Late
interval: 60000
roles:
- effect: Random
power_mode: Device
target_kind: Tag
target_value: Hexagon
interpolate: true
relative: false
order: +name
states:
- color: 'hs:35,1'
intensity: 0.08
- color: 'hs:35,1'
intensity: 0.085
- color: 'hs:35,1'
intensity: 0.09
- color: 'hs:35,1'
intensity: 0.12
- effect: Gradient
power_mode: Both
target_kind: Tag
target_value: Square
interpolate: true
relative: false
order: +name
states:
- color: 'hs:25,1'
intensity: 0.05
power: off
- color: 'hs:30,1'
intensity: 0.06
power: off
- color: 'hs:35,1'
intensity: 0.07
power: on
- effect: Static
power_mode: Device
target_kind: Tag
target_value: Nightstand
interpolate: true
relative: false
order: +name
states:
- color: 'k:2000'
intensity: 0.15
- effect: Static
power_mode: Scene
target_kind: All
target_value: ''
interpolate: true
relative: false
states:
- power: false
+40
View File
@@ -0,0 +1,40 @@
name: Morning
interval: 1000
roles:
- effect: WalkingGradient
power_mode: Device
target_kind: Tag
target_value: Hexagon
interpolate: true
relative: false
order: +name
states:
- color: 'hs:220,0.4'
intensity: 0.65
- color: 'hs:220,0.5'
intensity: 0.50
- effect: Random
power_mode: Device
target_kind: Tag
target_value: Square
interpolate: true
relative: false
order: +name
states:
- color: 'hs:220,0.6'
intensity: 0.30
- color: 'hs:220,0.5'
intensity: 0.35
- effect: Static
power_mode: Device
target_kind: All
target_value: ''
interpolate: true
relative: false
order: +name
states:
- color: 'hs:250,0.6'
intensity: 0.3
+42
View File
@@ -0,0 +1,42 @@
name: Saturday
interval: 700
roles:
- effect: WalkingGradient
power_mode: Device
target_kind: Tag
target_value: Hexagon
interpolate: true
relative: false
order: +name
states:
- color: 'hs:220,0.5'
intensity: 0.25
- color: 'hs:220,0.5'
intensity: 0.30
- effect: Random
power_mode: Device
target_kind: Tag
target_value: Square
interpolate: true
relative: false
order: +name
states:
- color: 'hs:220,0.6'
intensity: 0.15
- color: 'hs:220,0.55'
intensity: 0.16
- color: 'hs:220,0.5'
intensity: 0.20
- effect: Static
power_mode: Device
target_kind: All
target_value: ''
interpolate: true
relative: false
order: +name
states:
- color: 'hs:250,0.7'
intensity: 0.3
+3 -2
View File
@@ -5,12 +5,13 @@ CREATE TABLE device
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
bridge_id INT NOT NULL, bridge_id INT NOT NULL,
internal_id VARCHAR(255) NOT NULL, internal_id VARCHAR(255) NOT NULL,
icon CHAR NOT NULL, icon VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
capabilities VARCHAR(255) NOT NULL, capabilities VARCHAR(255) NOT NULL,
button_names VARCHAR(255) NOT NULL, button_names VARCHAR(255) NOT NULL,
PRIMARY KEY (id) PRIMARY KEY (id),
UNIQUE (bridge_id, internal_id)
); );
-- +goose StatementEnd -- +goose StatementEnd
+1 -1
View File
@@ -7,7 +7,7 @@ CREATE TABLE device_state
saturation DOUBLE NOT NULL, saturation DOUBLE NOT NULL,
kelvin INT NOT NULL, kelvin INT NOT NULL,
power TINYINT NOT NULL, power TINYINT NOT NULL,
intensity INT NOT NULL, intensity DOUBLE NOT NULL,
PRIMARY KEY (device_id) PRIMARY KEY (device_id)
); );
+17
View File
@@ -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
+16
View File
@@ -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
+21
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE scene
(
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
interval_ms INT NOT NULL,
roles JSON NOT NULL,
PRIMARY KEY (id)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE scene;
-- +goose StatementEnd
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE device ADD COLUMN scene_assignments JSON NOT NULL DEFAULT ('[]');
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE device DROP COLUMN IF EXISTS scene_assignments;
-- +goose StatementEnd
@@ -0,0 +1,13 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE event_handler
ADD COLUMN from_tod VARCHAR(255),
ADD COLUMN to_tod VARCHAR(255);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE event_handler
DROP COLUMN IF EXISTS from_tod,
DROP COLUMN IF EXISTS to_tod;
-- +goose StatementEnd
+2 -1
View File
@@ -1 +1,2 @@
node_modules/ node_modules/
yarn.error.log
+9 -1
View File
@@ -3,6 +3,9 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "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/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0", "@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10", "@testing-library/user-event": "^12.1.10",
@@ -10,11 +13,15 @@
"@types/node": "^12.0.0", "@types/node": "^12.0.0",
"@types/react": "^17.0.0", "@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0", "@types/react-dom": "^17.0.0",
"axios": "^0.21.1",
"hookrouter": "^1.2.5", "hookrouter": "^1.2.5",
"kelvin-to-rgb": "^1.0.2",
"node-sass": "4.14.1", "node-sass": "4.14.1",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-modal": "^3.14.2",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"react-semantic-ui-range": "^0.7.1",
"typescript": "^4.1.2", "typescript": "^4.1.2",
"web-vitals": "^1.0.1" "web-vitals": "^1.0.1"
}, },
@@ -43,6 +50,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@types/hookrouter": "^2.2.5" "@types/hookrouter": "^2.2.5",
"@types/react-modal": "^3.12.0"
} }
} }
+48 -7
View File
@@ -1,11 +1,38 @@
import React from 'react'; import React, {useContext, useLayoutEffect} from 'react';
import {HookRouter, navigate, usePath, useRoutes} from "hookrouter"; import {HookRouter, navigate, usePath, useRoutes} from "hookrouter";
import {Tabs} from "./primitives/Layout"; 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 = { const routeObj: HookRouter.RouteObject = {
"/": () => <div>1</div>, "/": () => (
"/devices": () => <div>2</div>, <Page>
"/settings": () => <div>3</div>, <h1>&nbsp;</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 routeList = ["/", "/devices", "/settings"];
@@ -13,17 +40,31 @@ const routeList = ["/", "/devices", "/settings"];
const tabNames = ["Lucifer", "Enheter", "Oppsett"]; const tabNames = ["Lucifer", "Enheter", "Oppsett"];
function App() { function App() {
return (
<DialogContextProvider>
<AppInContext/>
</DialogContextProvider>
);
}
function AppInContext() {
const route = useRoutes(routeObj); const route = useRoutes(routeObj);
const path = usePath(); const path = usePath();
const {dialog} = useContext(DialogContext);
useLayoutEffect(() => {
ReactModal.setAppElement("#root");
}, []);
return ( return (
<div className="App"> <div className="App">
<Tabs tabNames={tabNames} <Tabs tabNames={tabNames}
index={routeList.indexOf(path)} index={routeList.indexOf(path)}
onChange={i => navigate(routeList[i])} onChange={i => navigate(routeList[i])}
boldIndex={0} large boldIndex={0}
/> />
{route || <div>B</div>} {route || <Page>404</Page>}
{makeDialog(dialog)}
</div> </div>
); );
} }
+18
View File
@@ -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}`);
}
+56
View File
@@ -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;
+41
View File
@@ -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;
+71
View File
@@ -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>
);
}
+37
View File
@@ -0,0 +1,37 @@
import {ColorValue} from "../models/Colors";
import iro from "@jaames/iro";
export function kelvinToColorValue(rawKelvin: number): ColorValue {
const kelvin = normalizeKelvin(rawKelvin);
return kelvinTable[kelvin];
}
export function kelvinToRgbHex(rawKelvin: number): string {
const kelvin = normalizeKelvin(rawKelvin);
const c = new iro.Color();
c.kelvin = kelvin;
return c.hexString;
}
function normalizeKelvin(kelvin: number): number {
if (kelvin < 1000) {
return 1000;
} else if (kelvin > 10000) {
return 10000;
} else {
return kelvin - (kelvin % 100);
}
}
const kelvinTable: {[key: number]: ColorValue} = {};
for (let kelvin = 1000; kelvin <= 10000; kelvin += 100) {
const c = new iro.Color();
c.kelvin = kelvin;
const [h, s] = [c.hsv.h, (c.hsv.s as number) / 100];
kelvinTable[kelvin] = {h, s, kelvin};
}
+3
View File
@@ -0,0 +1,3 @@
export function unimplemented<T>(): T {
throw new Error("unimplemented");
}
+81
View File
@@ -0,0 +1,81 @@
import Axios, {AxiosError, AxiosRequestConfig, AxiosResponse} from "axios";
interface Response<T> {
code: number
message: string
data: T
}
export const authError = new Error("unauthenticated");
export async function getRequest<T>(url: string): Promise<T> {
const cors = url.includes("//");
return sendRequest<T>({
method: "GET",
url: cors ? url : `/api${url}`,
responseType: "json",
}, cors);
}
export function postRequest<T>(url: string, data: object = {}): Promise<T> {
return sendRequest<T>({
method: "POST",
url: `/api${url}`,
responseType: "json",
data,
});
}
export function putRequest<T>(url: string, data: object): Promise<T> {
return sendRequest<T>({
method: "PUT",
url: `/api${url}`,
responseType: "json",
data,
});
}
export function deleteRequest<T>(url: string): Promise<T> {
return sendRequest<T>({
method: "DELETE",
url: `/api${url}`,
responseType: "json",
});
}
async function sendRequest<T>(config: AxiosRequestConfig, cors: boolean = false): Promise<T> {
try {
const res = await Axios(config);
return await handleResponse(res, cors);
} catch (e) {
if (e === authError) {
throw e;
}
return await handleResponse((e as AxiosError).response as AxiosResponse);
}
}
function handleResponse<T>(response: AxiosResponse, object: boolean = false): Promise<T> {
if (object) {
return Promise.resolve(response.data);
} else {
const obj: Response<T> | undefined = response?.data;
if (![200, 201].includes(response.status)) {
if (response.status === 403) {
return Promise.reject(authError);
} else {
return Promise.reject(obj?.message || "Ukjent feil");
}
}
if (!obj) {
return Promise.reject("Ingen data mottatt");
}
return Promise.resolve(obj.data);
}
}
+4 -1
View File
@@ -3,10 +3,13 @@ import ReactDOM from 'react-dom';
import './index.css'; import './index.css';
import App from './App'; import App from './App';
import reportWebVitals from "./reportWebVitals"; import reportWebVitals from "./reportWebVitals";
import {DataContextProvider} from "./contexts/DataContext";
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<App /> <DataContextProvider>
<App/>
</DataContextProvider>
</React.StrictMode>, </React.StrictMode>,
document.getElementById('root') document.getElementById('root')
); );
+11
View File
@@ -0,0 +1,11 @@
export interface ColorValue {
h?: number
s?: number
kelvin?: number
}
export interface ColorPreset {
id: number
name: string
value: ColorValue
}
+9
View File
@@ -0,0 +1,9 @@
export enum DialogType {
None,
AddColor,
}
export type DialogConfig =
| { type: DialogType.None }
| { type: DialogType.AddColor }
;
+51
View File
@@ -0,0 +1,51 @@
import bridgeIcon from "@iconify-icons/mdi/bridge";
import checkIcon from "@iconify-icons/mdi/check";
import crossIcon from "@iconify-icons/mdi/window-close";
import cogIcon from "@iconify-icons/mdi/cog";
import hexagonIcon from "@iconify-icons/mdi/hexagon";
import hexagonGroupIcon from "@iconify-icons/mdi/hexagon-multiple";
import lightBulbIcon from "@iconify-icons/mdi/lightbulb";
import lightBulbGroupIcon from "@iconify-icons/mdi/lightbulb-group";
import lightSwitchIcon from '@iconify-icons/mdi/light-switch';
import routerIcon from "@iconify-icons/mdi/router-wireless";
import squareIcon from "@iconify-icons/mdi/square-rounded";
import triangleIcon from "@iconify-icons/mdi/triangle";
// To add a new icon, follow the instructions on this page:
// https://iconify.design/icon-sets/mdi/
export enum LuciferIcon {
Bridge = "Bridge",
Check = "Check",
Cog = "Cog",
Cross = "Cross",
Bulb = "Bulb",
BulbGroup = "BulbGroup",
Hexagon = "Hexagon",
HexagonGroup = "HexagonGroup",
Router = "Router",
Square = "Square",
Switch = "Switch",
Triangle = "Triangle",
}
const iconMap = {
[LuciferIcon.Bridge]: bridgeIcon,
[LuciferIcon.Check]: checkIcon,
[LuciferIcon.Cog]: cogIcon,
[LuciferIcon.Cross]: crossIcon,
[LuciferIcon.Bulb]: lightBulbIcon,
[LuciferIcon.BulbGroup]: lightBulbGroupIcon,
[LuciferIcon.Hexagon]: hexagonIcon,
[LuciferIcon.HexagonGroup]: hexagonGroupIcon,
[LuciferIcon.Router]: routerIcon,
[LuciferIcon.Square]: squareIcon,
[LuciferIcon.Switch]: lightSwitchIcon,
[LuciferIcon.Triangle]: triangleIcon,
}
export function toIconify(icon: LuciferIcon): object {
return iconMap[icon];
}
+4
View File
@@ -0,0 +1,4 @@
export enum DataTarget {
Default = "",
ColorPresets = "ColorPresets",
}
+26
View File
@@ -0,0 +1,26 @@
import React, {useContext, useEffect} from "react";
import {Page} from "../primitives/Layout";
import DataContext from "../contexts/DataContext";
import {IconElement} from "../primitives/Elements";
import DialogContext from "../contexts/DialogContext";
import {DialogType} from "../models/Dialog";
const SettingsPage: React.FC = () => {
const {setDialog} = useContext(DialogContext);
const {colorPresets} = useContext(DataContext);
useEffect(() => {
}, []);
return (
<Page title="Oppsett">
<h1>Definerte farger</h1>
{colorPresets.map(cp => (
<IconElement key={cp.id} color={cp.value} caption={cp.name}/>
))}
<button onClick={() => setDialog({type: DialogType.AddColor})}>Add</button>
</Page>
);
};
export default SettingsPage;
+17
View File
@@ -0,0 +1,17 @@
@import "../res/colors"
.Element
display: inline-block
padding: 0.2em 0.5ch
cursor: pointer
.Element:hover
background-color: $color-background-elevated
.ColorElement-block
vertical-align: text-top
display: inline-block
width: 1em
height: 1em
border-radius: 5px
margin-right: 0.5ch
+71
View File
@@ -0,0 +1,71 @@
import React, {CSSProperties, useMemo} from "react";
import {ColorValue} from "../models/Colors";
import {kelvinToRgbHex} from "../helpers/kelvin";
import {Icon} from "@iconify/react";
import "./Elements.sass";
import {LuciferIcon, toIconify} from "../models/Icons";
function Element({children}: React.PropsWithChildren<{}>) {
return (
<div className="Element">{children}</div>
)
}
interface IconProps {
icon?: LuciferIcon
color?: ColorValue | "hs-gradient" | "k-gradient"
}
export function IconBlock({icon, color}: IconProps) {
const style: CSSProperties = useMemo(() => {
const s: CSSProperties = {
backgroundClip: "text",
// @ts-ignore
textFillColor: "transparent",
};
if (color !== undefined) {
if (color === "hs-gradient") {
s.backgroundImage = "linear-gradient(to bottom right, red, yellow, green, blue, violet)";
} else if (color === "k-gradient") {
s.backgroundImage = "linear-gradient(to bottom right, #ff9329, #ffd6aa, #fffaf4, #fff, #c9e2ff, #40a3ff)";
} else if (color.h !== undefined) {
const hue = color.h;
const sat = Math.floor((color.s || 0) * 100);
s.color = `hsl(${hue}, ${sat}%, 50%)`;
if (color.kelvin) {
s.borderBottom = `1px solid ${kelvinToRgbHex(color.kelvin)}`;
}
} else if (color.kelvin !== undefined && color.kelvin > 0) {
s.color = kelvinToRgbHex(color.kelvin);
} else {
s.color = "white"
}
}
return s;
}, [color]);
return (
<Icon icon={toIconify(icon || LuciferIcon.Square)}
className="ColorElement-block"
style={style}
/>
);
}
interface IconElementProps extends IconProps {
caption: string
}
export function IconElement({caption, color, icon}: IconElementProps) {
return (
<Element>
<IconBlock icon={icon} color={color} />
{caption}
</Element>
);
}
+20
View File
@@ -0,0 +1,20 @@
@import "../res/colors"
.FormLine
padding: 0.5em 1ch
width: 100%
&:first-of-type
margin-top: 0
input, select
display: block
width: 100%
font: inherit
background-color: $color-background-elevated
color: $color-foreground
border: none
padding: 0.25em 0.5ch
&:focus, &:active
outline: 1px solid $color-foreground-elevated
+136
View File
@@ -0,0 +1,136 @@
import React, {PropsWithChildren, useCallback, useLayoutEffect, useMemo} from 'react';
import iro from "@jaames/iro";
import "./Forms.sass";
interface FormLineProps {
label: string
id?: string
}
function FormLine({label, id, children}: PropsWithChildren<FormLineProps>) {
return (
<div className="FormLine">
<label htmlFor={id}>{label}:</label>
{children}
</div>
);
}
interface InputProps {
label: string
value: string
onChange?: (s: string) => void,
onChangeNumeric?: (n: number) => void
numeric?: boolean
}
export function Input({label, numeric, onChange, onChangeNumeric, value}: InputProps) {
const id = useMemo(() => `input-${randomId()}`, []);
const actualOnChange = useCallback((newValue: string) => {
if (onChangeNumeric) {
onChangeNumeric(parseInt(newValue));
} else if (onChange) {
onChange(newValue);
}
}, [onChange, onChangeNumeric]);
return (
<FormLine label={label} id={id}>
<input id={id} value={value}
type={numeric ? "number" : "text"}
onChange={i => actualOnChange(i.target.value)}
/>
</FormLine>
)
}
interface ColorPickerProps {
label?: string
h: number
s: number
onChange: (h: number, s: number) => void
}
const randomId = () => Math.floor(Math.random() * 100000);
export const HSColorPicker: React.FC<ColorPickerProps> = ({h, s, onChange, label}) => {
const myId = useMemo(() => `color-picker-${randomId()}`, []);
useLayoutEffect(() => {
// @ts-ignore
const colorPicker = new iro.ColorPicker(`#${myId}`, {
color: {h, s: s * 100, v: 255},
layout: [
{
component: iro.ui.Wheel,
options: {}
}
],
});
colorPicker.on("input:end", (color: { hsv: { h: number, s: number } }) => {
onChange(color.hsv.h || 0, (color.hsv.s || 0) / 100);
});
return () => {
const elem = document.getElementById(myId);
if (elem === null) {
return;
}
elem.innerHTML = "";
};
}, [h, s, onChange, myId]);
return (
<FormLine label={label || "HS-farge"}>
<div id={myId} style={{margin: "0 auto"}}/>
</FormLine>
);
};
interface KelvinColorPickerProps {
label?: string
kelvin: number
onChange: (kelvin: number) => void
}
export const KelvinColorPicker: React.FC<KelvinColorPickerProps> = ({label, kelvin, onChange}) => {
const myId = useMemo(() => `color-picker-${randomId()}`, []);
useLayoutEffect(() => {
// @ts-ignore
const colorPicker = new iro.ColorPicker(`#${myId}`, {
color: {kelvin},
layout: [
{
component: iro.ui.Slider,
options: {
sliderType: "kelvin",
}
}
],
});
colorPicker.on("input:end", (color: iro.Color) => {
onChange(Math.floor(color.kelvin));
});
return () => {
const elem = document.getElementById(myId);
if (elem === null) {
return;
}
elem.innerHTML = "";
};
}, [kelvin, onChange, myId]);
return (
<FormLine label={label || "KV-farge"}>
<div title={`${kelvin}K`} id={myId} style={{margin: "0 auto"}}/>
</FormLine>
);
}
+76 -2
View File
@@ -1,7 +1,11 @@
@import "../res/colors" @import "../res/colors"
.Tabs-container .Tabs-container
background-color: $color-foreground-dark background-color: $color-background-elevated
&.Tabs-large
text-align: center
font-size: 175%
.Tabs-element .Tabs-element
display: inline-block display: inline-block
@@ -14,4 +18,74 @@
.Tabs-bold .Tabs-bold
font-family: 'Bitstream Vera Serif', 'Lucida Fax', serif font-family: 'Bitstream Vera Serif', 'Lucida Fax', serif
font-weight: 600 font-weight: 600
.Page
max-width: 800px
margin: 0.25em auto
.Dialog-overlay
background-color: rgba(0, 0, 0, 0.5)
position: fixed
left: 0
top: 0
right: 0
bottom: 0
overflow-y: scroll
.Dialog-main
max-width: 640px
margin: 4em auto
// background-color: #202020
color: #ddd
outline: none
box-shadow: 5px 10px 20px rgba(0, 0, 0, 0.5)
.Card
margin: 0
.Dialog-title
font-weight: 800
font-size: 200%
text-align: center
padding: 0.25em 0.5ch
.Dialog-body
margin-bottom: 0
.Dialog-footer
border-top: solid 1px #000
.Dialog-container
background-color: $color-background
.Dialog-header
font-size: 140%
font-weight: bold
text-align: center
padding: 0.1em 0
background-color: $color-background-elevated
border-bottom: solid 1px #000
.Dialog-footer
display: flex
flex-direction: row
button
flex-grow: 4
text-align: center
font: inherit
padding: 0.5em 1.5ch
background-color: $color-background-elevated
color: $color-foreground
border: none
cursor: pointer
border-bottom: solid 1px #000
&:hover, &:active
background-color: $color-background-elevated2
color: $color-foreground-elevated
&:not(:last-of-type)
border-right: solid 1px #000
+67 -10
View File
@@ -1,14 +1,18 @@
import React, {useCallback, useEffect, useMemo} from "react"; import React, {PropsWithChildren, useCallback, useEffect} from "react";
import ReactModal from "react-modal";
import "./Layout.sass"; import "./Layout.sass";
import {LuciferIcon} from "../models/Icons";
import {IconBlock} from "./Elements";
interface TabsProps { interface TabsProps {
tabNames: string[] tabNames: string[]
index: number index: number
onChange: (newIndex: number) => void onChange: (newIndex: number) => void
boldIndex?: number boldIndex?: number
large?: boolean
} }
export function Tabs({tabNames, index, onChange, boldIndex}: TabsProps) { export function Tabs({tabNames, index, onChange, large, boldIndex}: TabsProps) {
useEffect(() => { useEffect(() => {
if (index < 0) { if (index < 0) {
onChange(0); onChange(0);
@@ -32,14 +36,67 @@ export function Tabs({tabNames, index, onChange, boldIndex}: TabsProps) {
}, [index, boldIndex]) }, [index, boldIndex])
return ( return (
<div className="Tabs-container"> <div className={`Tabs-container ${large ? "Tabs-large" : ""}`}>
{tabNames.map((name, i) => ( <div className="Tabs-inner">
<div className={tabClass(i)} {tabNames.map((name, i) => (
onClick={() => onChange(i)} <div className={tabClass(i)}
> onClick={() => onChange(i)}
{name} >
</div> {name}
))} </div>
))}
</div>
</div> </div>
); );
} }
interface PageProps {
title?: string
}
export function Page({title, children}: PropsWithChildren<PageProps>) {
useEffect(() => {
window.document.title = title ? `${title} - Lucifer` : "Lucifer";
}, [title]);
return (
<div className="Page">
{children}
</div>
);
}
interface DialogProps {
title: string
buttons?: {
text: string
icon: LuciferIcon
onClick?: (() => void)
}[]
loading?: boolean
}
export function Dialog({title, buttons, children}: PropsWithChildren<DialogProps>) {
return (
<ReactModal isOpen={true}
className="Dialog-main"
overlayClassName="Dialog-overlay"
>
<div className="Dialog-container">
<div className="Dialog-header">
{title}
</div>
{children}
<div className="Dialog-footer">
{buttons?.map(b => (
<button key={b.text} onClick={b.onClick}>
<IconBlock icon={b.icon}/>
{" "}
{b.text}
</button>
))}
</div>
</div>
</ReactModal>
);
}

Some files were not shown because too many files have changed in this diff Show More