Compare commits
39
Commits
11ef2997c8
...
3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7b6b06748 | ||
|
|
27f8c6b91d | ||
|
|
0846290047 | ||
|
|
9b9e5e9da8 | ||
|
|
3c2aa7212d | ||
|
|
d6c38d647f | ||
|
|
06fa73b916 | ||
|
|
7a6ee3f87b | ||
|
|
d623647e21 | ||
|
|
4fd49d0480 | ||
|
|
38d777404b | ||
|
|
2f9bd3c007 | ||
|
|
56a0525b9d | ||
|
|
1cf8c019e5 | ||
|
|
b2c9cca400 | ||
|
|
4109d41bec | ||
|
|
616adc14a4 | ||
|
|
26fcc1f314 | ||
|
|
4e734c86a5 | ||
|
|
766c534fcb | ||
|
|
79fc2a319c | ||
|
|
e9bfbfbec7 | ||
|
|
b2375e5864 | ||
|
|
22c80a90a1 | ||
|
|
95ec918ed1 | ||
|
|
fff01befda | ||
|
|
ea3037b03a | ||
|
|
677d9aa9b1 | ||
|
|
5cdb4f3cf5 | ||
|
|
74fb2c6b5a | ||
|
|
2aa2757828 | ||
|
|
38d370894e | ||
|
|
a2e719def9 | ||
|
|
883115bf8b | ||
|
|
954d6bd3e1 | ||
|
|
e1f38e3dcd | ||
|
|
f045bc0f4d | ||
|
|
e4078de845 | ||
|
|
d6fdb48023 |
+40
@@ -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
|
||||
|
||||
|
||||
@@ -2,3 +2,10 @@
|
||||
.env
|
||||
.idea/
|
||||
*.iml
|
||||
/bridgetest
|
||||
/server
|
||||
/goose
|
||||
/bridgetest.exe
|
||||
/server.exe
|
||||
/goose.exe
|
||||
/10.24.4.1
|
||||
+58
-3
@@ -1,10 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Bridges(r gin.IRoutes) {
|
||||
@@ -12,11 +14,17 @@ func Bridges(r gin.IRoutes) {
|
||||
return config.BridgeRepository().FetchAll(ctxOf(c))
|
||||
}))
|
||||
|
||||
r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
}))
|
||||
|
||||
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
|
||||
var body struct {
|
||||
Driver models.DriverKind `json:"driver"`
|
||||
Address string `json:"address"`
|
||||
DryRun bool `json:"dryRun"`
|
||||
Driver models.DriverKind `json:"driver"`
|
||||
Address string `json:"address"`
|
||||
Token string `json:"token"`
|
||||
ForceSave bool `json:"forceSave"`
|
||||
DryRun bool `json:"dryRun"`
|
||||
}
|
||||
err := parseBody(c, &body)
|
||||
if err != nil {
|
||||
@@ -28,6 +36,22 @@ func Bridges(r gin.IRoutes) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -73,6 +97,37 @@ func Bridges(r gin.IRoutes) {
|
||||
return bridge, nil
|
||||
}))
|
||||
|
||||
r.PUT("/:id/search", handler(func(c *gin.Context) (interface{}, error) {
|
||||
var body struct {
|
||||
TimeoutMS int64 `json:"timeoutMs"`
|
||||
}
|
||||
err := parseBody(c, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if body.TimeoutMS <= 0 {
|
||||
body.TimeoutMS = 30000
|
||||
}
|
||||
|
||||
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices, err := driver.SearchDevices(ctxOf(c), bridge, time.Millisecond * time.Duration(body.TimeoutMS))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}))
|
||||
|
||||
r.DELETE("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) {
|
||||
if strings.HasPrefix(fetchStr, "tag:") {
|
||||
return config.DeviceRepository().FetchByReference(ctx, models.RKTag, fetchStr[4:])
|
||||
} else if strings.HasPrefix(fetchStr, "bridge:") {
|
||||
return config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, fetchStr[7:])
|
||||
} else if strings.HasPrefix(fetchStr, "id:") {
|
||||
return config.DeviceRepository().FetchByReference(ctx, models.RKDeviceID, fetchStr[7:])
|
||||
} else if strings.HasPrefix(fetchStr, "name:") {
|
||||
return config.DeviceRepository().FetchByReference(ctx, models.RKName, fetchStr[7:])
|
||||
}else if fetchStr == "all" {
|
||||
return config.DeviceRepository().FetchByReference(ctx, models.RKAll, "")
|
||||
} else {
|
||||
_, err := strconv.Atoi(fetchStr)
|
||||
if err != nil {
|
||||
return config.DeviceRepository().FetchByReference(ctx, models.RKName, fetchStr)
|
||||
}
|
||||
|
||||
return config.DeviceRepository().FetchByReference(ctx, models.RKDeviceID, fetchStr)
|
||||
}
|
||||
}
|
||||
|
||||
func Devices(r gin.IRoutes) {
|
||||
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return config.DeviceRepository().FetchByReference(ctxOf(c), models.RKAll, "")
|
||||
}))
|
||||
|
||||
r.GET("/:fetch", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return fetchDevices(ctxOf(c), c.Param("fetch"))
|
||||
}))
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
log.Println("Failed to save device for state:", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return 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])
|
||||
if err != nil {
|
||||
log.Println("Failed to save device for state:", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return 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() {
|
||||
for _, device := range devices {
|
||||
err := config.DeviceRepository().Save(context.Background(), &device)
|
||||
if err != nil {
|
||||
log.Println("Failed to save device for state:", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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) {
|
||||
var body models.EventHandlerUpdate
|
||||
err := parseBody(c, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
handler, err := config.EventHandlerRepository().FindByID(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handler.ApplyUpdate(body)
|
||||
|
||||
err = config.EventHandlerRepository().Save(ctxOf(c), handler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return handler, nil
|
||||
}))
|
||||
|
||||
r.DELETE("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
handler, err := config.EventHandlerRepository().FindByID(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = config.EventHandlerRepository().Delete(ctxOf(c), handler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return handler, nil
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ColorPresets(r gin.IRoutes) {
|
||||
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return config.ColorPresetRepository().FetchAll(ctxOf(c))
|
||||
}))
|
||||
|
||||
r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return config.ColorPresetRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
}))
|
||||
|
||||
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
ColorString string `json:"colorString"`
|
||||
}
|
||||
err := parseBody(c, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newColor, err := models.ParseColorValue(body.ColorString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
preset := models.ColorPreset{
|
||||
Name: body.Name,
|
||||
Value: newColor,
|
||||
}
|
||||
|
||||
preset.Validate()
|
||||
err = config.ColorPresetRepository().Save(ctxOf(c), &preset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return preset, nil
|
||||
}))
|
||||
|
||||
r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
ColorString *string `json:"colorString"`
|
||||
}
|
||||
err := parseBody(c, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
preset, err := config.ColorPresetRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if body.Name != nil {
|
||||
preset.Name = *body.Name
|
||||
}
|
||||
|
||||
if body.ColorString != nil {
|
||||
newColor, err := models.ParseColorValue(*body.ColorString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
preset.Value = newColor
|
||||
}
|
||||
|
||||
preset.Validate()
|
||||
err = config.ColorPresetRepository().Save(ctxOf(c), &preset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return preset, nil
|
||||
}))
|
||||
|
||||
r.DELETE("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
preset, err := config.ColorPresetRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = config.ColorPresetRepository().Delete(ctxOf(c), &preset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return preset, nil
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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) 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,
|
||||
}
|
||||
@@ -23,6 +23,9 @@ func DBX() *sqlx.DB {
|
||||
MySqlPort(),
|
||||
MySqlSchema(),
|
||||
))
|
||||
|
||||
dbx.SetMaxIdleConns(50)
|
||||
dbx.SetMaxOpenConns(100)
|
||||
}
|
||||
|
||||
return dbx
|
||||
|
||||
+16
-4
@@ -1,13 +1,25 @@
|
||||
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 dpOnce sync.Once
|
||||
|
||||
func DriverProvider() models.DriverProvider {
|
||||
if dp == nil {
|
||||
panic("not implemented yet")
|
||||
}
|
||||
dpOnce.Do(func() {
|
||||
dp = drivers.DriverMap{
|
||||
models.DTNanoLeaf: &nanoleaf.Driver{},
|
||||
models.DTHue: &hue.Driver{},
|
||||
models.DTLIFX: &lifx.Driver{},
|
||||
}
|
||||
})
|
||||
|
||||
return dp
|
||||
}
|
||||
|
||||
+6
-20
@@ -5,32 +5,18 @@ import (
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
)
|
||||
|
||||
var (
|
||||
bRepo models.BridgeRepository
|
||||
dRepo models.DeviceRepository
|
||||
ehRepo models.EventHandlerRepository
|
||||
)
|
||||
|
||||
func BridgeRepository() models.BridgeRepository {
|
||||
if bRepo == nil {
|
||||
bRepo = &mysql.BridgeRepo{DBX: DBX()}
|
||||
}
|
||||
return &mysql.BridgeRepo{DBX: DBX()}
|
||||
}
|
||||
|
||||
return bRepo
|
||||
func ColorPresetRepository() models.ColorPresetRepository {
|
||||
return &mysql.ColorPresetRepo{DBX: DBX()}
|
||||
}
|
||||
|
||||
func DeviceRepository() models.DeviceRepository {
|
||||
if dRepo == nil {
|
||||
panic("panik")
|
||||
}
|
||||
|
||||
return dRepo
|
||||
return &mysql.DeviceRepo{DBX: DBX()}
|
||||
}
|
||||
|
||||
func EventHandlerRepository() models.EventHandlerRepository {
|
||||
if ehRepo == nil {
|
||||
panic("panik")
|
||||
}
|
||||
|
||||
return ehRepo
|
||||
return &mysql.EventHandlerRepo{DBX: DBX()}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,20 @@ import (
|
||||
|
||||
func StartServer() {
|
||||
services.StartEventHandler()
|
||||
services.StartPublisher()
|
||||
services.ConnectToBridges()
|
||||
services.CheckNewDevices()
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
ginny := gin.New()
|
||||
|
||||
apiGin := ginny.Group("/api")
|
||||
api.Bridges(apiGin.Group("/bridges"))
|
||||
api.Devices(apiGin.Group("/devices"))
|
||||
api.ColorPresets(apiGin.Group("/color-presets"))
|
||||
api.DriverKinds(apiGin.Group("/driver-kinds"))
|
||||
api.Events(apiGin.Group("/events"))
|
||||
api.EventHandlers(apiGin.Group("/event-handlers"))
|
||||
|
||||
log.Fatal(ginny.Run(fmt.Sprintf("0.0.0.0:%d", config.ServerPort())))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"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()) {
|
||||
savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID))
|
||||
if err != nil {
|
||||
log.Println("Failed to fetch devices from db for refresh:", err)
|
||||
}
|
||||
err = driver.Publish(ctx, bridge, savedDevices)
|
||||
if err != nil {
|
||||
log.Println("Failed to publish devices from db before run:", err)
|
||||
}
|
||||
|
||||
err = driver.Run(ctx, bridge, config.EventChannel)
|
||||
log.Printf("Bridge \"%s\" (%d) stopped: %s", bridge.Name, bridge.ID, err)
|
||||
|
||||
cancelMutex.Lock()
|
||||
cancel()
|
||||
cancelMap[bridge.ID] = nil
|
||||
cancelMutex.Unlock()
|
||||
}(bridge, cancel)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+154
-11
@@ -2,37 +2,60 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartEventHandler() {
|
||||
config.EventChannel<-models.Event{Name: "LuciferStarted"}
|
||||
config.EventChannel <- models.Event{Name: "LuciferStarted"}
|
||||
|
||||
go func() {
|
||||
for event := range config.EventChannel {
|
||||
handleEvent(event)
|
||||
responses := handleEvent(event)
|
||||
|
||||
for _, response := range responses {
|
||||
handleEvent(response)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Dispatch an HourChanged event at every hour
|
||||
// Generate TimeChanged event
|
||||
go func() {
|
||||
drift := time.Now().Add(time.Minute).Truncate(time.Minute).Sub(time.Now())
|
||||
time.Sleep(drift + time.Millisecond * 5)
|
||||
time.Sleep(drift + time.Millisecond*5)
|
||||
|
||||
for range time.NewTicker(time.Minute).C {
|
||||
config.EventChannel <- models.Event{Name: "TimeChanged"}
|
||||
config.EventChannel <- models.Event{Name: "TimeChanged", Payload: map[string]string{
|
||||
"weekdayName": time.Now().In(loc).Format("Monday"),
|
||||
"month": time.Now().In(loc).Format("01"),
|
||||
"year": time.Now().In(loc).Format("2006"),
|
||||
}}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var loc, _ = time.LoadLocation("Europe/Oslo")
|
||||
var ctx = context.Background()
|
||||
var X = false
|
||||
|
||||
func handleEvent(event models.Event) {
|
||||
func handleEvent(event models.Event) (responses []models.Event) {
|
||||
var deadHandlers []models.EventHandler
|
||||
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
duration := time.Since(startTime)
|
||||
if duration > time.Millisecond*250 {
|
||||
log.Println(event.Name, "took long to handle:", duration)
|
||||
}
|
||||
}()
|
||||
|
||||
if !event.HasPayload("hour") {
|
||||
event.AddPayload("hour", time.Now().In(loc).Format("15"))
|
||||
}
|
||||
@@ -40,21 +63,40 @@ func handleEvent(event models.Event) {
|
||||
event.AddPayload("minute", time.Now().In(loc).Format("04"))
|
||||
}
|
||||
|
||||
if !X {
|
||||
log.Println("Unhandled event: " + event.Name)
|
||||
err := handleSpecial(event)
|
||||
if err != nil {
|
||||
log.Printf("Special event handler error (%s): %v", event.Name, err)
|
||||
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)
|
||||
if err != nil {
|
||||
log.Printf("Error fetchin event halders: %d", err)
|
||||
log.Printf("Error fetchin' event halders: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
allDevices := make([]models.Device, 0, 16)
|
||||
actions := make(map[int]models.EventAction, 16)
|
||||
priorities := make(map[int]int, 16)
|
||||
highestPriority := 0
|
||||
var prioritizedEvent *models.Event
|
||||
|
||||
for _, handler := range handlers {
|
||||
if handler.EventName != event.Name {
|
||||
continue
|
||||
}
|
||||
|
||||
devices, err := config.DeviceRepository().FetchByReference(ctx, handler.TargetKind, handler.TargetValue)
|
||||
if err != nil {
|
||||
log.Printf("Error fetchin event halders: %d", err)
|
||||
log.Printf("Error fetchin' event devices: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,5 +104,106 @@ func handleEvent(event models.Event) {
|
||||
continue
|
||||
}
|
||||
|
||||
if handler.OneShot {
|
||||
deadHandlers = append(deadHandlers, handler)
|
||||
}
|
||||
|
||||
if handler.Priority > highestPriority {
|
||||
highestPriority = handler.Priority
|
||||
prioritizedEvent = handler.Actions.FireEvent
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
_, ok := actions[device.ID]
|
||||
if !ok {
|
||||
allDevices = append(allDevices, device)
|
||||
actions[device.ID] = handler.Actions
|
||||
priorities[device.ID] = handler.Priority
|
||||
} else if handler.Priority > priorities[device.ID] {
|
||||
actions[device.ID] = handler.Actions
|
||||
priorities[device.ID] = handler.Priority
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
err = allDevices[i].SetState(newState)
|
||||
if err != nil {
|
||||
log.Println("Error updating state for device", device.ID, "err:", err)
|
||||
}
|
||||
}
|
||||
|
||||
config.PublishChannel <- allDevices
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
for _, device := range allDevices {
|
||||
wg.Add(1)
|
||||
|
||||
go func(device models.Device) {
|
||||
err := config.DeviceRepository().Save(context.Background(), &device)
|
||||
if err != nil {
|
||||
log.Println("Failed to save device for state:", err)
|
||||
}
|
||||
|
||||
wg.Done()
|
||||
}(device)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+45
-16
@@ -5,6 +5,8 @@ import (
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartPublisher() {
|
||||
@@ -16,32 +18,59 @@ func StartPublisher() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Emergency solution! Please avoid!
|
||||
// Send devices not belonging to the first channel separately
|
||||
bridgeID := devices[0].BridgeID
|
||||
lists := make(map[int][]models.Device, 4)
|
||||
for _, device := range devices {
|
||||
if device.BridgeID != bridgeID {
|
||||
config.PublishChannel<-[]models.Device{device}
|
||||
}
|
||||
lists[device.BridgeID] = append(lists[device.BridgeID], device)
|
||||
}
|
||||
|
||||
bridge, err := config.BridgeRepository().Find(ctx, devices[0].BridgeID)
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second * 30)
|
||||
|
||||
bridges, err := config.BridgeRepository().FetchAll(ctx)
|
||||
if err != nil {
|
||||
log.Println("Publishing error (1): " + err.Error())
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
|
||||
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||
if err != nil {
|
||||
log.Println("Publishing error (2): " + err.Error())
|
||||
continue
|
||||
wg := sync.WaitGroup{}
|
||||
for _, devices := range lists {
|
||||
wg.Add(1)
|
||||
|
||||
go func(devices []models.Device) {
|
||||
defer wg.Done()
|
||||
|
||||
var bridge models.Bridge
|
||||
for _, bridge2 := range bridges {
|
||||
if bridge2.ID == devices[0].BridgeID {
|
||||
bridge = bridge2
|
||||
}
|
||||
}
|
||||
if bridge.ID == 0 {
|
||||
log.Println("Unknown bridge")
|
||||
}
|
||||
|
||||
bridge, err := config.BridgeRepository().Find(ctx, devices[0].BridgeID)
|
||||
if err != nil {
|
||||
log.Println("Publishing error (1): " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||
if err != nil {
|
||||
log.Println("Publishing error (2): " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = driver.Publish(ctx, bridge, devices)
|
||||
if err != nil {
|
||||
log.Println("Publishing error (3): " + err.Error())
|
||||
return
|
||||
}
|
||||
}(devices)
|
||||
}
|
||||
|
||||
err = driver.Publish(ctx, bridge, devices)
|
||||
if err != nil {
|
||||
log.Println("Publishing error (3): " + err.Error())
|
||||
continue
|
||||
}
|
||||
wg.Wait()
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
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
@@ -3,9 +3,10 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf"
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"os"
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var flagDriver = flag.String("driver", "Nanoleaf", "The bridge driver to use")
|
||||
var flagDriver = flag.String("driver", string(models.DTNanoLeaf), "The bridge driver to use")
|
||||
var flagAddress = flag.String("address", "127.0.0.1", "The bridge's address")
|
||||
var flagToken = flag.String("token", "", "The bridge's access token / api key / login")
|
||||
var flagPair = flag.Bool("pair", false, "Try to pair with the bridge.")
|
||||
@@ -25,8 +26,11 @@ var flagSearchTimeout = flag.Duration("search-timeout", time.Second*3, "Timeout
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// TODO: Select driver
|
||||
driver := nanoleaf.Driver{}
|
||||
// Find drivers
|
||||
driver, err := config.DriverProvider().Provide(models.DriverKind(*flagDriver))
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to find driver:", err)
|
||||
}
|
||||
|
||||
// Find bridge
|
||||
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, !*flagPair)
|
||||
@@ -43,6 +47,15 @@ func main() {
|
||||
log.Println("New token:", bridge.Token)
|
||||
}
|
||||
|
||||
ch := config.EventChannel
|
||||
go func() {
|
||||
err := driver.Run(context.Background(), bridge, ch)
|
||||
if err != nil {
|
||||
log.Fatalln("Run bridge stopped:", err)
|
||||
}
|
||||
}()
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// List devices
|
||||
var devices []models.Device
|
||||
if *flagSearch {
|
||||
@@ -56,20 +69,14 @@ func main() {
|
||||
log.Fatalln("Failed to list devices:", err)
|
||||
}
|
||||
}
|
||||
|
||||
idMap := make(map[string]int)
|
||||
nextId := len(devices) + 1
|
||||
for i := range devices {
|
||||
devices[i].ID = i + 1
|
||||
idMap[devices[i].InternalID] = i + 1
|
||||
}
|
||||
|
||||
_ = driver.Publish(context.Background(), bridge, devices)
|
||||
|
||||
ch := make(chan models.Event)
|
||||
go func() {
|
||||
err := driver.Run(context.Background(), bridge, ch)
|
||||
if err != nil {
|
||||
log.Fatalln("Run bridge stopped:", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
@@ -83,6 +90,35 @@ func main() {
|
||||
text, _ := reader.ReadString('\n')
|
||||
text = strings.Trim(text, "\t \r\n")
|
||||
|
||||
if text == "search" {
|
||||
_, _ = driver.SearchDevices(context.Background(), bridge, time.Second)
|
||||
}
|
||||
if text == "list" || text == "search" || text == "json" {
|
||||
devices, err = driver.ListDevices(context.Background(), bridge)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to list devices:", err)
|
||||
}
|
||||
|
||||
for i, device := range devices {
|
||||
if extId, ok := idMap[device.InternalID]; ok {
|
||||
devices[i].ID = extId
|
||||
} else {
|
||||
idMap[device.InternalID] = nextId
|
||||
devices[i].ID = nextId
|
||||
|
||||
nextId += 1
|
||||
}
|
||||
|
||||
if text != "json" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Device: %d - %s %+v\n", device.ID, device.InternalID, device.Capabilities)
|
||||
}
|
||||
}
|
||||
}
|
||||
if text == "json" {
|
||||
j, _ := json.MarshalIndent(devices, "", " ")
|
||||
fmt.Println(string(j))
|
||||
}
|
||||
|
||||
tokens := strings.Split(text, " ")
|
||||
if len(tokens) < 4 {
|
||||
continue
|
||||
@@ -120,14 +156,15 @@ func main() {
|
||||
if id == -1 || id == device.ID {
|
||||
if (color.IsKelvin() && device.HasCapability(models.DCColorKelvin)) || (color.IsHueSat() && device.HasCapability(models.DCColorHS)) {
|
||||
device.State.Color = color
|
||||
if device.HasCapability(models.DCPower) {
|
||||
device.State.Power = power
|
||||
}
|
||||
if device.HasCapability(models.DCIntensity) {
|
||||
device.State.Intensity = intensity
|
||||
}
|
||||
updatedDevices = append(updatedDevices, device)
|
||||
}
|
||||
if device.HasCapability(models.DCPower) {
|
||||
device.State.Power = power
|
||||
}
|
||||
if device.HasCapability(models.DCIntensity) {
|
||||
device.State.Intensity = intensity
|
||||
}
|
||||
|
||||
updatedDevices = append(updatedDevices, device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -4,11 +4,14 @@ import (
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"github.com/pressly/goose"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
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("Authenticating as: %s", config.MySqlUsername())
|
||||
|
||||
@@ -17,7 +20,12 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = goose.Run("up", db, "./scripts")
|
||||
cmd := os.Getenv("GOOSE_COMMAND")
|
||||
if cmd == "" {
|
||||
cmd = "up"
|
||||
}
|
||||
|
||||
err = goose.Run(cmd, db, "./scripts")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Param struct {
|
||||
Index int
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
func (p *Param) String() *string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &p.Value
|
||||
}
|
||||
|
||||
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) 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})
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func (p Params) StringMap() map[string]string {
|
||||
res := make(map[string]string)
|
||||
for _, param := range p {
|
||||
if param.Index == -1 {
|
||||
res[param.Key] = param.Value
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func (p Params) StringPtrMap() map[string]*string {
|
||||
res := make(map[string]*string)
|
||||
for _, param := range p {
|
||||
if param.Index == -1 {
|
||||
if param.Value == "NULL" {
|
||||
res[param.Key] = nil
|
||||
} else {
|
||||
res[param.Key] = ¶m.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func (p Params) Strings(minIndex int) []string {
|
||||
res := make([]string, 0, len(p))
|
||||
for _, param := range p {
|
||||
if param.Index >= minIndex {
|
||||
res = append(res, param.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
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:] {
|
||||
kv := strings.SplitN(arg, "=", 2)
|
||||
|
||||
if len(kv) == 2 {
|
||||
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kv[0], Value: kv[1]})
|
||||
} else {
|
||||
cmd.Params = append(cmd.Params, Param{Index: nextIndex, Value: arg})
|
||||
nextIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v2"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Remote string `yaml:"remote"`
|
||||
}
|
||||
|
||||
func loadConfig() (*Config, error) {
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := os.Open(path.Join(homedir, ".config/lucy.yaml"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := Config{}
|
||||
err = yaml.NewDecoder(f).Decode(&cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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 COMMANDS
|
||||
run <event name> <*=S>
|
||||
`
|
||||
@@ -0,0 +1,121 @@
|
||||
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"}
|
||||
}
|
||||
|
||||
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(context.Background(), *fetchStr)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
WriteDeviceStateTable(os.Stdout, devices)
|
||||
}
|
||||
case "set":
|
||||
{
|
||||
devices, err := c.PutDeviceState(context.Background(), cmd.Params.Get(0).StringOr("all"), models.NewDeviceState{
|
||||
Power: cmd.Params.Get("power").Bool(),
|
||||
Color: cmd.Params.Get("color").String(),
|
||||
Intensity: cmd.Params.Get("intensity").Float(),
|
||||
Temperature: cmd.Params.Get("temperature").Int(),
|
||||
})
|
||||
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(
|
||||
context.Background(),
|
||||
cmd.Params.Get(0).StringOr("all"),
|
||||
addTags,
|
||||
removeTags,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
WriteDeviceInfoTable(os.Stdout, devices)
|
||||
}
|
||||
case "update":
|
||||
{
|
||||
devices, err := c.PutDevice(context.Background(), cmd.Params.Get(0).StringOr("all"), models.DeviceUpdate{
|
||||
Name: cmd.Params.Get("name").String(),
|
||||
Icon: cmd.Params.Get("icon").String(),
|
||||
UserProperties: cmd.Params.Subset("prop.").StringPtrMap(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
WriteDeviceInfoTable(os.Stdout, devices)
|
||||
}
|
||||
|
||||
// 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(context.Background(), event)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
if cmd.Name != "help" {
|
||||
log.Println("Unknown command:", cmd.Name)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(os.Stderr, helpString[1:])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"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)
|
||||
|
||||
for _, v := range devices {
|
||||
powerStr := ""
|
||||
if v.HasCapability(models.DCPower) {
|
||||
powerStr = strconv.FormatBool(v.State.Power)
|
||||
}
|
||||
|
||||
colorStr := ""
|
||||
if v.HasCapability(models.DCColorHSK, models.DCColorHS, models.DCColorKelvin) {
|
||||
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) {
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#####################
|
||||
## 1. Build server ##
|
||||
#####################
|
||||
FROM golang:1.17 AS build-server
|
||||
|
||||
WORKDIR /project
|
||||
COPY . .
|
||||
|
||||
ENV CGO_ENABLED 0
|
||||
|
||||
RUN go mod download
|
||||
RUN go build -ldflags "-w -s" -o /build/lucifer cmd/server/main.go
|
||||
|
||||
|
||||
|
||||
###################
|
||||
## 2. Run server ##
|
||||
###################
|
||||
FROM alpine:3.14.2
|
||||
|
||||
# Add results from previous builds
|
||||
COPY --from=build-server /build/lucifer /usr/bin/lucifer
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
# Run server
|
||||
CMD lucifer
|
||||
@@ -3,13 +3,17 @@ module git.aiterp.net/lucifer/new-server
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.0
|
||||
github.com/gin-gonic/gin v1.7.1
|
||||
github.com/go-sql-driver/mysql v1.6.0
|
||||
github.com/jmoiron/sqlx v1.3.4
|
||||
github.com/lib/pq v1.10.2 // indirect
|
||||
github.com/lib/pq v1.10.3 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pressly/goose v2.7.0+incompatible
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect
|
||||
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
gopkg.in/yaml.v2 v2.2.8
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/Masterminds/squirrel v1.5.0 h1:JukIZisrUXadA9pl3rMkjhiamxiB0cXiu+HGp/Y8cY8=
|
||||
github.com/Masterminds/squirrel v1.5.0/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -23,15 +25,21 @@ github.com/jmoiron/sqlx v1.3.4 h1:wv+0IJZfL5z0uZoUjlpKgHkgaFSYD+r9CfrXjEXsO7w=
|
||||
github.com/jmoiron/sqlx v1.3.4/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
|
||||
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
|
||||
github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
@@ -39,6 +47,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -46,6 +56,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/pressly/goose v2.7.0+incompatible h1:PWejVEv07LCerQEzMMeAtjuyCKbyprZ/LBa6K5P0OCQ=
|
||||
github.com/pressly/goose v2.7.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
@@ -55,15 +66,18 @@ github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs
|
||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 h1:3erb+vDS8lU1sxfDHF4/hhWyaXnhIaO+7RgL4fDZORA=
|
||||
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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 bool
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
name := models.ENSensorPresenceStarted
|
||||
if !newData.State.Presence {
|
||||
name = models.ENSensorPresenceEnding
|
||||
state.presenceCooldown = true
|
||||
}
|
||||
|
||||
return &models.Event{
|
||||
Name: name,
|
||||
Payload: map[string]string{
|
||||
"deviceId": strconv.Itoa(state.externalID),
|
||||
"deviceInternalId": newData.UniqueID,
|
||||
},
|
||||
}
|
||||
} else if state.presenceCooldown && !newData.State.Presence && time.Since(stateTime) > time.Minute {
|
||||
state.presenceCooldown = false
|
||||
return &models.Event{
|
||||
Name: models.ENSensorPresenceEnded,
|
||||
Payload: map[string]string{
|
||||
"deviceId": strconv.Itoa(state.externalID),
|
||||
"deviceInternalId": newData.UniqueID,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
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 {
|
||||
if state.firmware == nil {
|
||||
_, _ = client.Send(state.target, &GetHostFirmware{})
|
||||
}
|
||||
if state.version == nil {
|
||||
_, _ = client.Send(state.target, &GetVersion{})
|
||||
}
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
if atomic.LoadUint32(&client.isHorrible) == 0 && time.Since(lastServiceTime) > time.Second*30 && time.Since(lastSearchTime) > time.Second*3 {
|
||||
lastSearchTime = time.Now()
|
||||
_, err = client.Send("", &GetService{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) checkAndUpdateState(state *State) {
|
||||
state.acksPending = state.acksPending[:0]
|
||||
|
||||
updatePayloads := state.generateUpdate()
|
||||
for _, updatePayload := range updatePayloads {
|
||||
seq, err := b.client.Send(state.target, updatePayload)
|
||||
if err != nil {
|
||||
log.Println("Error sending updates to", state.externalId, state.target, "err:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
state.updateTime = time.Now()
|
||||
state.acksPending = append(state.acksPending, seq)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) ensureState(target string) (*State, bool) {
|
||||
for _, state := range b.states {
|
||||
if state.target == target {
|
||||
return state, false
|
||||
}
|
||||
}
|
||||
|
||||
state := &State{
|
||||
target: target,
|
||||
discoveredTime: time.Now(),
|
||||
}
|
||||
|
||||
b.states = append(b.states, state)
|
||||
|
||||
return state, true
|
||||
}
|
||||
|
||||
func (b *Bridge) getClient() *Client {
|
||||
b.mu.Lock()
|
||||
client := b.client
|
||||
b.mu.Unlock()
|
||||
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package lifx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
seq uint8
|
||||
buf []byte
|
||||
lastN int
|
||||
|
||||
conn *net.UDPConn
|
||||
source uint32
|
||||
debug bool
|
||||
|
||||
isHorrible uint32
|
||||
|
||||
addrMap map[string]*net.UDPAddr
|
||||
}
|
||||
|
||||
// Send sends the payload to the hardware addr specified. If it cannot find a mapped
|
||||
// IP address from a past message, it will be broadcast and may be slow to use. If the
|
||||
// address is left blank, the package is marked as tagged and broadcast.
|
||||
//
|
||||
// ErrInvalidAddress can be returned by this, and indicates a badly configured device.
|
||||
// It should probably be logged.
|
||||
func (c *Client) Send(addr string, payload Payload) (seq uint8, err error) {
|
||||
c.mu.Lock()
|
||||
seq = c.seq
|
||||
c.seq += 1
|
||||
c.mu.Unlock()
|
||||
|
||||
packet := createPacket()
|
||||
packet.SetSource(c.source)
|
||||
packet.SetSequence(seq)
|
||||
|
||||
sendAddr := &net.UDPAddr{IP: net.IPv4(255, 255, 255, 255), Port: 56700}
|
||||
if addr == "" {
|
||||
packet.SetTagged(true)
|
||||
|
||||
if c.debug {
|
||||
log.Println("Broadcasting", payload, "seq", seq)
|
||||
}
|
||||
} else {
|
||||
err = packet.SetTarget(addr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if udpAddr, ok := c.addrMap[addr]; ok {
|
||||
sendAddr = udpAddr
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if c.debug {
|
||||
log.Println("Sending", payload, "to", addr, "seq", seq)
|
||||
}
|
||||
}
|
||||
|
||||
packet.SetPayload(payload)
|
||||
|
||||
_, err = c.conn.WriteToUDP(packet, sendAddr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// HorribleBroadcast "broadcasts" by blasting every IP address in the space with a tagged packet, because LIFX is LIFX.
|
||||
func (c *Client) HorribleBroadcast(ctx context.Context, payload Payload) (seq uint8, err error) {
|
||||
c.mu.Lock()
|
||||
seq = c.seq
|
||||
c.seq += 1
|
||||
c.mu.Unlock()
|
||||
|
||||
defer atomic.StoreUint32(&c.isHorrible, 0)
|
||||
|
||||
packet := createPacket()
|
||||
packet.SetSource(c.source)
|
||||
packet.SetSequence(seq)
|
||||
packet.SetPayload(payload)
|
||||
packet.SetTagged(true)
|
||||
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
connIP := c.conn.LocalAddr().(*net.UDPAddr).IP
|
||||
|
||||
var minIPNum uint32
|
||||
var maxIPNum uint32
|
||||
for _, iface := range ifaces {
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
naddr, ok := addr.(*net.IPNet)
|
||||
if !ok || !naddr.Contains(connIP) {
|
||||
continue
|
||||
}
|
||||
|
||||
networkAddress := naddr.IP.Mask(naddr.Mask)
|
||||
cidr, bits := naddr.Mask.Size()
|
||||
minIPNum = binary.BigEndian.Uint32(networkAddress) + 1
|
||||
maxIPNum = minIPNum + (1 << (bits - cidr)) - 2
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if minIPNum == maxIPNum {
|
||||
return
|
||||
}
|
||||
myIPNum := binary.BigEndian.Uint32(connIP)
|
||||
|
||||
if c.debug {
|
||||
ipBuf := make(net.IP, 8)
|
||||
binary.BigEndian.PutUint32(ipBuf, minIPNum)
|
||||
binary.BigEndian.PutUint32(ipBuf[4:], maxIPNum-1)
|
||||
|
||||
log.Println("Sending tagged", payload, "to range", ipBuf[:4], "-", ipBuf[4:])
|
||||
|
||||
defer func() {
|
||||
log.Println("Completed sending tagged", payload, "to range", ipBuf[:4], "-", ipBuf[4:])
|
||||
}()
|
||||
}
|
||||
|
||||
for ipNum := minIPNum; ipNum < maxIPNum; ipNum++ {
|
||||
if ipNum == myIPNum {
|
||||
continue
|
||||
}
|
||||
|
||||
atomic.StoreUint32(&c.isHorrible, 1)
|
||||
|
||||
ipBuf := make(net.IP, 4)
|
||||
binary.BigEndian.PutUint32(ipBuf, ipNum)
|
||||
addr := &net.UDPAddr{IP: ipBuf, Port: 56700}
|
||||
|
||||
_, err = c.conn.WriteToUDP(packet, addr)
|
||||
if err != nil {
|
||||
if c.debug {
|
||||
log.Println("HorribleBroadcast™ aborted by error.")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if ipNum%256 == 0 {
|
||||
if c.debug {
|
||||
log.Println("Progress:", (ipNum-minIPNum)+1, "of", maxIPNum-minIPNum)
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
if c.debug {
|
||||
log.Println("HorribleBroadcast™ aborted at call site.")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// LastPacket gets the last read packet. This data is valid until the next call to Recv. The Packet may
|
||||
// not be valid, however!
|
||||
func (c *Client) LastPacket() Packet {
|
||||
if c.lastN < 36 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.buf[:c.lastN]
|
||||
}
|
||||
|
||||
// Recv reads a message from the UDP socket. The data returned is decoded and will always be valid.
|
||||
//
|
||||
// However, these should be handled specifically:
|
||||
// - ErrPayloadTooShort, ErrInvalidPacketSize: Garbage was received, please ignore.
|
||||
// - ErrUnrecognizedPacketType: Log these and see what's up
|
||||
// - ErrReadTimeout: The connection gets a 50ms read deadline. It should be used to do other things than wait
|
||||
func (c *Client) Recv(timeout time.Duration) (target string, seq uint8, payload Payload, err error) {
|
||||
if c.buf == nil {
|
||||
c.buf = make([]byte, 2048)
|
||||
}
|
||||
|
||||
if timeout > 0 {
|
||||
err = c.conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
} else {
|
||||
err = c.conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
n, addr, err := c.conn.ReadFromUDP(c.buf)
|
||||
if n > 0 {
|
||||
c.lastN = n
|
||||
}
|
||||
if err != nil {
|
||||
if netErr, ok := err.(*net.OpError); ok && netErr.Timeout() {
|
||||
err = models.ErrReadTimeout
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
packet := Packet(c.buf[:n])
|
||||
if n < 2 || packet.Size() != n && packet.Protocol() != 1024 {
|
||||
err = models.ErrInvalidAddress
|
||||
return
|
||||
}
|
||||
|
||||
seq = packet.Sequence()
|
||||
target = packet.Target().String()
|
||||
|
||||
payload, err = packet.Payload()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Learn the IP address from state service or ack messages.
|
||||
if service, ok := payload.(*StateService); ok && service.Service == 1 {
|
||||
c.mu.Lock()
|
||||
if c.addrMap == nil {
|
||||
c.addrMap = make(map[string]*net.UDPAddr)
|
||||
}
|
||||
c.addrMap[packet.Target().String()] = addr
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
if c.debug {
|
||||
log.Println("Received", payload, "from", target, "seq", seq)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// createClient creates a client that will last as long as the context.
|
||||
func createClient(ctx context.Context, bindAddr string, debug bool) (*Client, error) {
|
||||
addr := net.ParseIP(bindAddr)
|
||||
if addr == nil {
|
||||
return nil, errors.New("invalid addr")
|
||||
}
|
||||
|
||||
source := uint32(rand.Uint64())
|
||||
if source < 2 {
|
||||
source = 2
|
||||
}
|
||||
|
||||
conn, err := net.ListenUDP("udp", &net.UDPAddr{
|
||||
IP: addr,
|
||||
Port: 0,
|
||||
Zone: "",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = conn.SetWriteBuffer(2048 * 1024)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
return &Client{conn: conn, source: source, debug: debug}, nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package lifx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
mu sync.Mutex
|
||||
bridges []*Bridge
|
||||
}
|
||||
|
||||
func (d *Driver) SearchBridge(ctx context.Context, address string, _ bool) ([]models.Bridge, error) {
|
||||
if address == "" {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bridges := make([]models.Bridge, 0, len(ifaces))
|
||||
for _, iface := range ifaces {
|
||||
if iface.Name == "lo" {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil || len(addrs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
bridges = append(bridges, models.Bridge{
|
||||
ID: -1,
|
||||
Name: fmt.Sprintf("%s (%s)", iface.Name, addr),
|
||||
Driver: models.DTLIFX,
|
||||
Address: addr.String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return bridges, nil
|
||||
}
|
||||
|
||||
ctx2, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
_, err := createClient(ctx2, address, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []models.Bridge{{
|
||||
ID: 0,
|
||||
Name: "Your network card",
|
||||
Driver: models.DTLIFX,
|
||||
Address: address,
|
||||
Token: "",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
before, err := d.ListDevices(ctx, bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = b.StartSearch(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if timeout < time.Second/10 {
|
||||
timeout = time.Second / 10
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(timeout):
|
||||
}
|
||||
|
||||
after, err := d.ListDevices(ctx, bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intersection := make([]models.Device, 0, 4)
|
||||
for _, device := range after {
|
||||
found := false
|
||||
for _, device2 := range before {
|
||||
if device2.InternalID == device.InternalID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
intersection = append(intersection, device)
|
||||
}
|
||||
}
|
||||
|
||||
return intersection, nil
|
||||
}
|
||||
|
||||
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.Devices(), nil
|
||||
}
|
||||
|
||||
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.Publish(devices)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.Run(ctx, bridge.Token == "debug")
|
||||
}
|
||||
|
||||
func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*Bridge, error) {
|
||||
d.mu.Lock()
|
||||
for _, bridge := range d.bridges {
|
||||
if bridge.ip == info.Address {
|
||||
d.mu.Unlock()
|
||||
return bridge, nil
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
bridge := &Bridge{
|
||||
ip: info.Address,
|
||||
externalID: info.ID,
|
||||
}
|
||||
|
||||
// Try to create a short-lived client to make sure it works.
|
||||
ctx2, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
_, err := createClient(ctx2, info.Address, info.Token == "debug")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// To avoid a potential duplicate, try looking for it again before inserting
|
||||
d.mu.Lock()
|
||||
for _, bridge := range d.bridges {
|
||||
if bridge.ip == info.Address {
|
||||
d.mu.Unlock()
|
||||
return bridge, nil
|
||||
}
|
||||
}
|
||||
d.bridges = append(d.bridges, bridge)
|
||||
d.mu.Unlock()
|
||||
|
||||
return bridge, nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package lifx
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"net"
|
||||
)
|
||||
|
||||
func createPacket() Packet {
|
||||
p := make(Packet, 36)
|
||||
p.Reset()
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
type Packet []byte
|
||||
|
||||
func (p *Packet) Reset() {
|
||||
p.ensureHeader()
|
||||
copy(*p, blankHeader)
|
||||
}
|
||||
|
||||
func (p *Packet) Size() int {
|
||||
return int(binary.LittleEndian.Uint16((*p)[0:2]))
|
||||
}
|
||||
|
||||
func (p *Packet) Protocol() int {
|
||||
return int(binary.LittleEndian.Uint16((*p)[2:3]) & 0b00001111_11111111)
|
||||
}
|
||||
|
||||
func (p *Packet) SetTagged(v bool) {
|
||||
if v {
|
||||
(*p)[3] |= uint8(0b00100000)
|
||||
} else {
|
||||
(*p)[3] &= ^uint8(0b00100000)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Packet) Source() uint32 {
|
||||
return binary.LittleEndian.Uint32((*p)[4:8])
|
||||
}
|
||||
|
||||
func (p *Packet) Target() net.HardwareAddr {
|
||||
return net.HardwareAddr((*p)[8:14])
|
||||
}
|
||||
|
||||
func (p *Packet) SetSource(v uint32) {
|
||||
binary.LittleEndian.PutUint32((*p)[4:8], v)
|
||||
}
|
||||
|
||||
func (p *Packet) CopyTargetFrom(other Packet) {
|
||||
copy((*p)[8:14], other[8:14])
|
||||
}
|
||||
|
||||
func (p *Packet) SetTarget(v string) error {
|
||||
addr, err := net.ParseMAC(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(addr) != 6 {
|
||||
return models.ErrInvalidAddress
|
||||
}
|
||||
|
||||
copy((*p)[8:], addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Packet) SetResRequired(v bool) {
|
||||
if v {
|
||||
(*p)[22] |= uint8(0b00000001)
|
||||
} else {
|
||||
(*p)[22] &= ^uint8(0b00000001)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Packet) SetAckRequired(v bool) {
|
||||
if v {
|
||||
(*p)[22] |= uint8(0b00000010)
|
||||
} else {
|
||||
(*p)[22] &= ^uint8(0b00000010)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Packet) Sequence() uint8 {
|
||||
return (*p)[23]
|
||||
}
|
||||
|
||||
func (p *Packet) SetSequence(v uint8) {
|
||||
(*p)[23] = v
|
||||
}
|
||||
|
||||
func (p *Packet) PacketType() uint16 {
|
||||
return binary.LittleEndian.Uint16((*p)[32:34])
|
||||
}
|
||||
|
||||
func (p *Packet) SetRawPayload(packetType uint16, data []byte) {
|
||||
p.ensureSize(36 + len(data))
|
||||
copy((*p)[36:], data)
|
||||
binary.LittleEndian.PutUint16(*p, uint16(36+len(data)))
|
||||
binary.LittleEndian.PutUint16((*p)[32:34], packetType)
|
||||
}
|
||||
|
||||
func (p *Packet) Payload() (res Payload, err error) {
|
||||
switch p.PacketType() {
|
||||
case 2:
|
||||
res = &GetService{}
|
||||
case 3:
|
||||
res = &StateService{}
|
||||
err = res.Decode((*p)[36:])
|
||||
case 14:
|
||||
res = &GetHostFirmware{}
|
||||
case 15:
|
||||
res = &StateHostFirmware{}
|
||||
err = res.Decode((*p)[36:])
|
||||
case 32:
|
||||
res = &GetVersion{}
|
||||
case 33:
|
||||
res = &StateVersion{}
|
||||
err = res.Decode((*p)[36:])
|
||||
case 45:
|
||||
res = &Acknowledgement{}
|
||||
case 101:
|
||||
res = &GetColor{}
|
||||
case 102:
|
||||
res = &SetColor{}
|
||||
err = res.Decode((*p)[36:])
|
||||
case 107:
|
||||
res = &LightState{}
|
||||
err = res.Decode((*p)[36:])
|
||||
case 117:
|
||||
res = &SetLightPower{}
|
||||
err = res.Decode((*p)[36:])
|
||||
default:
|
||||
err = models.ErrUnrecognizedPacketType
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
res = nil
|
||||
}
|
||||
|
||||
if res != nil && res.PacketType() != p.PacketType() {
|
||||
log.Println("BUG Incorrect packet type used in Packet.Payload")
|
||||
log.Println("BUG Payload Type:", p.PacketType())
|
||||
log.Println("BUG Panic Packet Type:", res.PacketType())
|
||||
log.Println("BUG Panic Payload:", res.String())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (p *Packet) SetPayload(payload Payload) {
|
||||
ackRequired, resRequired := payload.Flags()
|
||||
|
||||
p.SetRawPayload(payload.PacketType(), payload.Encode())
|
||||
p.SetAckRequired(ackRequired)
|
||||
p.SetResRequired(resRequired)
|
||||
}
|
||||
|
||||
func (p *Packet) ensureHeader() {
|
||||
p.ensureSize(36)
|
||||
}
|
||||
|
||||
func (p *Packet) ensureSize(size int) {
|
||||
if len(*p) < size {
|
||||
newData := make([]byte, size)
|
||||
copy(newData, *p)
|
||||
*p = newData
|
||||
}
|
||||
}
|
||||
|
||||
type Payload interface {
|
||||
fmt.Stringer
|
||||
Decode(data []byte) error
|
||||
Encode() []byte
|
||||
PacketType() uint16
|
||||
Flags() (ackRequired bool, resRequired bool)
|
||||
}
|
||||
|
||||
var blankHeader = []byte{
|
||||
0x24, 0x00, 0x00, 0x14,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package lifx
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GetService struct{}
|
||||
|
||||
func (p *GetService) Decode([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GetService) Encode() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func (p *GetService) PacketType() uint16 {
|
||||
return 2
|
||||
}
|
||||
|
||||
func (p *GetService) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
func (p *GetService) String() string {
|
||||
return "GetService()"
|
||||
}
|
||||
|
||||
type GetVersion struct{}
|
||||
|
||||
func (p *GetVersion) Decode([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GetVersion) Encode() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func (p *GetVersion) PacketType() uint16 {
|
||||
return 32
|
||||
}
|
||||
|
||||
func (p *GetVersion) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
func (p *GetVersion) String() string {
|
||||
return "GetVersion()"
|
||||
}
|
||||
|
||||
type GetHostFirmware struct{}
|
||||
|
||||
func (p *GetHostFirmware) Decode([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GetHostFirmware) Encode() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func (p *GetHostFirmware) PacketType() uint16 {
|
||||
return 14
|
||||
}
|
||||
|
||||
func (p *GetHostFirmware) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
func (p *GetHostFirmware) String() string {
|
||||
return "GetHostFirmware()"
|
||||
}
|
||||
|
||||
type StateHostFirmware struct {
|
||||
BuildTime time.Time
|
||||
Major uint16
|
||||
Minor uint16
|
||||
}
|
||||
|
||||
func (p *StateHostFirmware) Decode(data []byte) error {
|
||||
if len(data) < 20 {
|
||||
return models.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
ts := int64(binary.LittleEndian.Uint64(data[0:8]))
|
||||
|
||||
p.BuildTime = time.Unix(ts/1000000000, ts%1000000000)
|
||||
p.Major = binary.LittleEndian.Uint16(data[16:18])
|
||||
p.Minor = binary.LittleEndian.Uint16(data[18:20])
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StateHostFirmware) Encode() []byte {
|
||||
var data [20]byte
|
||||
|
||||
binary.LittleEndian.PutUint64(data[0:8], uint64(p.BuildTime.UnixNano()))
|
||||
binary.LittleEndian.PutUint16(data[16:18], p.Major)
|
||||
binary.LittleEndian.PutUint16(data[18:20], p.Minor)
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (p *StateHostFirmware) PacketType() uint16 {
|
||||
return 15
|
||||
}
|
||||
|
||||
func (p *StateHostFirmware) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (p *StateHostFirmware) String() string {
|
||||
return fmt.Sprintf("StateHostFirmware(major=%d, minor=%d, build=%s)", p.Major, p.Minor, p.BuildTime.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
type StateVersion struct {
|
||||
Vendor uint32
|
||||
Product uint32
|
||||
}
|
||||
|
||||
func (p *StateVersion) Decode(data []byte) error {
|
||||
if len(data) < 8 {
|
||||
return models.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
p.Vendor = binary.LittleEndian.Uint32(data[0:4])
|
||||
p.Product = binary.LittleEndian.Uint32(data[4:8])
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StateVersion) Encode() []byte {
|
||||
var data [12]byte
|
||||
|
||||
binary.LittleEndian.PutUint32(data[0:4], p.Vendor)
|
||||
binary.LittleEndian.PutUint32(data[4:8], p.Product)
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (p *StateVersion) PacketType() uint16 {
|
||||
return 33
|
||||
}
|
||||
|
||||
func (p *StateVersion) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (p *StateVersion) String() string {
|
||||
return fmt.Sprintf("StateVersion(vendor=%d, product=%d)", p.Vendor, p.Product)
|
||||
}
|
||||
|
||||
type GetColor struct{}
|
||||
|
||||
func (p *GetColor) Decode([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GetColor) Encode() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func (p *GetColor) PacketType() uint16 {
|
||||
return 101
|
||||
}
|
||||
|
||||
func (p *GetColor) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
func (p *GetColor) String() string {
|
||||
return "GetColor()"
|
||||
}
|
||||
|
||||
type Acknowledgement struct{}
|
||||
|
||||
func (p *Acknowledgement) Decode([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Acknowledgement) Encode() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func (p *Acknowledgement) PacketType() uint16 {
|
||||
return 45
|
||||
}
|
||||
|
||||
func (p *Acknowledgement) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
func (p *Acknowledgement) String() string {
|
||||
return "Acknowledgement()"
|
||||
}
|
||||
|
||||
type SetColor struct {
|
||||
Hue float64
|
||||
Sat float64
|
||||
Bri float64
|
||||
Kelvin int
|
||||
TransitionTime time.Duration
|
||||
}
|
||||
|
||||
func (p *SetColor) Decode(data []byte) error {
|
||||
if len(data) < 13 {
|
||||
return models.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
hue := binary.LittleEndian.Uint16(data[1:3])
|
||||
sat := binary.LittleEndian.Uint16(data[3:5])
|
||||
bri := binary.LittleEndian.Uint16(data[5:7])
|
||||
kelvin := binary.LittleEndian.Uint16(data[7:9])
|
||||
transitionMs := binary.LittleEndian.Uint32(data[9:13])
|
||||
|
||||
p.Hue = float64(hue) / (65536 / 360)
|
||||
p.Sat = float64(sat) / 65535
|
||||
p.Bri = float64(bri) / 65535
|
||||
p.Kelvin = int(kelvin)
|
||||
p.TransitionTime = time.Duration(transitionMs) * time.Millisecond
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *SetColor) Encode() []byte {
|
||||
var data [13]byte
|
||||
|
||||
data[0] = 0x00 // reserved
|
||||
binary.LittleEndian.PutUint16(data[1:3], uint16(math.Mod(p.Hue, 360)*(65536/360)))
|
||||
binary.LittleEndian.PutUint16(data[3:5], uint16(p.Sat*65535))
|
||||
binary.LittleEndian.PutUint16(data[5:7], uint16(p.Bri*65535))
|
||||
binary.LittleEndian.PutUint16(data[7:9], uint16(p.Kelvin))
|
||||
binary.LittleEndian.PutUint32(data[9:13], uint32(p.TransitionTime.Milliseconds()))
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (p *SetColor) PacketType() uint16 {
|
||||
return 102
|
||||
}
|
||||
|
||||
func (p *SetColor) Flags() (ackRequired bool, resRequired bool) {
|
||||
return true, false
|
||||
}
|
||||
|
||||
func (p *SetColor) String() string {
|
||||
return fmt.Sprintf("SetColor(hsvk={%f, %f, %f, %d}, ttime=%s)",
|
||||
p.Hue,
|
||||
p.Sat,
|
||||
p.Bri,
|
||||
p.Kelvin,
|
||||
p.TransitionTime,
|
||||
)
|
||||
}
|
||||
|
||||
type SetLightPower struct {
|
||||
On bool
|
||||
TransitionTime time.Duration
|
||||
}
|
||||
|
||||
func (p *SetLightPower) Decode(data []byte) error {
|
||||
if len(data) < 6 {
|
||||
return models.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
level := binary.LittleEndian.Uint16(data[0:2])
|
||||
transitionMs := binary.LittleEndian.Uint32(data[2:6])
|
||||
|
||||
p.On = level > 0
|
||||
p.TransitionTime = time.Duration(transitionMs) * time.Millisecond
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *SetLightPower) Encode() []byte {
|
||||
var data [6]byte
|
||||
|
||||
if p.On {
|
||||
data[0], data[1] = 0xff, 0xff
|
||||
} else {
|
||||
data[0], data[1] = 0x00, 0x00
|
||||
}
|
||||
binary.LittleEndian.PutUint32(data[2:6], uint32(p.TransitionTime.Milliseconds()))
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (p *SetLightPower) PacketType() uint16 {
|
||||
return 117
|
||||
}
|
||||
|
||||
func (p *SetLightPower) Flags() (ackRequired bool, resRequired bool) {
|
||||
return true, false
|
||||
}
|
||||
|
||||
func (p *SetLightPower) String() string {
|
||||
return fmt.Sprintf("SetLightPower(on=%t, ttime=%s)",
|
||||
p.On,
|
||||
p.TransitionTime,
|
||||
)
|
||||
}
|
||||
|
||||
type StateService struct {
|
||||
Service int
|
||||
Port int
|
||||
}
|
||||
|
||||
func (p *StateService) String() string {
|
||||
return fmt.Sprintf("StateService(service=%d, port=%d)", p.Service, p.Port)
|
||||
}
|
||||
|
||||
func (p *StateService) Decode(data []byte) error {
|
||||
if len(data) < 5 {
|
||||
return models.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
p.Service = int(data[0])
|
||||
p.Port = int(binary.LittleEndian.Uint32(data[1:5]))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StateService) Encode() []byte {
|
||||
var data [5]byte
|
||||
data[0] = uint8(p.Service)
|
||||
binary.LittleEndian.PutUint32(data[1:5], uint32(p.Port))
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (p *StateService) PacketType() uint16 {
|
||||
return 3
|
||||
}
|
||||
|
||||
func (p *StateService) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, false
|
||||
}
|
||||
|
||||
type LightState struct {
|
||||
Hue float64
|
||||
Sat float64
|
||||
Bri float64
|
||||
Kelvin int
|
||||
On bool
|
||||
Label string
|
||||
}
|
||||
|
||||
func (p *LightState) String() string {
|
||||
return fmt.Sprintf("LightState(hsvk=(%f, %f, %f, %d), on=%t, label=%+v)",
|
||||
p.Hue, p.Sat, p.Bri, p.Kelvin,
|
||||
p.On, p.Label,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *LightState) Decode(data []byte) error {
|
||||
if len(data) < 52 {
|
||||
return models.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
hue := binary.LittleEndian.Uint16(data[0:2])
|
||||
sat := binary.LittleEndian.Uint16(data[2:4])
|
||||
bri := binary.LittleEndian.Uint16(data[4:6])
|
||||
kelvin := binary.LittleEndian.Uint16(data[6:8])
|
||||
power := binary.LittleEndian.Uint16(data[10:12])
|
||||
|
||||
p.Hue = float64(hue) / (65536 / 360)
|
||||
p.Sat = float64(sat) / 65535
|
||||
p.Bri = float64(bri) / 65535
|
||||
p.Kelvin = int(kelvin)
|
||||
p.On = power > 32767
|
||||
|
||||
p.Label = ""
|
||||
for i, v := range data[14:46] {
|
||||
if v == 0 {
|
||||
p.Label = string(data[14 : 14+i])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LightState) Encode() []byte {
|
||||
var data [52]byte
|
||||
|
||||
power := uint16(0)
|
||||
if p.On {
|
||||
power = 0xffff
|
||||
}
|
||||
|
||||
binary.LittleEndian.PutUint16(data[0:2], uint16(math.Mod(p.Hue, 360)*(65536/360)))
|
||||
binary.LittleEndian.PutUint16(data[2:4], uint16(p.Sat*65535))
|
||||
binary.LittleEndian.PutUint16(data[4:6], uint16(p.Bri*65535))
|
||||
binary.LittleEndian.PutUint16(data[6:8], uint16(p.Kelvin))
|
||||
binary.LittleEndian.PutUint16(data[10:12], power)
|
||||
|
||||
label := p.Label
|
||||
labelBytes := []byte(label)
|
||||
for len(labelBytes) > 31 {
|
||||
label = label[:len(label)-1]
|
||||
labelBytes = []byte(label)
|
||||
}
|
||||
copy(data[14:], labelBytes)
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (p *LightState) PacketType() uint16 {
|
||||
return 107
|
||||
}
|
||||
|
||||
func (p *LightState) Flags() (ackRequired bool, resRequired bool) {
|
||||
return false, false
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,87 @@
|
||||
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
|
||||
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
|
||||
}
|
||||
@@ -50,12 +50,12 @@ func (b *bridge) Devices() []models.Device {
|
||||
models.DCButtons,
|
||||
},
|
||||
ButtonNames: []string{"Touch"},
|
||||
DriverProperties: map[string]string{
|
||||
"x": strconv.Itoa(panel.X),
|
||||
"y": strconv.Itoa(panel.Y),
|
||||
"o": strconv.Itoa(panel.O),
|
||||
DriverProperties: map[string]interface{}{
|
||||
"x": panel.X,
|
||||
"y": panel.Y,
|
||||
"o": panel.O,
|
||||
"shapeType": shapeTypeMap[panel.ShapeType],
|
||||
"shapeWidth": strconv.Itoa(shapeWidthMap[panel.ShapeType]),
|
||||
"shapeWidth": shapeWidthMap[panel.ShapeType],
|
||||
},
|
||||
UserProperties: nil,
|
||||
State: models.DeviceState{
|
||||
@@ -195,9 +195,9 @@ func (b *bridge) Run(ctx context.Context, info models.Bridge, ch chan<- models.E
|
||||
defer conn.Close()
|
||||
|
||||
// Notify connections and disconnections
|
||||
ch <- models.Event{Name: "BridgeConnected", Payload: map[string]string{"bridgeId": strconv.Itoa(info.ID)}}
|
||||
ch <- models.BridgeConnectedEvent(info)
|
||||
defer func() {
|
||||
ch <- models.Event{Name: "BridgeDisconnected", Payload: map[string]string{"bridgeId": strconv.Itoa(info.ID)}}
|
||||
ch <- models.BridgeDisconnectedEvent(info)
|
||||
}()
|
||||
|
||||
// Start touch listener. This one should go down together with this one, though, so it needs a new context.
|
||||
@@ -207,15 +207,29 @@ func (b *bridge) Run(ctx context.Context, info models.Bridge, ch chan<- models.E
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second * 5)
|
||||
hadErrors := false
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-ctx2.Done():
|
||||
}
|
||||
|
||||
err := b.updateEffect(ctx)
|
||||
reqTimeout, cancel := context.WithTimeout(ctx2, time.Second*4)
|
||||
err := b.updateEffect(reqTimeout)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Println("Failed to update effects:", err, "This error is non-fatal, and it will be retried shortly.")
|
||||
hadErrors = true
|
||||
} else if hadErrors {
|
||||
b.mu.Lock()
|
||||
for _, panel := range b.panels {
|
||||
panel.Stale = true
|
||||
panel.SlowUpdates = 3
|
||||
panel.TicksUntilSlowUpdate = 10
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
hadErrors = false
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -300,6 +314,12 @@ func (b *bridge) updateEffect(ctx context.Context) error {
|
||||
return models.ErrUnexpectedResponse
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
for _, panel := range b.panels {
|
||||
panel.Stale = true
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -373,7 +393,7 @@ func (b *bridge) runTouchListener(ctx context.Context, host, apiKey string, ch c
|
||||
}
|
||||
|
||||
event := models.Event{
|
||||
Name: "ButtonPressed",
|
||||
Name: models.ENButtonPressed,
|
||||
Payload: map[string]string{
|
||||
"buttonIndex": "0",
|
||||
"buttonName": "Touch",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ type BridgeRepo struct {
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return models.Bridge{}, dbErr(err)
|
||||
if err != nil {
|
||||
return models.Bridge{}, dbErr(err)
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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) Save(ctx context.Context, device *models.Device) error {
|
||||
tx, err := r.DBX.Beginx()
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
record := deviceRecord{
|
||||
ID: device.ID,
|
||||
BridgeID: device.BridgeID,
|
||||
InternalID: device.InternalID,
|
||||
Icon: device.Icon,
|
||||
Name: device.Name,
|
||||
Capabilities: strings.Join(models.DeviceCapabilitiesToStrings(device.Capabilities), ","),
|
||||
ButtonNames: strings.Join(device.ButtonNames, ","),
|
||||
}
|
||||
|
||||
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
|
||||
WHERE id=:id
|
||||
`, record)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
// Let's just be lazy for now, optimize later if need be.
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM device_property WHERE device_id=?", record.ID)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
} else {
|
||||
res, err := tx.NamedExecContext(ctx, `
|
||||
INSERT INTO device (bridge_id, internal_id, icon, name, capabilities, button_names)
|
||||
VALUES (:bridge_id, :internal_id, :icon, :name, :capabilities, :button_names)
|
||||
`, record)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
lastID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
record.ID = int(lastID)
|
||||
device.ID = int(lastID)
|
||||
}
|
||||
|
||||
for _, tag := range device.Tags {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO device_tag (device_id, tag_name) VALUES (?, ?)", record.ID, tag)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range device.UserProperties {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 1)",
|
||||
record.ID, key, value,
|
||||
)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range device.DriverProperties {
|
||||
j, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
// Eh, it'll get filled by the driver anyway
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 0)",
|
||||
record.ID, key, string(j),
|
||||
)
|
||||
if err != nil {
|
||||
// Return err here anyway, it might put the tx in a bad state to ignore it.
|
||||
return dbErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.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) 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,
|
||||
ButtonNames: strings.Split(record.ButtonNames, ","),
|
||||
DriverProperties: make(map[string]interface{}, 8),
|
||||
UserProperties: make(map[string]string, 8),
|
||||
Tags: make([]string, 0, 8),
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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"`
|
||||
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,
|
||||
}
|
||||
|
||||
if record.ID == 0 {
|
||||
res, err := r.DBX.NamedExecContext(ctx, `
|
||||
INSERT INTO event_handler (event_name, one_shot, priority, target_kind, target_value, conditions, actions)
|
||||
VALUES (:event_name, :one_shot, :priority, :target_kind, :target_value, :conditions, :actions);
|
||||
`, 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,
|
||||
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)
|
||||
}
|
||||
|
||||
res = append(res, handler)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type presetRecord struct {
|
||||
ID int `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Hue float64 `db:"hue"`
|
||||
Saturation float64 `db:"saturation"`
|
||||
Kelvin int `db:"kelvin"`
|
||||
}
|
||||
|
||||
type ColorPresetRepo struct {
|
||||
DBX *sqlx.DB
|
||||
}
|
||||
|
||||
func (c *ColorPresetRepo) Find(ctx context.Context, id int) (models.ColorPreset, error) {
|
||||
var record presetRecord
|
||||
err := c.DBX.GetContext(ctx, &record, "SELECT * FROM color_preset WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return models.ColorPreset{}, dbErr(err)
|
||||
}
|
||||
|
||||
return c.fromRecords(record)[0], nil
|
||||
}
|
||||
|
||||
func (c *ColorPresetRepo) FetchAll(ctx context.Context) ([]models.ColorPreset, error) {
|
||||
records := make([]presetRecord, 0, 16)
|
||||
err := c.DBX.SelectContext(ctx, &records, "SELECT * FROM color_preset")
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
return c.fromRecords(records...), nil
|
||||
}
|
||||
|
||||
func (c *ColorPresetRepo) Save(ctx context.Context, preset *models.ColorPreset) error {
|
||||
if preset.ID > 0 {
|
||||
_, err := c.DBX.ExecContext(
|
||||
ctx,
|
||||
"UPDATE color_preset SET name = ?, hue = ?, saturation = ?, kelvin = ? WHERE id = ?",
|
||||
preset.Name, preset.Value.Hue, preset.Value.Saturation, preset.Value.Kelvin, preset.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
} else {
|
||||
rs, err := c.DBX.ExecContext(
|
||||
ctx,
|
||||
"INSERT INTO color_preset (name, hue, saturation, kelvin) VALUES (?, ?, ?, ?)",
|
||||
preset.Name, preset.Value.Hue, preset.Value.Saturation, preset.Value.Kelvin,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
id, err := rs.LastInsertId()
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
preset.ID = int(id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ColorPresetRepo) Delete(ctx context.Context, preset *models.ColorPreset) error {
|
||||
_, err := c.DBX.ExecContext(ctx, "DELETE FROM color_preset WHERE id = ?", preset.ID)
|
||||
if err != nil {
|
||||
return dbErr(err)
|
||||
}
|
||||
|
||||
preset.ID = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ColorPresetRepo) fromRecords(records ...presetRecord) []models.ColorPreset {
|
||||
newList := make([]models.ColorPreset, len(records), len(records))
|
||||
for i, record := range records {
|
||||
newList[i] = models.ColorPreset{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
Value: models.ColorValue{
|
||||
Hue: record.Hue,
|
||||
Saturation: record.Saturation,
|
||||
Kelvin: record.Kelvin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return newList
|
||||
}
|
||||
+2
-1
@@ -21,7 +21,8 @@ type DriverKind string
|
||||
|
||||
var (
|
||||
DTHue DriverKind = "Hue"
|
||||
DTNanoLeaf DriverKind = "NanoLeaf"
|
||||
DTNanoLeaf DriverKind = "Nanoleaf"
|
||||
DTLIFX DriverKind = "LIFX"
|
||||
)
|
||||
|
||||
var ValidDriverKinds = []DriverKind{
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ColorPreset struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value ColorValue `json:"value"`
|
||||
}
|
||||
|
||||
type ColorPresetRepository interface {
|
||||
Find(ctx context.Context, id int) (ColorPreset, error)
|
||||
FetchAll(ctx context.Context) ([]ColorPreset, error)
|
||||
Save(ctx context.Context, preset *ColorPreset) error
|
||||
Delete(ctx context.Context, preset *ColorPreset) error
|
||||
}
|
||||
|
||||
func (c *ColorPreset) Validate() {
|
||||
c.Name = strings.Trim(c.Name, " \t\n")
|
||||
if len(c.Name) == 0 {
|
||||
c.Name = c.Value.String()
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -23,10 +23,10 @@ func (c *ColorValue) IsKelvin() bool {
|
||||
|
||||
func (c *ColorValue) String() string {
|
||||
if c.Kelvin > 0 {
|
||||
return fmt.Sprintf("kelvin:%d", c.Kelvin)
|
||||
return fmt.Sprintf("k:%d", c.Kelvin)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("hsv:%f,%f", c.Hue, c.Saturation)
|
||||
return fmt.Sprintf("hs:%g,%g", c.Hue, c.Saturation)
|
||||
}
|
||||
|
||||
func ParseColorValue(raw string) (ColorValue, error) {
|
||||
@@ -35,7 +35,7 @@ func ParseColorValue(raw string) (ColorValue, error) {
|
||||
return ColorValue{}, ErrBadInput
|
||||
}
|
||||
|
||||
if tokens[0] == "kelvin" {
|
||||
if tokens[0] == "kelvin" || tokens[0] == "k" {
|
||||
parsedPart, err := strconv.Atoi(tokens[1])
|
||||
if err != nil {
|
||||
return ColorValue{}, ErrBadInput
|
||||
@@ -59,5 +59,25 @@ func ParseColorValue(raw string) (ColorValue, error) {
|
||||
return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil
|
||||
}
|
||||
|
||||
if tokens[0] == "hsk" {
|
||||
parts := strings.Split(tokens[1], ",")
|
||||
if len(parts) < 3 {
|
||||
return ColorValue{}, ErrUnknownColorFormat
|
||||
}
|
||||
|
||||
part1, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
part2, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
part3, err3 := strconv.Atoi(parts[2])
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return ColorValue{}, ErrBadInput
|
||||
}
|
||||
|
||||
return ColorValue{
|
||||
Hue: math.Mod(part1, 360),
|
||||
Saturation: math.Min(math.Max(part2, 0), 1),
|
||||
Kelvin: part3,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return ColorValue{}, ErrUnknownColorFormat
|
||||
}
|
||||
|
||||
+84
-27
@@ -6,17 +6,23 @@ import (
|
||||
)
|
||||
|
||||
type Device struct {
|
||||
ID int `json:"id"`
|
||||
BridgeID int `json:"bridgeID"`
|
||||
InternalID string `json:"internalId"`
|
||||
Icon string `json:"icon"`
|
||||
Name string `json:"name"`
|
||||
Capabilities []DeviceCapability `json:"capabilities"`
|
||||
ButtonNames []string `json:"buttonNames"`
|
||||
DriverProperties map[string]string `json:"driverProperties"`
|
||||
UserProperties map[string]string `json:"userProperties"`
|
||||
State DeviceState `json:"state"`
|
||||
Tags []string `json:"tags"`
|
||||
ID int `json:"id"`
|
||||
BridgeID int `json:"bridgeID"`
|
||||
InternalID string `json:"internalId"`
|
||||
Icon string `json:"icon"`
|
||||
Name string `json:"name"`
|
||||
Capabilities []DeviceCapability `json:"capabilities"`
|
||||
ButtonNames []string `json:"buttonNames"`
|
||||
DriverProperties map[string]interface{} `json:"driverProperties"`
|
||||
UserProperties map[string]string `json:"userProperties"`
|
||||
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
|
||||
@@ -32,28 +38,40 @@ type DeviceState struct {
|
||||
}
|
||||
|
||||
type NewDeviceState struct {
|
||||
Power *bool `json:"power"`
|
||||
Color *string `json:"color"`
|
||||
Intensity int `json:"intensity"`
|
||||
Temperature int `json:"temperature"`
|
||||
Power *bool `json:"power"`
|
||||
Color *string `json:"color"`
|
||||
Intensity *float64 `json:"intensity"`
|
||||
Temperature *int `json:"temperature"`
|
||||
}
|
||||
|
||||
type DeviceCapability string
|
||||
|
||||
type DeviceRepository interface {
|
||||
FindByID(ctx context.Context, id int) (*Device, error)
|
||||
Find(ctx context.Context, id int) (*Device, error)
|
||||
FetchByReference(ctx context.Context, kind ReferenceKind, value string) ([]Device, error)
|
||||
Save(ctx context.Context, device *Device) error
|
||||
Delete(ctx context.Context, device *Device) error
|
||||
}
|
||||
|
||||
func DeviceCapabilitiesToStrings(caps []DeviceCapability) []string {
|
||||
res := make([]string, 0, len(caps))
|
||||
for _, cap := range caps {
|
||||
res = append(res, string(cap))
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
var (
|
||||
DCPower DeviceCapability = "Power"
|
||||
DCColorHS DeviceCapability = "ColorHS"
|
||||
DCColorKelvin DeviceCapability = "ColorKelvin"
|
||||
DCButtons DeviceCapability = "Buttons"
|
||||
DCIntensity DeviceCapability = "Intensity"
|
||||
DCTemperature DeviceCapability = "Temperature"
|
||||
DCPower DeviceCapability = "Power"
|
||||
DCColorHS DeviceCapability = "ColorHS"
|
||||
DCColorHSK DeviceCapability = "ColorHSK"
|
||||
DCColorKelvin DeviceCapability = "ColorKelvin"
|
||||
DCButtons DeviceCapability = "Buttons"
|
||||
DCPresence DeviceCapability = "Presence"
|
||||
DCIntensity DeviceCapability = "Intensity"
|
||||
DCTemperatureControl DeviceCapability = "TemperatureControl"
|
||||
DCTemperatureSensor DeviceCapability = "TemperatureSensor"
|
||||
)
|
||||
|
||||
var Capabilities = []DeviceCapability{
|
||||
@@ -61,8 +79,29 @@ var Capabilities = []DeviceCapability{
|
||||
DCColorHS,
|
||||
DCColorKelvin,
|
||||
DCButtons,
|
||||
DCPresence,
|
||||
DCIntensity,
|
||||
DCTemperature,
|
||||
DCTemperatureControl,
|
||||
DCTemperatureSensor,
|
||||
}
|
||||
|
||||
func (d *Device) ApplyUpdate(update DeviceUpdate) {
|
||||
if update.Name != nil {
|
||||
d.Name = *update.Name
|
||||
}
|
||||
if update.Icon != nil {
|
||||
d.Icon = *update.Icon
|
||||
}
|
||||
if d.UserProperties == nil {
|
||||
d.UserProperties = make(map[string]string)
|
||||
}
|
||||
for key, value := range update.UserProperties {
|
||||
if value != nil {
|
||||
d.UserProperties[key] = *value
|
||||
} else {
|
||||
delete(d.UserProperties, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Device) Validate() error {
|
||||
@@ -85,10 +124,24 @@ func (d *Device) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Device) HasCapability(capacity DeviceCapability) bool {
|
||||
func (d *Device) HasTag(tags ...string) bool {
|
||||
for _, c := range d.Tags {
|
||||
for _, c2 := range tags {
|
||||
if c == c2 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *Device) HasCapability(capabilities ...DeviceCapability) bool {
|
||||
for _, c := range d.Capabilities {
|
||||
if c == capacity {
|
||||
return true
|
||||
for _, c2 := range capabilities {
|
||||
if c == c2 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,10 +159,14 @@ func (d *Device) SetState(newState NewDeviceState) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if (parsed.IsKelvin() && d.HasCapability(DCColorKelvin)) || (parsed.IsHueSat() && d.HasCapability(DCColorHS)) {
|
||||
if (parsed.IsKelvin() && d.HasCapability(DCColorKelvin, DCColorHSK)) || (parsed.IsHueSat() && d.HasCapability(DCColorHS)) {
|
||||
d.State.Color = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if newState.Intensity != nil && d.HasCapability(DCIntensity) {
|
||||
d.State.Intensity = *newState.Intensity
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,8 +8,17 @@ var ErrBadInput = errors.New("bad input")
|
||||
var ErrBadColor = errors.New("bad color")
|
||||
var ErrInternal = errors.New("internal")
|
||||
var ErrUnknownColorFormat = errors.New("unknown color format")
|
||||
var ErrUnknownDriver = errors.New("unknown driver")
|
||||
|
||||
var ErrMissingToken = errors.New("driver is missing authentication information")
|
||||
var ErrIncorrectToken = errors.New("driver is not accepting authentication information")
|
||||
var ErrUnexpectedResponse = errors.New("driver api returned unexpected response (wrong driver selected?)")
|
||||
var ErrBridgeSearchFailed = errors.New("bridge search failed")
|
||||
var ErrAddressOnlyDryRunnable = errors.New("this address may only be used for a dry run")
|
||||
|
||||
var 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")
|
||||
|
||||
+19
-2
@@ -19,9 +19,26 @@ func (e *Event) HasPayload(key string) bool {
|
||||
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 {
|
||||
e := Event{Name: "BridgeConnected"}
|
||||
e.AddPayload("id", strconv.Itoa(bridge.ID))
|
||||
e := Event{Name: ENBridgeConnected}
|
||||
e.AddPayload("bridgeId", strconv.Itoa(bridge.ID))
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func BridgeDisconnectedEvent(bridge Bridge) Event {
|
||||
e := Event{Name: ENBridgeDisconnected}
|
||||
e.AddPayload("bridgeId", strconv.Itoa(bridge.ID))
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
+121
-20
@@ -11,15 +11,18 @@ type EventHandler struct {
|
||||
ID int `json:"id"`
|
||||
EventName string `json:"eventName"`
|
||||
Conditions map[string]EventCondition `json:"conditions"`
|
||||
TargetKind ReferenceKind `json:"targetType"`
|
||||
OneShot bool `json:"oneShot"`
|
||||
Priority int `json:"priority"`
|
||||
TargetKind ReferenceKind `json:"targetKind"`
|
||||
TargetValue string `json:"targetValue"`
|
||||
Actions EventAction `json:"actions"`
|
||||
}
|
||||
|
||||
type EventHandlerRepository interface {
|
||||
FindByID(ctx context.Context, id int) (EventHandler, error)
|
||||
FindByID(ctx context.Context, id int) (*EventHandler, error)
|
||||
FetchAll(ctx context.Context) ([]EventHandler, error)
|
||||
Save(ctx context.Context, handler *EventHandler)
|
||||
Delete(ctx context.Context, handler *EventHandler)
|
||||
Save(ctx context.Context, handler *EventHandler) error
|
||||
Delete(ctx context.Context, handler *EventHandler) error
|
||||
}
|
||||
|
||||
type EventCondition struct {
|
||||
@@ -30,13 +33,62 @@ type EventCondition struct {
|
||||
LTE string `json:"lte,omitempty"`
|
||||
}
|
||||
|
||||
type EventHandlerUpdate struct {
|
||||
SetEventName *string `json:"setEventName"`
|
||||
SetPriority *int `json:"setPriority"`
|
||||
SetTargetKind *ReferenceKind `json:"setTargetKind"`
|
||||
SetTargetValue *string `json:"setTargetValue"`
|
||||
SetOneShot *bool `json:"setOneShot"`
|
||||
SetConditions map[string]EventCondition `json:"setConditions"`
|
||||
PatchConditions map[string]*EventCondition `json:"patchConditions"`
|
||||
SetActions *EventAction `json:"setActions"`
|
||||
}
|
||||
|
||||
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.SetConditions != nil {
|
||||
h.Conditions = update.SetConditions
|
||||
}
|
||||
if update.PatchConditions != nil {
|
||||
if h.Conditions == nil {
|
||||
h.Conditions = make(map[string]EventCondition)
|
||||
}
|
||||
|
||||
for key, value := range update.PatchConditions {
|
||||
if value == nil {
|
||||
delete(h.Conditions, key)
|
||||
} else {
|
||||
h.Conditions[key] = *value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *EventHandler) MatchesEvent(event Event, targets []Device) bool {
|
||||
if event.Name != h.EventName {
|
||||
return false
|
||||
}
|
||||
|
||||
for key, condition := range h.Conditions {
|
||||
if !event.HasPayload(key) {
|
||||
if !strings.ContainsRune(key, '.') && !event.HasPayload(key) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -49,35 +101,58 @@ func (h *EventHandler) MatchesEvent(event Event, targets []Device) bool {
|
||||
}
|
||||
|
||||
func (c *EventCondition) check(key, value string, targets []Device) bool {
|
||||
any := strings.Index(key, "any.") == 0
|
||||
all := strings.Index(key, "all.") == 0
|
||||
if any || all && len(key) > 4 {
|
||||
any := strings.HasPrefix(key, "any.")
|
||||
all := strings.HasPrefix(key, "all.")
|
||||
if any || all {
|
||||
count := 0
|
||||
total := 0
|
||||
for _, target := range targets {
|
||||
if c.checkDevice(key[4:], target) {
|
||||
matches, skip := c.checkDevice(key[4:], target)
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
|
||||
if matches {
|
||||
count++
|
||||
}
|
||||
total++
|
||||
}
|
||||
|
||||
return (any && count > 0) || (all && count == len(targets))
|
||||
return (any && count > 0) || (all && count == total)
|
||||
}
|
||||
|
||||
return c.matches(value)
|
||||
}
|
||||
|
||||
func (c *EventCondition) checkDevice(key string, device Device) bool {
|
||||
func (c *EventCondition) checkDevice(key string, device Device) (matches bool, skip bool) {
|
||||
switch key {
|
||||
case "power":
|
||||
return c.matches(strconv.FormatBool(device.State.Power))
|
||||
case "color":
|
||||
return c.matches(device.State.Color.String())
|
||||
case "intensity":
|
||||
return c.matches(strconv.FormatFloat(device.State.Intensity, 'f', -1, 64))
|
||||
case "temperature":
|
||||
return c.matches(strconv.FormatFloat(device.State.Temperature, 'f', -1, 64))
|
||||
}
|
||||
if !device.HasCapability(DCPower) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
return false
|
||||
return c.matches(strconv.FormatBool(device.State.Power)), false
|
||||
case "color":
|
||||
if !device.HasCapability(DCColorKelvin, DCColorHS, DCColorHSK) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
return c.matches(device.State.Color.String()), false
|
||||
case "intensity":
|
||||
if !device.HasCapability(DCIntensity) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
return c.matches(strconv.FormatFloat(device.State.Intensity, 'f', -1, 64)), false
|
||||
case "temperature":
|
||||
if !device.HasCapability(DCTemperatureControl, DCTemperatureSensor) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
return c.matches(strconv.FormatFloat(device.State.Temperature, 'f', -1, 64)), false
|
||||
default:
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
|
||||
var numRegex = regexp.MustCompile("^{-[0-9].}+$")
|
||||
@@ -119,3 +194,29 @@ func (c *EventCondition) matches(value string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type EventAction struct {
|
||||
SetPower *bool `json:"setPower"`
|
||||
SetColor *string `json:"setColor"`
|
||||
SetIntensity *float64 `json:"setIntensity"`
|
||||
AddIntensity *float64 `json:"addIntensity"`
|
||||
FireEvent *Event `json:"fireEvent"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -3,9 +3,9 @@ package models
|
||||
type ReferenceKind string
|
||||
|
||||
var (
|
||||
RTDeviceID ReferenceKind = "DeviceID"
|
||||
RTBridgeID ReferenceKind = "BridgeID"
|
||||
RTTag ReferenceKind = "Tag"
|
||||
RKDeviceID ReferenceKind = "DeviceID"
|
||||
RKBridgeID ReferenceKind = "BridgeID"
|
||||
RKTag ReferenceKind = "Tag"
|
||||
RKAll ReferenceKind = "All"
|
||||
RKName ReferenceKind = "Name"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ CREATE TABLE device
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
bridge_id INT NOT NULL,
|
||||
internal_id VARCHAR(255) NOT NULL,
|
||||
icon CHAR NOT NULL,
|
||||
icon VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
capabilities VARCHAR(255) NOT NULL,
|
||||
button_names VARCHAR(255) NOT NULL,
|
||||
|
||||
PRIMARY KEY (id)
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE (bridge_id, internal_id)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ CREATE TABLE device_state
|
||||
saturation DOUBLE NOT NULL,
|
||||
kelvin INT NOT NULL,
|
||||
power TINYINT NOT NULL,
|
||||
intensity INT NOT NULL,
|
||||
intensity DOUBLE NOT NULL,
|
||||
|
||||
PRIMARY KEY (device_id)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE color_preset
|
||||
(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
hue DOUBLE NOT NULL,
|
||||
saturation DOUBLE NOT NULL,
|
||||
kelvin INT NOT NULL,
|
||||
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE color_preset;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE color_preset
|
||||
ADD COLUMN name VARCHAR(255) NOT NULL DEFAULT 'Unnamed';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE color_preset
|
||||
DROP COLUMN name;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE device_tag
|
||||
(
|
||||
device_id INT NOT NULL,
|
||||
tag_name VARCHAR(255) NOT NULL,
|
||||
|
||||
PRIMARY KEY (device_id, tag_name),
|
||||
INDEX (tag_name)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE device_tag;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,21 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE event_handler
|
||||
(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
event_name VARCHAR(255) NOT NULL,
|
||||
one_shot TINYINT NOT NULL DEFAULT 0,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
target_kind VARCHAR(255) NOT NULL,
|
||||
target_value VARCHAR(255) NOT NULL,
|
||||
conditions JSON NOT NULL,
|
||||
actions JSON NOT NULL,
|
||||
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE event_handler;
|
||||
-- +goose StatementEnd
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
node_modules/
|
||||
node_modules/
|
||||
yarn.error.log
|
||||
|
||||
+9
-1
@@ -3,6 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@iconify-icons/mdi": "^1.1.15",
|
||||
"@iconify/react": "^1.1.4",
|
||||
"@jaames/iro": "^5.5.1",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
@@ -10,11 +13,15 @@
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"axios": "^0.21.1",
|
||||
"hookrouter": "^1.2.5",
|
||||
"kelvin-to-rgb": "^1.0.2",
|
||||
"node-sass": "4.14.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-modal": "^3.14.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"react-semantic-ui-range": "^0.7.1",
|
||||
"typescript": "^4.1.2",
|
||||
"web-vitals": "^1.0.1"
|
||||
},
|
||||
@@ -43,6 +50,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/hookrouter": "^2.2.5"
|
||||
"@types/hookrouter": "^2.2.5",
|
||||
"@types/react-modal": "^3.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
+48
-7
@@ -1,11 +1,38 @@
|
||||
import React from 'react';
|
||||
import React, {useContext, useLayoutEffect} from 'react';
|
||||
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 = {
|
||||
"/": () => <div>1</div>,
|
||||
"/devices": () => <div>2</div>,
|
||||
"/settings": () => <div>3</div>,
|
||||
"/": () => (
|
||||
<Page>
|
||||
<h1> </h1>
|
||||
<h1>Enheter</h1>
|
||||
</Page>
|
||||
),
|
||||
"/devices": () => (
|
||||
<div>
|
||||
<IconElement icon={LuciferIcon.Hexagon} caption="Grønn" color={{h: 125, s: 0.5}}/>
|
||||
<IconElement icon={LuciferIcon.Square} caption="Kelvin 1k" color={{kelvin: 1000}}/>
|
||||
<IconElement icon={LuciferIcon.Bulb} caption="Kelvin 2k" color={{kelvin: 2000}}/>
|
||||
<IconElement icon={LuciferIcon.BulbGroup} caption="Kelvin 3k" color={{kelvin: 3000}}/>
|
||||
<IconElement icon={LuciferIcon.Bridge} caption="Kelvin 4k" color={{kelvin: 4000}}/>
|
||||
<IconElement icon={LuciferIcon.Cog} caption="Kelvin 5k" color={{kelvin: 5000}}/>
|
||||
<IconElement icon={LuciferIcon.Switch} caption="Kelvin 6k" color={{kelvin: 6000}}/>
|
||||
<IconElement icon={LuciferIcon.Triangle} caption="Kelvin 7k" color={{kelvin: 7000}}/>
|
||||
<IconElement icon={LuciferIcon.HexagonGroup} caption="Kelvin 8k" color={{kelvin: 8000}}/>
|
||||
<IconElement icon={LuciferIcon.Router} caption="Kelvin 9k" color={{kelvin: 9000}}/>
|
||||
<IconElement icon={LuciferIcon.Check} caption="Kelvin 10k" color={{kelvin: 10000}}/>
|
||||
<IconElement icon={LuciferIcon.Cross} caption="Kelvin 10k" color={{kelvin: 10000}}/>
|
||||
<IconElement caption="Mer" color="hs-gradient"/>
|
||||
<IconElement caption="Mer" color="k-gradient"/>
|
||||
</div>
|
||||
),
|
||||
"/settings": () => <SettingsPage/>,
|
||||
}
|
||||
|
||||
const routeList = ["/", "/devices", "/settings"];
|
||||
@@ -13,17 +40,31 @@ const routeList = ["/", "/devices", "/settings"];
|
||||
const tabNames = ["Lucifer", "Enheter", "Oppsett"];
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DialogContextProvider>
|
||||
<AppInContext/>
|
||||
</DialogContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppInContext() {
|
||||
const route = useRoutes(routeObj);
|
||||
const path = usePath();
|
||||
const {dialog} = useContext(DialogContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ReactModal.setAppElement("#root");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<Tabs tabNames={tabNames}
|
||||
index={routeList.indexOf(path)}
|
||||
onChange={i => navigate(routeList[i])}
|
||||
boldIndex={0}
|
||||
large boldIndex={0}
|
||||
/>
|
||||
{route || <div>B</div>}
|
||||
{route || <Page>404</Page>}
|
||||
{makeDialog(dialog)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {ColorPreset} from "../models/Colors";
|
||||
import {deleteRequest, getRequest, postRequest, putRequest} from "../helpers/rest";
|
||||
|
||||
export function listColorPresets(): Promise<ColorPreset[]> {
|
||||
return getRequest("/color-presets");
|
||||
}
|
||||
|
||||
export function addColorPreset(name: string, colorString: string) {
|
||||
return postRequest("/color-presets", {name, colorString});
|
||||
}
|
||||
|
||||
export function editColorPreset(id: number, name: string, colorString: string) {
|
||||
return putRequest(`/color-presets/${id}`, {name, colorString});
|
||||
}
|
||||
|
||||
export function deleteColorPreset(id: number) {
|
||||
return deleteRequest(`/color-presets/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, {createContext, useCallback, useEffect, useState} from "react";
|
||||
import {ColorPreset} from "../models/Colors";
|
||||
import {DataTarget} from "../models/Shared";
|
||||
import {unimplemented} from "../helpers/misc";
|
||||
import {listColorPresets} from "../actions/ColorPresets";
|
||||
|
||||
interface DataContextValue {
|
||||
colorPresets: ColorPreset[]
|
||||
|
||||
ready(target?: DataTarget): boolean
|
||||
|
||||
refresh(target?: DataTarget): void
|
||||
}
|
||||
|
||||
const DataContext = createContext<DataContextValue>({
|
||||
colorPresets: [],
|
||||
ready: unimplemented,
|
||||
refresh: unimplemented,
|
||||
});
|
||||
|
||||
export const DataContextProvider: React.FC = ({children}) => {
|
||||
const [colorPresets, setColorPresets] = useState<ColorPreset[]>([]);
|
||||
|
||||
const [readyMap, setReadyMap] = useState<{ [key: string]: boolean }>({
|
||||
[DataTarget.Default]: false,
|
||||
[DataTarget.ColorPresets]: false,
|
||||
});
|
||||
|
||||
const ready = useCallback((target: DataTarget = DataTarget.Default) => {
|
||||
return readyMap[target] || false;
|
||||
}, [readyMap]);
|
||||
|
||||
const refresh = useCallback((target: DataTarget = DataTarget.Default) => {
|
||||
const refreshing: Promise<any>[] = [];
|
||||
if ([DataTarget.Default, DataTarget.ColorPresets].includes(target)) {
|
||||
refreshing.push(listColorPresets().then(setColorPresets));
|
||||
}
|
||||
|
||||
Promise.all(refreshing).then(() => setReadyMap(prev => ({...prev, [target]: true})));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={{
|
||||
colorPresets,
|
||||
ready, refresh,
|
||||
}}>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataContext;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, {createContext, useCallback, useState} from "react";
|
||||
import {DialogConfig, DialogType} from "../models/Dialog";
|
||||
import {unimplemented} from "../helpers/misc";
|
||||
import ColorDialog from "../dialogs/ColorDialog";
|
||||
|
||||
interface DialogContextValue {
|
||||
dialog: DialogConfig,
|
||||
setDialog: (dialog: DialogConfig) => void,
|
||||
clearDialog: () => void,
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextValue>({
|
||||
dialog: {type: DialogType.None},
|
||||
setDialog: unimplemented,
|
||||
clearDialog: unimplemented,
|
||||
});
|
||||
|
||||
export const DialogContextProvider: React.FC = ({children}) => {
|
||||
const [dialog, setDialog] = useState<DialogConfig>({type: DialogType.None});
|
||||
|
||||
const clearDialog = useCallback(() => {
|
||||
setDialog({type: DialogType.None});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{dialog, setDialog, clearDialog}}>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function makeDialog(dialog: DialogConfig) {
|
||||
switch (dialog.type) {
|
||||
case DialogType.None:
|
||||
return undefined;
|
||||
case DialogType.AddColor:
|
||||
return <ColorDialog/>;
|
||||
}
|
||||
}
|
||||
|
||||
export default DialogContext;
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, {useCallback, useContext, useEffect, useState} from "react";
|
||||
import {ColorPreset} from "../models/Colors";
|
||||
import DialogContext from "../contexts/DialogContext";
|
||||
import {DialogType} from "../models/Dialog";
|
||||
import {Dialog} from "../primitives/Layout";
|
||||
import {LuciferIcon} from "../models/Icons";
|
||||
import {HSColorPicker, Input, KelvinColorPicker} from "../primitives/Forms";
|
||||
|
||||
interface ColorDialogProps {
|
||||
data?: ColorPreset
|
||||
}
|
||||
|
||||
enum ColorType {
|
||||
HueSat,
|
||||
Kelvin,
|
||||
HueSatKelvin,
|
||||
}
|
||||
|
||||
const COLOR_TYPE_OPTIONS = [
|
||||
{value: ColorType.HueSat, name: "Bare Nyanse (H) og metning (S)"},
|
||||
{value: ColorType.Kelvin, name: "Bare temperatur (K)"},
|
||||
{value: ColorType.HueSatKelvin, name: "Nyanse (H), metning (K) og kelvin (S)"},
|
||||
];
|
||||
|
||||
export default function ColorDialog({data}: ColorDialogProps) {
|
||||
const {setDialog, clearDialog} = useContext(DialogContext);
|
||||
|
||||
const [name, setName] = useState(data?.name || "");
|
||||
const [hue, setHue] = useState(data?.value?.h || 0);
|
||||
const [sat, setSat] = useState(data?.value?.s || 0);
|
||||
const [kelvin, setKelvin] = useState(data?.value?.kelvin || 0);
|
||||
|
||||
const [colorType, setColorType] = useState(COLOR_TYPE_OPTIONS[0].value);
|
||||
const [isInit, setIsInit] = useState(false);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
setDialog({type: DialogType.None});
|
||||
}, [setDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInit && data) {
|
||||
const v = data.value;
|
||||
|
||||
if (v.kelvin !== undefined && v.kelvin > 0) {
|
||||
setColorType((v.h || v.s) ? ColorType.HueSatKelvin : ColorType.Kelvin);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInit) {
|
||||
setIsInit(true);
|
||||
}
|
||||
}, [isInit, data, colorType]);
|
||||
|
||||
return (
|
||||
<Dialog title={data ? "Endre farge" : "Ny farge"} buttons={[
|
||||
{icon: LuciferIcon.Check, text: "Lagre", onClick: onSave},
|
||||
{icon: LuciferIcon.Cross, text: "Avbryt", onClick: clearDialog},
|
||||
]}>
|
||||
<Input label="Navn" value={name} onChange={setName}/>
|
||||
{colorType !== ColorType.Kelvin && (
|
||||
<HSColorPicker h={hue} s={sat} onChange={(h, s) => {
|
||||
setHue(h);
|
||||
setSat(s);
|
||||
}}/>
|
||||
)}
|
||||
{colorType !== ColorType.HueSat && (
|
||||
<KelvinColorPicker kelvin={kelvin} onChange={setKelvin}/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function unimplemented<T>(): T {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
@@ -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
@@ -3,10 +3,13 @@ import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
import {DataContextProvider} from "./contexts/DataContext";
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<DataContextProvider>
|
||||
<App/>
|
||||
</DataContextProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface ColorValue {
|
||||
h?: number
|
||||
s?: number
|
||||
kelvin?: number
|
||||
}
|
||||
|
||||
export interface ColorPreset {
|
||||
id: number
|
||||
name: string
|
||||
value: ColorValue
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export enum DialogType {
|
||||
None,
|
||||
AddColor,
|
||||
}
|
||||
|
||||
export type DialogConfig =
|
||||
| { type: DialogType.None }
|
||||
| { type: DialogType.AddColor }
|
||||
;
|
||||
@@ -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];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum DataTarget {
|
||||
Default = "",
|
||||
ColorPresets = "ColorPresets",
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
@import "../res/colors"
|
||||
|
||||
.Tabs-container
|
||||
background-color: $color-foreground-dark
|
||||
background-color: $color-background-elevated
|
||||
|
||||
&.Tabs-large
|
||||
text-align: center
|
||||
font-size: 175%
|
||||
|
||||
.Tabs-element
|
||||
display: inline-block
|
||||
@@ -14,4 +18,74 @@
|
||||
|
||||
.Tabs-bold
|
||||
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
|
||||
|
||||
@@ -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 {LuciferIcon} from "../models/Icons";
|
||||
import {IconBlock} from "./Elements";
|
||||
|
||||
interface TabsProps {
|
||||
tabNames: string[]
|
||||
index: number
|
||||
onChange: (newIndex: number) => void
|
||||
boldIndex?: number
|
||||
large?: boolean
|
||||
}
|
||||
|
||||
export function Tabs({tabNames, index, onChange, boldIndex}: TabsProps) {
|
||||
export function Tabs({tabNames, index, onChange, large, boldIndex}: TabsProps) {
|
||||
useEffect(() => {
|
||||
if (index < 0) {
|
||||
onChange(0);
|
||||
@@ -32,14 +36,67 @@ export function Tabs({tabNames, index, onChange, boldIndex}: TabsProps) {
|
||||
}, [index, boldIndex])
|
||||
|
||||
return (
|
||||
<div className="Tabs-container">
|
||||
{tabNames.map((name, i) => (
|
||||
<div className={tabClass(i)}
|
||||
onClick={() => onChange(i)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
<div className={`Tabs-container ${large ? "Tabs-large" : ""}`}>
|
||||
<div className="Tabs-inner">
|
||||
{tabNames.map((name, i) => (
|
||||
<div className={tabClass(i)}
|
||||
onClick={() => onChange(i)}
|
||||
>
|
||||
{name}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
$color-background: #111
|
||||
$color-foreground: rgb(238, 238, 238)
|
||||
$color-background-elevated: #222
|
||||
$color-background-elevated2: #333
|
||||
$color-foreground: rgb(204, 204, 204)
|
||||
$color-foreground-elevated: #FFF
|
||||
$color-foreground-dark: rgba(238, 238, 238, 0.05)
|
||||
|
||||
|
||||
body, html
|
||||
background-color: $color-background
|
||||
color: $color-foreground
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
const proxy = require("http-proxy-middleware");
|
||||
|
||||
let config = {url: "http://127.0.0.1:9000/"};
|
||||
|
||||
module.exports = function(app) {
|
||||
app.use(proxy('/api/ws', { target: config.url, ws: true }));
|
||||
app.use(proxy('/api', { target: config.url, ws: false }));
|
||||
};
|
||||
-11959
File diff suppressed because it is too large
Load Diff
+85
-1
@@ -1209,6 +1209,21 @@
|
||||
dependencies:
|
||||
"@hapi/hoek" "^8.3.0"
|
||||
|
||||
"@iconify-icons/mdi@^1.1.15":
|
||||
version "1.1.15"
|
||||
resolved "https://registry.yarnpkg.com/@iconify-icons/mdi/-/mdi-1.1.15.tgz#eff8e16d5095828e60bacc476c9facc993e8c301"
|
||||
integrity sha512-NozPlEOMiXwYheHet3u5ktu0o/GP+pCtta8kb2jGf0jSgOePirK73wj2u8QniZUot/xtT0KprvwXaBg/IFX2Og==
|
||||
|
||||
"@iconify/react@^1.1.4":
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@iconify/react/-/react-1.1.4.tgz#c778eddbaf196e55705d0bedff00d039dc1de8d3"
|
||||
integrity sha512-oxq8IMOq8q3nKGiDHbQPC8FcFuBsKve68JWBo140d5LRnj0bv5TB/FE/y01ZSvEZ7PlI2HIrnb2qivPN8kTDgw==
|
||||
|
||||
"@irojs/iro-core@^1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@irojs/iro-core/-/iro-core-1.2.0.tgz#3587c2db7a6de09f76dbf75b94605ac251039ca8"
|
||||
integrity sha512-RVxd4lEx3KPXRBYo7urLSr9C3PJsm3t9AgPQppP5LDqQcMrXCXcZoeneMtQndMk60k72NziNiPpbZlZIvcT4VQ==
|
||||
|
||||
"@istanbuljs/load-nyc-config@^1.0.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
|
||||
@@ -1225,6 +1240,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98"
|
||||
integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==
|
||||
|
||||
"@jaames/iro@^5.5.1":
|
||||
version "5.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@jaames/iro/-/iro-5.5.1.tgz#1935713fe3cca8c4eadcfc6013b6f846ac8be097"
|
||||
integrity sha512-HR2phfXjEINFKXxEdbGqp9/MfHfQ/xP+nLi2gjNV4RHB3tBOgHMpS1WFMU7fp1vl//vWXxKxVKa91D+5JoFgMA==
|
||||
dependencies:
|
||||
"@irojs/iro-core" "^1.2.0"
|
||||
preact "^10.0.0"
|
||||
|
||||
"@jest/console@^26.6.2":
|
||||
version "26.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@jest/console/-/console-26.6.2.tgz#4e04bc464014358b03ab4937805ee36a0aeb98f2"
|
||||
@@ -1806,6 +1829,13 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-modal@^3.12.0":
|
||||
version "3.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.12.0.tgz#91fa86a76fd7fc57e36d2cf9b76efe5735a855a1"
|
||||
integrity sha512-UnHu/YO73NZLwqFpju/c0tqiswR0UHIfeO16rkfLVJUIMfQsl7X0CBm99H5XXgBMe/PgtQ/Rkud72iuRBr1TeA==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^17.0.0":
|
||||
version "17.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.6.tgz#0ec564566302c562bf497d73219797a5e0297013"
|
||||
@@ -2570,6 +2600,13 @@ axe-core@^4.0.2:
|
||||
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.1.2.tgz#7cf783331320098bfbef620df3b3c770147bc224"
|
||||
integrity sha512-V+Nq70NxKhYt89ArVcaNL9FDryB3vQOd+BFXZIfO3RP6rwtj+2yqqqdHEkacutglPaZLkJeuXKCjCJDMGPtPqg==
|
||||
|
||||
axios@^0.21.1:
|
||||
version "0.21.1"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
|
||||
integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
|
||||
dependencies:
|
||||
follow-redirects "^1.10.0"
|
||||
|
||||
axobject-query@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
|
||||
@@ -4845,6 +4882,11 @@ execa@^4.0.0:
|
||||
signal-exit "^3.0.2"
|
||||
strip-final-newline "^2.0.0"
|
||||
|
||||
exenv@^1.2.0:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
|
||||
integrity sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=
|
||||
|
||||
exit@^0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
|
||||
@@ -5149,6 +5191,11 @@ follow-redirects@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.2.tgz#dd73c8effc12728ba5cf4259d760ea5fb83e3147"
|
||||
integrity sha512-6mPTgLxYm3r6Bkkg0vNM0HTjfGrOEtsfbhagQvbxDEsEkpNhw582upBaoRZylzen6krEmxXJgt9Ju6HiI4O7BA==
|
||||
|
||||
follow-redirects@^1.10.0:
|
||||
version "1.14.1"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43"
|
||||
integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==
|
||||
|
||||
for-in@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
|
||||
@@ -6983,6 +7030,11 @@ jsprim@^1.2.2:
|
||||
array-includes "^3.1.2"
|
||||
object.assign "^4.1.2"
|
||||
|
||||
kelvin-to-rgb@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/kelvin-to-rgb/-/kelvin-to-rgb-1.0.2.tgz#bc4990a833b0873d41ccfbd6475fd8a0b1abe09e"
|
||||
integrity sha1-vEmQqDOwhz1BzPvWR1/YoLGr4J4=
|
||||
|
||||
killable@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892"
|
||||
@@ -7189,7 +7241,7 @@ loglevel@^1.6.8:
|
||||
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197"
|
||||
integrity sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw==
|
||||
|
||||
loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||
@@ -9070,6 +9122,11 @@ postcss@^8.1.0:
|
||||
nanoid "^3.1.20"
|
||||
source-map "^0.6.1"
|
||||
|
||||
preact@^10.0.0:
|
||||
version "10.5.13"
|
||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.5.13.tgz#85f6c9197ecd736ce8e3bec044d08fd1330fa019"
|
||||
integrity sha512-q/vlKIGNwzTLu+jCcvywgGrt+H/1P/oIRSD6mV4ln3hmlC+Aa34C7yfPI4+5bzW8pONyVXYS7SvXosy2dKKtWQ==
|
||||
|
||||
prelude-ls@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
||||
@@ -9378,6 +9435,21 @@ react-is@^17.0.1:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339"
|
||||
integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==
|
||||
|
||||
react-lifecycles-compat@^3.0.0:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
|
||||
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
|
||||
|
||||
react-modal@^3.14.2:
|
||||
version "3.14.2"
|
||||
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.14.2.tgz#e04e1119d64c9fbb3db80736eb80ea9372f28778"
|
||||
integrity sha512-CYasEJanwneDsmvtx/fisXhgDxtt3I8jWTVX/tP9dM/J1NgDKU9lgjR9zuCCl33ub2jrTWhXyijCxCzYGN8sJg==
|
||||
dependencies:
|
||||
exenv "^1.2.0"
|
||||
prop-types "^15.7.2"
|
||||
react-lifecycles-compat "^3.0.0"
|
||||
warning "^4.0.3"
|
||||
|
||||
react-refresh@^0.8.3:
|
||||
version "0.8.3"
|
||||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
|
||||
@@ -9449,6 +9521,11 @@ react-scripts@4.0.3:
|
||||
optionalDependencies:
|
||||
fsevents "^2.1.3"
|
||||
|
||||
react-semantic-ui-range@^0.7.1:
|
||||
version "0.7.1"
|
||||
resolved "https://registry.yarnpkg.com/react-semantic-ui-range/-/react-semantic-ui-range-0.7.1.tgz#bc552f889e4243d500d4673dd7966752664935fe"
|
||||
integrity sha512-6AQLuFeARHcIO7yl4Pd0BBIB9sJyeNK1mku7luK6aTXrj9EGaqr1fWS7w4b0+GUP7CvkixmcyYqykxzpKS4Lcw==
|
||||
|
||||
react@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
||||
@@ -11442,6 +11519,13 @@ walker@^1.0.7, walker@~1.0.5:
|
||||
dependencies:
|
||||
makeerror "1.0.x"
|
||||
|
||||
warning@^4.0.3:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
|
||||
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
|
||||
dependencies:
|
||||
loose-envify "^1.0.0"
|
||||
|
||||
watchpack-chokidar2@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz#38500072ee6ece66f3769936950ea1771be1c957"
|
||||
|
||||
Reference in New Issue
Block a user