Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf903c1d71 | ||
|
|
19eb205ee3 | ||
|
|
edb45c74b6 | ||
|
|
c4b391db66 | ||
|
|
3bf8cbef2f | ||
|
|
5db753e401 | ||
|
|
2a750ae1f7 | ||
|
|
f1ed6da6d7 | ||
|
|
d5cf30ea4b | ||
|
|
582bf3f46b | ||
|
|
c72a7c3f43 | ||
|
|
8a20bab709 | ||
|
|
27fd858c04 | ||
|
|
d19a8e6ca0 | ||
|
|
107b29620d | ||
|
|
73e6cba770 | ||
|
|
44bd327a71 | ||
|
|
cedd0d1d28 | ||
|
|
7471102413 | ||
|
|
bf4d000455 | ||
|
|
6947fc3461 | ||
|
|
600c90a614 |
+63
-7
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/app/services/publisher"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -217,14 +219,28 @@ func Devices(r gin.IRoutes) {
|
||||
return []models.Device{}, nil
|
||||
}
|
||||
|
||||
_, err = config.SceneRepository().Find(ctxOf(c), body.SceneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var scene *models.Scene
|
||||
if body.SceneName != "" {
|
||||
scene, err = config.SceneRepository().FindName(ctxOf(c), body.SceneName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body.DurationMS < 0 {
|
||||
body.DurationMS = 0
|
||||
}
|
||||
body.StartTime = time.Now()
|
||||
body.SceneID = scene.ID
|
||||
} else {
|
||||
scene, err = config.SceneRepository().Find(ctxOf(c), body.SceneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body.DurationMS < 0 {
|
||||
body.DurationMS = 0
|
||||
}
|
||||
body.StartTime = time.Now()
|
||||
body.SceneName = scene.Name
|
||||
}
|
||||
if body.DurationMS < 0 {
|
||||
body.DurationMS = 0
|
||||
}
|
||||
body.StartTime = time.Now()
|
||||
|
||||
pushMode := c.Query("push") == "true"
|
||||
for i := range devices {
|
||||
@@ -265,6 +281,46 @@ func Devices(r gin.IRoutes) {
|
||||
|
||||
return withSceneState(devices), nil
|
||||
}))
|
||||
|
||||
r.DELETE("/:fetch", handler(func(c *gin.Context) (interface{}, error) {
|
||||
id, err := strconv.Atoi(c.Param("fetch"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
device, err := config.DeviceRepository().Find(ctxOf(c), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.Query("forgetmenot") != "true" {
|
||||
bridge, err := config.BridgeRepository().Find(ctxOf(c), device.BridgeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
forgettableDriver, ok := driver.(models.ForgettableDriver)
|
||||
if !ok {
|
||||
return nil, lerrors.ErrCannotForget
|
||||
}
|
||||
err = forgettableDriver.ForgetDevice(ctxOf(c), bridge, *device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
err = config.DeviceRepository().Delete(ctxOf(c), device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}))
|
||||
}
|
||||
|
||||
func withSceneState(devices []models.Device) []models.Device {
|
||||
|
||||
+4
-3
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -25,13 +26,13 @@ func ColorPresets(r gin.IRoutes) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newColor, err := models.ParseColorValue(body.ColorString)
|
||||
newColor, err := color.Parse(body.ColorString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
preset := models.ColorPreset{
|
||||
Name: body.Name,
|
||||
Name: body.Name,
|
||||
Value: newColor,
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ func ColorPresets(r gin.IRoutes) {
|
||||
}
|
||||
|
||||
if body.ColorString != nil {
|
||||
newColor, err := models.ParseColorValue(*body.ColorString)
|
||||
newColor, err := color.Parse(*body.ColorString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+14
-2
@@ -5,15 +5,27 @@ import (
|
||||
"git.aiterp.net/lucifer/new-server/app/services/publisher"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
func Scenes(r gin.IRoutes) {
|
||||
nonNumericRegex := regexp.MustCompile("[a-zA-Z ]")
|
||||
|
||||
findScene := func(c *gin.Context) (*models.Scene, error) {
|
||||
param := c.Param("id")
|
||||
if nonNumericRegex.MatchString(param) {
|
||||
return config.SceneRepository().FindName(ctxOf(c), param)
|
||||
} else {
|
||||
return config.SceneRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
}
|
||||
}
|
||||
|
||||
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return config.SceneRepository().FetchAll(ctxOf(c))
|
||||
}))
|
||||
|
||||
r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||
return config.SceneRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
return findScene(c)
|
||||
}))
|
||||
|
||||
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
|
||||
@@ -45,7 +57,7 @@ func Scenes(r gin.IRoutes) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scene, err := config.SceneRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||
scene, err := findScene(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+14
-14
@@ -3,25 +3,25 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var errorMap = map[error]int{
|
||||
models.ErrNotFound: 404,
|
||||
models.ErrInvalidName: 400,
|
||||
models.ErrBadInput: 400,
|
||||
models.ErrBadColor: 400,
|
||||
models.ErrInternal: 500,
|
||||
models.ErrUnknownColorFormat: 400,
|
||||
lerrors.ErrNotFound: 404,
|
||||
lerrors.ErrInvalidName: 400,
|
||||
lerrors.ErrBadInput: 400,
|
||||
lerrors.ErrBadColor: 400,
|
||||
lerrors.ErrInternal: 500,
|
||||
lerrors.ErrUnknownColorFormat: 400,
|
||||
|
||||
models.ErrSceneInvalidInterval: 400,
|
||||
models.ErrSceneNoRoles: 400,
|
||||
models.ErrSceneRoleNoStates: 400,
|
||||
models.ErrSceneRoleUnsupportedOrdering: 422,
|
||||
models.ErrSceneRoleUnknownEffect: 422,
|
||||
models.ErrSceneRoleUnknownPowerMode: 422,
|
||||
lerrors.ErrSceneInvalidInterval: 400,
|
||||
lerrors.ErrSceneNoRoles: 400,
|
||||
lerrors.ErrSceneRoleNoStates: 400,
|
||||
lerrors.ErrSceneRoleUnsupportedOrdering: 422,
|
||||
lerrors.ErrSceneRoleUnknownEffect: 422,
|
||||
lerrors.ErrSceneRoleUnknownPowerMode: 422,
|
||||
}
|
||||
|
||||
type response struct {
|
||||
@@ -66,7 +66,7 @@ func intParam(c *gin.Context, key string) int {
|
||||
func parseBody(c *gin.Context, target interface{}) error {
|
||||
err := json.NewDecoder(c.Request.Body).Decode(target)
|
||||
if err != nil {
|
||||
return models.ErrBadInput
|
||||
return lerrors.ErrBadInput
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
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/hue2"
|
||||
"git.aiterp.net/lucifer/new-server/internal/drivers/lifx"
|
||||
"git.aiterp.net/lucifer/new-server/internal/drivers/mill"
|
||||
"git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf"
|
||||
@@ -18,6 +19,7 @@ func DriverProvider() models.DriverProvider {
|
||||
dp = drivers.DriverMap{
|
||||
models.DTNanoLeaf: &nanoleaf.Driver{},
|
||||
models.DTHue: &hue.Driver{},
|
||||
models.DTHue2: &hue2.Driver{},
|
||||
models.DTLIFX: &lifx.Driver{},
|
||||
models.DTMill: &mill.Driver{},
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ func (p *Publisher) Publish(devices ...models.Device) {
|
||||
return
|
||||
}
|
||||
|
||||
unwaitMap := make(map[int]bool)
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
@@ -106,10 +108,14 @@ func (p *Publisher) Publish(devices ...models.Device) {
|
||||
}
|
||||
} else {
|
||||
p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device)
|
||||
if p.waiting[device.BridgeID] != nil {
|
||||
close(p.waiting[device.BridgeID])
|
||||
p.waiting[device.BridgeID] = nil
|
||||
}
|
||||
unwaitMap[device.BridgeID] = true
|
||||
}
|
||||
}
|
||||
|
||||
for bridgeID := range unwaitMap {
|
||||
if p.waiting[bridgeID] != nil {
|
||||
close(p.waiting[bridgeID])
|
||||
p.waiting[bridgeID] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,11 +130,16 @@ func (p *Publisher) Run() {
|
||||
ticker := time.NewTicker(time.Millisecond * 100)
|
||||
deleteList := make([]int, 0, 8)
|
||||
updatedList := make([]models.Device, 0, 16)
|
||||
unwaitMap := make(map[int]bool)
|
||||
|
||||
for range ticker.C {
|
||||
deleteList = deleteList[:0]
|
||||
updatedList = updatedList[:0]
|
||||
|
||||
for key := range unwaitMap {
|
||||
delete(unwaitMap, key)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
for i, scene := range p.scenes {
|
||||
if (!scene.endTime.IsZero() && time.Now().After(scene.endTime)) || scene.Empty() {
|
||||
@@ -160,9 +171,13 @@ func (p *Publisher) Run() {
|
||||
}
|
||||
|
||||
p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device)
|
||||
if p.waiting[device.BridgeID] != nil {
|
||||
close(p.waiting[device.BridgeID])
|
||||
p.waiting[device.BridgeID] = nil
|
||||
unwaitMap[device.BridgeID] = true
|
||||
}
|
||||
|
||||
for bridgeID := range unwaitMap {
|
||||
if p.waiting[bridgeID] != nil {
|
||||
close(p.waiting[bridgeID])
|
||||
p.waiting[bridgeID] = nil
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
@@ -270,8 +285,8 @@ func (p *Publisher) runBridge(id int) {
|
||||
p.mu.Lock()
|
||||
}
|
||||
|
||||
updates := p.pending[id]
|
||||
p.pending[id] = p.pending[id][:0:0]
|
||||
updates := append(p.pending[id][:0:0], p.pending[id]...)
|
||||
p.pending[id] = p.pending[id][:0]
|
||||
p.mu.Unlock()
|
||||
|
||||
// Only allow the latest update per device (this avoids slow bridges causing a backlog of cations).
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/app/config"
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"os"
|
||||
@@ -124,7 +125,7 @@ func main() {
|
||||
continue
|
||||
}
|
||||
|
||||
color, err := models.ParseColorValue(tokens[2])
|
||||
color, err := color.Parse(tokens[2])
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "Invalid color:", err)
|
||||
continue
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
||||
func main() {
|
||||
db := config.DBX().DB
|
||||
|
||||
log.Printf("Target version: %s",time.Now().Format("20060102150405"))
|
||||
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())
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ EVENT HANDLER FACTORY COMMANDS
|
||||
|
||||
factory run <file.yaml>
|
||||
|
||||
MISC COMMANDS
|
||||
|
||||
color <color>
|
||||
|
||||
File example (use '---' to delimit multiple configs in one file):
|
||||
mode: SceneDimmer
|
||||
target: tag:Soverom
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func WriteDeviceInfo(w io.Writer, devices []models.Device, fields []string) {
|
||||
if len(fields) == 1 && fields[0] == "all" {
|
||||
fields = allFields
|
||||
} else {
|
||||
fields = append(fields[:0:0], fields...)
|
||||
for i, field := range fields {
|
||||
if aliased, ok := fieldAliases[field]; ok {
|
||||
fields[i] = aliased
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
length := 0
|
||||
for _, field := range fields {
|
||||
if mlFields[field] {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(field) > length {
|
||||
length = len(field)
|
||||
}
|
||||
}
|
||||
|
||||
for i, device := range devices {
|
||||
if i > 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
for _, field := range fields {
|
||||
if formatter, ok := fieldFormatters[field]; ok {
|
||||
_, _ = fmt.Fprintf(w, "%-*s %s\n", length+1, field+":", formatter(&device))
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(w, "%-*s (unknown field)\n", length+1, field+":")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var allFields = []string{
|
||||
"id", "name", "icon", "bridgeId", "tags", "buttonNames",
|
||||
"power", "color", "intensity", "temperature",
|
||||
"internalId", "sceneAssignment", "userProperties", "driverProperties",
|
||||
}
|
||||
|
||||
var mlFields = map[string]bool{
|
||||
"sceneAssignment": true,
|
||||
"userProperties": true,
|
||||
"driverProperties": true,
|
||||
}
|
||||
|
||||
var fieldFormatters = map[string]func(d *models.Device) string{
|
||||
"id": func(d *models.Device) string { return fmt.Sprint(d.ID) },
|
||||
"internalId": func(d *models.Device) string { return d.InternalID },
|
||||
"name": func(d *models.Device) string { return d.Name },
|
||||
"icon": func(d *models.Device) string { return d.Icon },
|
||||
"bridgeId": func(d *models.Device) string { return fmt.Sprint(d.BridgeID) },
|
||||
"tags": func(d *models.Device) string { return strings.Join(d.Tags, ", ") },
|
||||
"buttonNames": func(d *models.Device) string { return strings.Join(d.ButtonNames, ", ") },
|
||||
"power": func(d *models.Device) string { return fmt.Sprint(d.State.Power) },
|
||||
"color": func(d *models.Device) string { return d.State.Color.String() },
|
||||
"intensity": func(d *models.Device) string { return fmt.Sprint(d.State.Intensity) },
|
||||
"temperature": func(d *models.Device) string { return fmt.Sprint(d.State.Temperature) },
|
||||
|
||||
"sceneAssignment": func(d *models.Device) string {
|
||||
str := &strings.Builder{}
|
||||
str.Grow(64)
|
||||
for _, scene := range d.SceneAssignments {
|
||||
_, _ = fmt.Fprintf(str, "\n - id: %d\n", scene.SceneID)
|
||||
_, _ = fmt.Fprintf(str, " duration: %d\n", scene.DurationMS)
|
||||
_, _ = fmt.Fprintf(str, " group: %s\n", scene.Group)
|
||||
_, _ = fmt.Fprintf(str, " startTime: %s", scene.StartTime.Format(time.RFC3339))
|
||||
}
|
||||
if len(d.SceneAssignments) == 0 {
|
||||
str.Reset()
|
||||
str.WriteString("(none)")
|
||||
}
|
||||
|
||||
return str.String()
|
||||
},
|
||||
|
||||
"userProperties": func(d *models.Device) string {
|
||||
str := &strings.Builder{}
|
||||
str.Grow(64)
|
||||
str.WriteString("\n")
|
||||
keys := make([]string, 0, len(d.UserProperties))
|
||||
for key := range d.UserProperties {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
_, _ = fmt.Fprintf(str, " %s: %s\n", key, d.UserProperties[key])
|
||||
}
|
||||
if len(d.UserProperties) == 0 {
|
||||
str.Reset()
|
||||
str.WriteString("(none)")
|
||||
}
|
||||
|
||||
return str.String()
|
||||
},
|
||||
|
||||
"driverProperties": func(d *models.Device) string {
|
||||
str := &strings.Builder{}
|
||||
str.Grow(256)
|
||||
str.WriteString("\n")
|
||||
keys := make([]string, 0, len(d.DriverProperties))
|
||||
for key := range d.DriverProperties {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
j, _ := json.Marshal(d.DriverProperties[key])
|
||||
_, _ = fmt.Fprintf(str, " %s: %s\n", key, string(j))
|
||||
}
|
||||
if len(d.DriverProperties) == 0 {
|
||||
str.Reset()
|
||||
str.WriteString("(none)")
|
||||
}
|
||||
|
||||
return str.String()
|
||||
},
|
||||
|
||||
"capabilities": func(d *models.Device) string {
|
||||
capStr := make([]string, len(d.Capabilities))
|
||||
for i := range d.Capabilities {
|
||||
capStr[i] = string(d.Capabilities[i])
|
||||
}
|
||||
return strings.Join(capStr, ", ")
|
||||
},
|
||||
}
|
||||
|
||||
var fieldAliases = map[string]string{
|
||||
"bridge": "bridgeId",
|
||||
"buttons": "buttonNames",
|
||||
"internal": "internalId",
|
||||
"caps": "capabilities",
|
||||
"cap": "capabilities",
|
||||
"userprops": "userProperties",
|
||||
"uprops": "userProperties",
|
||||
"driverprops": "driverProperties",
|
||||
"dprops": "driverProperties",
|
||||
"internalid": "internalId",
|
||||
"bridgeid": "bridgeId",
|
||||
"buttonnames": "buttonNames",
|
||||
"scene": "sceneAssignment",
|
||||
}
|
||||
+26
-2
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/app/client"
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"os"
|
||||
@@ -37,8 +38,8 @@ func main() {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
if cmd.Params.Get("info").StringOr("false") == "true" {
|
||||
WriteDeviceInfoTable(os.Stdout, devices)
|
||||
if info := cmd.Params.Get("info").String(); info != nil {
|
||||
WriteDeviceInfo(os.Stdout, devices, strings.Split(*info, ","))
|
||||
} else {
|
||||
WriteDeviceStateTable(os.Stdout, devices)
|
||||
}
|
||||
@@ -92,6 +93,29 @@ func main() {
|
||||
|
||||
WriteDeviceInfoTable(os.Stdout, devices)
|
||||
}
|
||||
case "color":
|
||||
{
|
||||
col, err := color.Parse(cmd.Params.Get(0).StringOr("hs:0,0"))
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
if col.IsKelvin() {
|
||||
fmt.Println(col.String())
|
||||
}
|
||||
if hs, ok := col.ToHS(); ok {
|
||||
fmt.Println(hs.String())
|
||||
}
|
||||
if hsk, ok := col.ToHSK(); ok {
|
||||
fmt.Println(hsk.String())
|
||||
}
|
||||
if xy, ok := col.ToXY(); ok {
|
||||
fmt.Println(xy.String())
|
||||
}
|
||||
if rgb, ok := col.ToRGB(); ok {
|
||||
fmt.Println(rgb.String())
|
||||
}
|
||||
}
|
||||
|
||||
case "factory":
|
||||
factoryCmd(ctx, c)
|
||||
|
||||
+22
-5
@@ -19,6 +19,16 @@ func sceneCmd(
|
||||
cmd := parseCommand(os.Args[2:])
|
||||
|
||||
switch cmd.Name {
|
||||
case "list":
|
||||
{
|
||||
scenes, err := c.GetScenes(ctx)
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to load scenes:", err)
|
||||
}
|
||||
|
||||
WriteSceneTable(os.Stdout, scenes)
|
||||
}
|
||||
|
||||
case "create", "update":
|
||||
{
|
||||
fileName := cmd.Params.Get(0).String()
|
||||
@@ -83,15 +93,22 @@ func sceneCmd(
|
||||
{
|
||||
fetch := cmd.Params.Get(0).String()
|
||||
id := cmd.Params.Get(1).Int()
|
||||
if fetch == nil || id == nil {
|
||||
log.Println("Usage: lucy scene assign <fetch> <id> <group=S> <duration=I>")
|
||||
name := cmd.Params.Get(1).String()
|
||||
if fetch == nil || (id == nil && name == nil) {
|
||||
log.Println("Usage: lucy scene assign <fetch> <id|name> <group=S> <duration=I>")
|
||||
}
|
||||
|
||||
devices, err := c.AssignDevice(ctx, *fetch, cmd.Name == "push", models.DeviceSceneAssignment{
|
||||
SceneID: *id,
|
||||
assignment := models.DeviceSceneAssignment{
|
||||
Group: cmd.Params.Get("group").StringOr(*fetch),
|
||||
DurationMS: int64(cmd.Params.Get("duration").IntOr(0)),
|
||||
})
|
||||
}
|
||||
if id != nil {
|
||||
assignment.SceneID = *id
|
||||
} else {
|
||||
assignment.SceneName = *name
|
||||
}
|
||||
|
||||
devices, err := c.AssignDevice(ctx, *fetch, cmd.Name == "push", assignment)
|
||||
if err != nil {
|
||||
log.Println("Could not assign devices:", err)
|
||||
return
|
||||
|
||||
@@ -94,6 +94,24 @@ func WriteDeviceInfoTable(w io.Writer, devices []models.Device) {
|
||||
table.Render()
|
||||
}
|
||||
|
||||
func WriteSceneTable(w io.Writer, scenes []models.Scene) {
|
||||
table := tablewriter.NewWriter(w)
|
||||
table.SetHeader([]string{"ID", "NAME", "ROLES", "INTERVAL"})
|
||||
|
||||
table.SetReflowDuringAutoWrap(false)
|
||||
|
||||
for _, v := range scenes {
|
||||
table.Append([]string{
|
||||
fmt.Sprint(v.ID),
|
||||
v.Name,
|
||||
fmt.Sprint(len(v.Roles)),
|
||||
fmt.Sprint(v.IntervalMS),
|
||||
})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
}
|
||||
|
||||
func WriteHandlerInfoTable(w io.Writer, handlers []models.EventHandler) {
|
||||
table := tablewriter.NewWriter(w)
|
||||
table.SetHeader([]string{"ID", "EVENT NAME", "PRIORITY", "CONDITIONS", "TARGET", "ACTION"})
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cv, _ := color.Parse("xy:0.1944,0.0942")
|
||||
hs, _ := cv.ToHS()
|
||||
log.Println(cv.String())
|
||||
log.Println(hs.String())
|
||||
xy, _ := hs.ToXY()
|
||||
log.Println(xy.String())
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package color
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Color struct {
|
||||
RGB *RGB `json:"rgb,omitempty"`
|
||||
HS *HueSat `json:"hs,omitempty"`
|
||||
K *int `json:"k,omitempty"`
|
||||
XY *XY `json:"xy,omitempty"`
|
||||
}
|
||||
|
||||
func (col *Color) IsHueSat() bool {
|
||||
return col.HS != nil
|
||||
}
|
||||
|
||||
func (col *Color) IsHueSatKelvin() bool {
|
||||
return col.HS != nil && col.K != nil
|
||||
}
|
||||
|
||||
func (col *Color) IsKelvin() bool {
|
||||
return col.K != nil
|
||||
}
|
||||
|
||||
func (col *Color) IsEmpty() bool {
|
||||
return *col == Color{}
|
||||
}
|
||||
|
||||
func (col *Color) SetK(k int) {
|
||||
*col = Color{K: &k}
|
||||
}
|
||||
|
||||
func (col *Color) SetXY(xy XY) {
|
||||
*col = Color{XY: &xy}
|
||||
}
|
||||
|
||||
// ToRGB tries to copy the color to an RGB color. If it's already RGB, it will be plainly copied, but HS
|
||||
// will be returned. If ok is false, then no copying has occurred and cv2 will be blank.
|
||||
func (col *Color) ToRGB() (col2 Color, ok bool) {
|
||||
if col.RGB != nil {
|
||||
rgb := *col.RGB
|
||||
col2 = Color{RGB: &rgb}
|
||||
ok = true
|
||||
} else if col.HS != nil {
|
||||
rgb := col.HS.ToRGB()
|
||||
col2 = Color{RGB: &rgb}
|
||||
ok = true
|
||||
} else if col.XY != nil {
|
||||
rgb := col.XY.ToRGB()
|
||||
col2 = Color{RGB: &rgb}
|
||||
ok = true
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (col *Color) ToHS() (col2 Color, ok bool) {
|
||||
if col.HS != nil {
|
||||
hs := *col.HS
|
||||
col2 = Color{HS: &hs}
|
||||
ok = true
|
||||
} else if col.RGB != nil {
|
||||
hs := col.RGB.ToHS()
|
||||
col2 = Color{HS: &hs}
|
||||
ok = true
|
||||
} else if col.XY != nil {
|
||||
hs := col.XY.ToHS()
|
||||
col2 = Color{HS: &hs}
|
||||
ok = true
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (col *Color) ToHSK() (col2 Color, ok bool) {
|
||||
k := 4000
|
||||
|
||||
if col.HS != nil {
|
||||
hs := *col.HS
|
||||
col2 = Color{HS: &hs}
|
||||
|
||||
if col.K != nil {
|
||||
k = *col.K
|
||||
}
|
||||
col2.K = &k
|
||||
|
||||
ok = true
|
||||
} else if col.RGB != nil {
|
||||
hs := col.RGB.ToHS()
|
||||
col2 = Color{HS: &hs}
|
||||
col2.K = &k
|
||||
ok = true
|
||||
} else if col.XY != nil {
|
||||
hs := col.XY.ToHS()
|
||||
col2 = Color{HS: &hs}
|
||||
col2.K = &k
|
||||
ok = true
|
||||
} else if col.K != nil {
|
||||
k = *col.K
|
||||
col2.HS = &HueSat{Hue: 0, Sat: 0}
|
||||
col2.K = &k
|
||||
ok = true
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ToXY tries to copy the color to an XY color.
|
||||
func (col *Color) ToXY() (col2 Color, ok bool) {
|
||||
if col.XY != nil {
|
||||
xy := *col.XY
|
||||
col2 = Color{XY: &xy}
|
||||
ok = true
|
||||
} else if col.HS != nil {
|
||||
xy := col.HS.ToXY()
|
||||
col2 = Color{XY: &xy}
|
||||
ok = true
|
||||
} else if col.RGB != nil {
|
||||
xy := col.RGB.ToXY()
|
||||
col2 = Color{XY: &xy}
|
||||
ok = true
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (col *Color) Interpolate(other Color, fac float64) Color {
|
||||
// Special case for kelvin values.
|
||||
if col.IsKelvin() && other.IsKelvin() {
|
||||
k1 := *col.K
|
||||
k2 := *col.K
|
||||
k3 := k1 + int(float64(k2-k1)*fac)
|
||||
return Color{K: &k3}
|
||||
}
|
||||
|
||||
// Get the colorful values.
|
||||
cvCF := col.colorful()
|
||||
otherCF := other.colorful()
|
||||
|
||||
// Blend and normalize
|
||||
blended := cvCF.BlendLuv(otherCF, fac)
|
||||
blendedHue, blendedSat, _ := blended.Hsv()
|
||||
blendedHs := HueSat{Hue: blendedHue, Sat: blendedSat}
|
||||
|
||||
// Convert to the first's type
|
||||
switch col.Kind() {
|
||||
case "rgb":
|
||||
rgb := blendedHs.ToRGB()
|
||||
return Color{RGB: &rgb}
|
||||
case "xy":
|
||||
xy := blendedHs.ToXY()
|
||||
return Color{XY: &xy}
|
||||
default:
|
||||
return Color{HS: &blendedHs}
|
||||
}
|
||||
}
|
||||
|
||||
func (col *Color) Kind() string {
|
||||
switch {
|
||||
case col.RGB != nil:
|
||||
return "rgb"
|
||||
case col.XY != nil:
|
||||
return "xy"
|
||||
case col.HS != nil && col.K != nil:
|
||||
return "hsk"
|
||||
case col.HS != nil:
|
||||
return "hs"
|
||||
case col.K != nil:
|
||||
return "k"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (col *Color) String() string {
|
||||
switch {
|
||||
case col.RGB != nil:
|
||||
return fmt.Sprintf("rgb:%.3f,%.3f,%.3f", col.RGB.Red, col.RGB.Green, col.RGB.Blue)
|
||||
case col.XY != nil:
|
||||
return fmt.Sprintf("xy:%.4f,%.4f", col.XY.X, col.XY.Y)
|
||||
case col.HS != nil && col.K != nil:
|
||||
return fmt.Sprintf("hsk:%.4f,%.3f,%d", col.HS.Hue, col.HS.Sat, *col.K)
|
||||
case col.HS != nil:
|
||||
return fmt.Sprintf("hs:%.4f,%.3f", col.HS.Hue, col.HS.Sat)
|
||||
case col.K != nil:
|
||||
return fmt.Sprintf("k:%d", *col.K)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (col *Color) colorful() colorful.Color {
|
||||
switch {
|
||||
case col.HS != nil:
|
||||
return colorful.Hsv(col.HS.Hue, col.HS.Sat, 1)
|
||||
case col.RGB != nil:
|
||||
return colorful.Color{R: col.RGB.Red, G: col.RGB.Green, B: col.RGB.Blue}
|
||||
case col.XY != nil:
|
||||
return colorful.Xyy(col.XY.X, col.XY.Y, 0.5)
|
||||
default:
|
||||
return colorful.Color{R: 255, B: 255, G: 255}
|
||||
}
|
||||
}
|
||||
|
||||
func Parse(raw string) (col Color, err error) {
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
tokens := strings.SplitN(raw, ":", 2)
|
||||
if len(tokens) != 2 {
|
||||
err = lerrors.ErrBadInput
|
||||
return
|
||||
}
|
||||
|
||||
switch tokens[0] {
|
||||
case "kelvin", "k":
|
||||
{
|
||||
parsedPart, err := strconv.Atoi(tokens[1])
|
||||
if err != nil {
|
||||
err = lerrors.ErrBadInput
|
||||
break
|
||||
}
|
||||
|
||||
col.K = &parsedPart
|
||||
}
|
||||
|
||||
case "xy":
|
||||
{
|
||||
parts := strings.Split(tokens[1], ",")
|
||||
if len(parts) < 2 {
|
||||
err = lerrors.ErrUnknownColorFormat
|
||||
return
|
||||
}
|
||||
|
||||
x, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
y, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
err = lerrors.ErrBadInput
|
||||
break
|
||||
}
|
||||
|
||||
col.XY = &XY{x, y}
|
||||
}
|
||||
|
||||
case "hs":
|
||||
{
|
||||
parts := strings.Split(tokens[1], ",")
|
||||
if len(parts) < 2 {
|
||||
err = lerrors.ErrUnknownColorFormat
|
||||
return
|
||||
}
|
||||
|
||||
part1, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
part2, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
err = lerrors.ErrBadInput
|
||||
break
|
||||
}
|
||||
|
||||
col.HS = &HueSat{Hue: part1, Sat: part2}
|
||||
}
|
||||
|
||||
case "hsk":
|
||||
{
|
||||
parts := strings.Split(tokens[1], ",")
|
||||
if len(parts) < 3 {
|
||||
err = lerrors.ErrUnknownColorFormat
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
err = lerrors.ErrBadInput
|
||||
break
|
||||
}
|
||||
|
||||
col.HS = &HueSat{Hue: part1, Sat: part2}
|
||||
col.K = &part3
|
||||
}
|
||||
|
||||
case "rgb":
|
||||
{
|
||||
if strings.HasPrefix(tokens[1], "#") {
|
||||
hex := tokens[1][1:]
|
||||
if !validHex(hex) {
|
||||
err = lerrors.ErrBadInput
|
||||
break
|
||||
}
|
||||
|
||||
if len(hex) == 6 {
|
||||
col.RGB = &RGB{
|
||||
Red: float64(hex2num(hex[0:2])) / 255.0,
|
||||
Green: float64(hex2num(hex[2:4])) / 255.0,
|
||||
Blue: float64(hex2num(hex[4:6])) / 255.0,
|
||||
}
|
||||
} else if len(hex) == 3 {
|
||||
col.RGB = &RGB{
|
||||
Red: float64(hex2digit(hex[0])) / 15.0,
|
||||
Green: float64(hex2digit(hex[1])) / 15.0,
|
||||
Blue: float64(hex2digit(hex[2])) / 15.0,
|
||||
}
|
||||
} else {
|
||||
err = lerrors.ErrUnknownColorFormat
|
||||
return
|
||||
}
|
||||
} else {
|
||||
parts := strings.Split(tokens[1], ",")
|
||||
if len(parts) < 3 {
|
||||
err = lerrors.ErrUnknownColorFormat
|
||||
return
|
||||
}
|
||||
|
||||
part1, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
part2, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
part3, err3 := strconv.ParseFloat(parts[2], 64)
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
err = lerrors.ErrBadInput
|
||||
break
|
||||
}
|
||||
|
||||
col.RGB = &RGB{Red: part1, Green: part2, Blue: part3}
|
||||
}
|
||||
|
||||
normalizedRGB := col.RGB.ToHS().ToRGB()
|
||||
col.RGB = &normalizedRGB
|
||||
}
|
||||
|
||||
default:
|
||||
err = lerrors.ErrUnknownColorFormat
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func validHex(h string) bool {
|
||||
for _, ch := range h {
|
||||
if !((ch >= 'a' && ch <= 'f') || (ch >= '0' || ch <= '9')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func hex2num(s string) int {
|
||||
v := 0
|
||||
for _, h := range s {
|
||||
v *= 16
|
||||
v += hex2digit(byte(h))
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func hex2digit(h byte) int {
|
||||
if h >= 'a' && h <= 'f' {
|
||||
return 10 + int(h-'a')
|
||||
} else {
|
||||
return int(h - '0')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package color
|
||||
|
||||
import "github.com/lucasb-eyer/go-colorful"
|
||||
|
||||
type HueSat struct {
|
||||
Hue float64 `json:"hue"`
|
||||
Sat float64 `json:"sat"`
|
||||
}
|
||||
|
||||
func (hs HueSat) ToXY() XY {
|
||||
return hs.ToRGB().ToXY()
|
||||
}
|
||||
|
||||
func (hs HueSat) ToRGB() RGB {
|
||||
c := colorful.Hsv(hs.Hue, hs.Sat, 1)
|
||||
return RGB{Red: c.R, Green: c.G, Blue: c.B}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package color
|
||||
|
||||
import "github.com/lucasb-eyer/go-colorful"
|
||||
|
||||
type RGB struct {
|
||||
Red float64 `json:"red"`
|
||||
Green float64 `json:"green"`
|
||||
Blue float64 `json:"blue"`
|
||||
}
|
||||
|
||||
func (rgb RGB) AtIntensity(intensity float64) RGB {
|
||||
hue, sat, _ := colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}.Hsv()
|
||||
hsv2 := colorful.Hsv(hue, sat, intensity)
|
||||
return RGB{Red: hsv2.R, Green: hsv2.G, Blue: hsv2.B}
|
||||
}
|
||||
|
||||
func (rgb RGB) ToHS() HueSat {
|
||||
hue, sat, _ := colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}.Hsv()
|
||||
return HueSat{Hue: hue, Sat: sat}
|
||||
}
|
||||
|
||||
func (rgb RGB) ToXY() XY {
|
||||
x, y, z := (colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}).Xyz()
|
||||
|
||||
return XY{
|
||||
X: x / (x + y + z),
|
||||
Y: y / (x + y + z),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package color
|
||||
|
||||
import (
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
"math"
|
||||
)
|
||||
|
||||
const eps = 0.0001
|
||||
const epsSquare = eps * eps
|
||||
|
||||
type Gamut struct {
|
||||
Red XY `json:"red"`
|
||||
Green XY `json:"green"`
|
||||
Blue XY `json:"blue"`
|
||||
}
|
||||
|
||||
func (cg *Gamut) side(x1, y1, x2, y2, x, y float64) float64 {
|
||||
return (y2-y1)*(x-x1) + (-x2+x1)*(y-y1)
|
||||
}
|
||||
|
||||
func (cg *Gamut) naiveContains(color XY) bool {
|
||||
x, y := color.X, color.Y
|
||||
x1, y1 := cg.Red.X, cg.Red.Y
|
||||
x2, y2 := cg.Green.X, cg.Green.Y
|
||||
x3, y3 := cg.Blue.X, cg.Blue.Y
|
||||
|
||||
checkSide1 := cg.side(x1, y1, x2, y2, x, y) < 0
|
||||
checkSide2 := cg.side(x2, y2, x3, y3, x, y) < 0
|
||||
checkSide3 := cg.side(x3, y3, x1, y1, x, y) < 0
|
||||
|
||||
return checkSide1 && checkSide2 && checkSide3
|
||||
}
|
||||
|
||||
func (cg *Gamut) getBounds() (xMin, xMax, yMin, yMax float64) {
|
||||
x1, y1 := cg.Red.X, cg.Red.Y
|
||||
x2, y2 := cg.Green.X, cg.Green.Y
|
||||
x3, y3 := cg.Blue.X, cg.Blue.Y
|
||||
|
||||
xMin = math.Min(x1, math.Min(x2, x3)) - eps
|
||||
xMax = math.Max(x1, math.Max(x2, x3)) + eps
|
||||
yMin = math.Min(y1, math.Min(y2, y3)) - eps
|
||||
yMax = math.Max(y1, math.Max(y2, y3)) + eps
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (cg *Gamut) isInBounds(color XY) bool {
|
||||
x, y := color.X, color.Y
|
||||
xMin, xMax, yMin, yMax := cg.getBounds()
|
||||
|
||||
return !(x < xMin || xMax < x || y < yMin || yMax < y)
|
||||
}
|
||||
|
||||
func (cg *Gamut) distanceSquarePointToSegment(x1, y1, x2, y2, x, y float64) float64 {
|
||||
sqLength1 := (x2-x1)*(x2-x1) + (y2-y1)*(y2-y1)
|
||||
dotProduct := ((x-x1)*(x2-x1) + (y-y1)*(y2-y1)) / sqLength1
|
||||
if dotProduct < 0 {
|
||||
return (x-x1)*(x-x1) + (y-y1)*(y-y1)
|
||||
} else if dotProduct <= 1 {
|
||||
sqLength2 := (x1-x)*(x1-x) + (y1-y)*(y1-y)
|
||||
return sqLength2 - dotProduct*dotProduct*sqLength1
|
||||
} else {
|
||||
return (x-x2)*(x-x2) + (y-y2)*(y-y2)
|
||||
}
|
||||
}
|
||||
|
||||
func (cg *Gamut) atTheEdge(color XY) bool {
|
||||
x, y := color.X, color.Y
|
||||
x1, y1 := cg.Red.X, cg.Red.Y
|
||||
x2, y2 := cg.Green.X, cg.Green.Y
|
||||
x3, y3 := cg.Blue.X, cg.Blue.Y
|
||||
|
||||
if cg.distanceSquarePointToSegment(x1, y1, x2, y2, x, y) <= epsSquare {
|
||||
return true
|
||||
}
|
||||
if cg.distanceSquarePointToSegment(x2, y2, x3, y3, x, y) <= epsSquare {
|
||||
return true
|
||||
}
|
||||
if cg.distanceSquarePointToSegment(x3, y3, x1, y1, x, y) <= epsSquare {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (cg *Gamut) Contains(color XY) bool {
|
||||
if cg == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return cg.isInBounds(color) && (cg.naiveContains(color) || cg.atTheEdge(color))
|
||||
}
|
||||
|
||||
func (cg *Gamut) Conform(color XY) XY {
|
||||
if cg.Contains(color) {
|
||||
return color
|
||||
}
|
||||
|
||||
var best *XY
|
||||
|
||||
xMin, xMax, yMin, yMax := cg.getBounds()
|
||||
|
||||
for x := xMin; x < xMax; x += 0.001 {
|
||||
for y := yMin; y < yMax; y += 0.001 {
|
||||
color2 := XY{X: x, Y: y}
|
||||
|
||||
if cg.Contains(color2) {
|
||||
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
|
||||
best = &color2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if best == nil {
|
||||
centerX := (cg.Red.X + cg.Green.X + cg.Blue.X) / 3
|
||||
centerY := (cg.Red.Y + cg.Green.Y + cg.Blue.Y) / 3
|
||||
|
||||
stepX := (centerX - color.X) / 5000
|
||||
stepY := (centerY - color.Y) / 5000
|
||||
|
||||
for !cg.Contains(color) {
|
||||
color.X += stepX
|
||||
color.Y += stepY
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
|
||||
for x := best.X - 0.001; x < best.X+0.001; x += 0.0002 {
|
||||
for y := best.Y - 0.001; y < best.Y+0.001; y += 0.0002 {
|
||||
color2 := XY{X: x, Y: y}
|
||||
|
||||
if cg.atTheEdge(color2) {
|
||||
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
|
||||
best = &color2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for x := best.X - 0.0001; x < best.X+0.0001; x += 0.00003 {
|
||||
for y := best.Y - 0.0001; y < best.Y+0.0001; y += 0.00003 {
|
||||
color2 := XY{X: x, Y: y}
|
||||
|
||||
if cg.atTheEdge(color2) {
|
||||
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
|
||||
best = &color2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return *best
|
||||
}
|
||||
|
||||
type XY struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
func (xy XY) ToRGB() RGB {
|
||||
h, s, _ := colorful.Xyy(xy.X, xy.Y, 0.5).Hsv()
|
||||
c := colorful.Hsv(h, s, 1)
|
||||
return RGB{Red: c.R, Green: c.G, Blue: c.B}
|
||||
}
|
||||
|
||||
func (xy XY) ToHS() HueSat {
|
||||
h, s, _ := colorful.Xyy(xy.X, xy.Y, 0.5).Hsv()
|
||||
return HueSat{Hue: h, Sat: s}
|
||||
}
|
||||
|
||||
func (xy XY) DistanceTo(other XY) float64 {
|
||||
return math.Sqrt(math.Pow(xy.X-other.X, 2) + math.Pow(xy.Y-other.Y, 2))
|
||||
}
|
||||
|
||||
func (xy XY) Round() XY {
|
||||
return XY{
|
||||
X: math.Round(xy.X*10000) / 10000,
|
||||
Y: math.Round(xy.Y*10000) / 10000,
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -23,6 +24,7 @@ type Bridge struct {
|
||||
externalID int
|
||||
lightStates []*hueLightState
|
||||
sensorStates []*hueSensorState
|
||||
quarantine map[string]time.Time
|
||||
syncingPublish uint32
|
||||
}
|
||||
|
||||
@@ -34,6 +36,10 @@ func (b *Bridge) Refresh(ctx context.Context) error {
|
||||
|
||||
b.mu.Lock()
|
||||
for index, light := range lightMap {
|
||||
if time.Now().Before(b.quarantine[light.Uniqueid]) {
|
||||
continue
|
||||
}
|
||||
|
||||
var state *hueLightState
|
||||
for _, existingState := range b.lightStates {
|
||||
if existingState.index == index {
|
||||
@@ -68,6 +74,10 @@ func (b *Bridge) Refresh(ctx context.Context) error {
|
||||
|
||||
b.mu.Lock()
|
||||
for index, sensor := range sensorMap {
|
||||
if time.Now().Before(b.quarantine[sensor.UniqueID]) {
|
||||
continue
|
||||
}
|
||||
|
||||
var state *hueSensorState
|
||||
for _, existingState := range b.sensorStates {
|
||||
if existingState.index == index {
|
||||
@@ -98,17 +108,17 @@ func (b *Bridge) Refresh(ctx context.Context) error {
|
||||
|
||||
func (b *Bridge) SyncStale(ctx context.Context) error {
|
||||
indices := make([]int, 0, 4)
|
||||
arrayIndices := make([]int, 0, 4)
|
||||
inputs := make([]LightStateInput, 0, 4)
|
||||
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
b.mu.Lock()
|
||||
for i, state := range b.lightStates {
|
||||
for _, state := range b.lightStates {
|
||||
if !state.stale {
|
||||
continue
|
||||
}
|
||||
|
||||
indices = append(indices, state.index)
|
||||
arrayIndices = append(arrayIndices, i)
|
||||
inputs = append(inputs, state.input)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
@@ -117,83 +127,60 @@ func (b *Bridge) SyncStale(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
groups := make([]*syncGroup, 0, 4)
|
||||
for i := range inputs {
|
||||
input := inputs[i]
|
||||
for i, input := range inputs {
|
||||
iCopy := i
|
||||
index := indices[i]
|
||||
inputCopy := input
|
||||
|
||||
found := false
|
||||
for _, group := range groups {
|
||||
if group.State.Equal(input) {
|
||||
group.Indexes = append(group.Indexes, indices[i])
|
||||
group.ArrayIndexes = append(group.ArrayIndexes, arrayIndices[i])
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
groups = append(groups, &syncGroup{
|
||||
GroupIndex: -1,
|
||||
State: input,
|
||||
Indexes: []int{indices[i]},
|
||||
ArrayIndexes: []int{arrayIndices[i]},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
groupMap, err := b.getGroups(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for id, data := range groupMap {
|
||||
for _, group := range groups {
|
||||
if group.Matches(&data) {
|
||||
group.GroupIndex = id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, group := range groups {
|
||||
if group.GroupIndex == -1 {
|
||||
data := GroupData{
|
||||
Name: "lucifer_auto_group",
|
||||
Lights: []string{},
|
||||
}
|
||||
|
||||
for _, idx := range group.Indexes {
|
||||
data.Lights = append(data.Lights, strconv.Itoa(idx))
|
||||
}
|
||||
|
||||
id, err := b.postGroup(ctx, data)
|
||||
eg.Go(func() error {
|
||||
err := b.putLightState(ctx, index, inputCopy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
group.GroupIndex = id
|
||||
}
|
||||
b.lightStates[iCopy].stale = false
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
log.Println("Updating", len(inputs), "lights on Hue bridge", b.externalID, "in", len(groups), "groups")
|
||||
return eg.Wait()
|
||||
}
|
||||
|
||||
for _, group := range groups {
|
||||
err := b.putGroupLightState(ctx, group.GroupIndex, group.State)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
for _, arrayIndex := range group.ArrayIndexes {
|
||||
b.lightStates[arrayIndex].stale = false
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
if groupMap[group.GroupIndex].Name == "lucifer_auto_group" {
|
||||
err := b.deleteGroup(ctx, group.GroupIndex)
|
||||
if err != nil {
|
||||
log.Println("Could not delete temporary group", err)
|
||||
}
|
||||
func (b *Bridge) ForgetDevice(ctx context.Context, device models.Device) error {
|
||||
// Find index
|
||||
b.mu.Lock()
|
||||
found := false
|
||||
index := -1
|
||||
for i, ls := range b.lightStates {
|
||||
if ls.uniqueID == device.InternalID {
|
||||
found = true
|
||||
index = i
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if !found {
|
||||
return lerrors.ErrNotFound
|
||||
}
|
||||
|
||||
// Delete light from bridge
|
||||
err := b.deleteLight(ctx, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove light state from local list. I don't know if the quarantine is necessary, but let's have it anyway.
|
||||
b.mu.Lock()
|
||||
for i, ls := range b.lightStates {
|
||||
if ls.uniqueID == device.InternalID {
|
||||
b.lightStates = append(b.lightStates[:i], b.lightStates[i+1:]...)
|
||||
}
|
||||
}
|
||||
if b.quarantine == nil {
|
||||
b.quarantine = make(map[string]time.Time, 1)
|
||||
}
|
||||
b.quarantine[device.InternalID] = time.Now().Add(time.Second * 30)
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -236,6 +223,10 @@ func (b *Bridge) putGroupLightState(ctx context.Context, index int, input LightS
|
||||
return b.put(ctx, fmt.Sprintf("groups/%d/action", index), input, nil)
|
||||
}
|
||||
|
||||
func (b *Bridge) deleteLight(ctx context.Context, index int) error {
|
||||
return b.delete(ctx, fmt.Sprintf("lights/%d", index), nil)
|
||||
}
|
||||
|
||||
func (b *Bridge) getToken(ctx context.Context) (string, error) {
|
||||
result := make([]CreateUserResponse, 0, 1)
|
||||
err := b.post(ctx, "", CreateUserInput{DeviceType: "git.aiterp.net/lucifer"}, &result)
|
||||
@@ -247,7 +238,7 @@ func (b *Bridge) getToken(ctx context.Context) (string, error) {
|
||||
return "", errLinkButtonNotPressed
|
||||
}
|
||||
if result[0].Success == nil {
|
||||
return "", models.ErrUnexpectedResponse
|
||||
return "", lerrors.ErrUnexpectedResponse
|
||||
}
|
||||
|
||||
return result[0].Success.Username, nil
|
||||
@@ -296,7 +287,6 @@ func (b *Bridge) postGroup(ctx context.Context, input GroupData) (int, error) {
|
||||
return id, err
|
||||
}
|
||||
|
||||
|
||||
func (b *Bridge) deleteGroup(ctx context.Context, index int) error {
|
||||
return b.delete(ctx, "groups/"+strconv.Itoa(index), nil)
|
||||
}
|
||||
@@ -343,7 +333,6 @@ func (b *Bridge) delete(ctx context.Context, resource string, target interface{}
|
||||
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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -22,7 +23,7 @@ type Driver struct {
|
||||
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) {
|
||||
if address == "" {
|
||||
if !dryRun {
|
||||
return nil, models.ErrAddressOnlyDryRunnable
|
||||
return nil, lerrors.ErrAddressOnlyDryRunnable
|
||||
}
|
||||
|
||||
res, err := http.Get("https://discovery.meethue.com")
|
||||
@@ -322,8 +323,8 @@ func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models
|
||||
return err
|
||||
}
|
||||
|
||||
fastTicker := time.NewTicker(time.Second / 10)
|
||||
slowTicker := time.NewTicker(time.Second / 3)
|
||||
fastTicker := time.NewTicker(time.Second / 5)
|
||||
slowTicker := time.NewTicker(time.Second / 2)
|
||||
selectedTicker := fastTicker
|
||||
ticksUntilRefresh := 0
|
||||
ticksSinceChange := 0
|
||||
@@ -372,6 +373,15 @@ func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) ForgetDevice(ctx context.Context, bridge models.Bridge, device models.Device) error {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.ForgetDevice(ctx, device)
|
||||
}
|
||||
|
||||
func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*Bridge, error) {
|
||||
d.mu.Lock()
|
||||
for _, bridge := range d.bridges {
|
||||
|
||||
@@ -40,7 +40,7 @@ func (s *hueLightState) Update(state models.DeviceState) {
|
||||
if state.Power {
|
||||
input.On = ptrBool(true)
|
||||
if state.Color.IsKelvin() {
|
||||
input.CT = ptrInt(1000000 / state.Color.Kelvin)
|
||||
input.CT = ptrInt(1000000 / *state.Color.K)
|
||||
if *input.CT < s.info.Capabilities.Control.CT.Min {
|
||||
*input.CT = s.info.Capabilities.Control.CT.Min
|
||||
}
|
||||
@@ -51,13 +51,13 @@ func (s *hueLightState) Update(state models.DeviceState) {
|
||||
if s.input.CT == nil || *s.input.CT != *input.CT {
|
||||
s.stale = true
|
||||
}
|
||||
} else {
|
||||
input.Hue = ptrInt(int(state.Color.Hue*(65536/360)) % 65536)
|
||||
} else if color, ok := state.Color.ToHS(); ok {
|
||||
input.Hue = ptrInt(int(color.HS.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))
|
||||
input.Sat = ptrInt(int(color.HS.Sat * 255))
|
||||
if *input.Sat > 254 {
|
||||
*input.Sat = 254
|
||||
}
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
package hue2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Bridge struct {
|
||||
mu sync.Mutex
|
||||
externalID int
|
||||
client *Client
|
||||
needsUpdate chan struct{}
|
||||
devices map[string]models.Device
|
||||
resources map[string]*ResourceData
|
||||
}
|
||||
|
||||
func NewBridge(client *Client) *Bridge {
|
||||
return &Bridge{
|
||||
client: client,
|
||||
needsUpdate: make(chan struct{}, 4),
|
||||
devices: make(map[string]models.Device, 64),
|
||||
resources: make(map[string]*ResourceData, 256),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) Run(ctx context.Context, eventCh chan<- models.Event) error {
|
||||
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Connecting SSE...")
|
||||
sse := b.client.SSE(ctx)
|
||||
|
||||
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Refreshing...")
|
||||
err := b.RefreshAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Running updates...")
|
||||
updated, err := b.MakeCongruent(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (startup)")
|
||||
|
||||
lightRefreshTimer := time.NewTicker(time.Second * 5)
|
||||
defer lightRefreshTimer.Stop()
|
||||
|
||||
motionTicker := time.NewTicker(time.Second * 10)
|
||||
defer motionTicker.Stop()
|
||||
|
||||
absences := make(map[int]time.Time)
|
||||
lastPress := make(map[string]time.Time)
|
||||
lastUpdate := time.Now()
|
||||
needFull := false
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-lightRefreshTimer.C:
|
||||
if time.Since(lastUpdate) < time.Second*2 {
|
||||
continue
|
||||
}
|
||||
|
||||
if needFull {
|
||||
err := b.RefreshAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
err := b.Refresh(ctx, "light")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := b.MakeCongruent(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated > 0 {
|
||||
// Force this to cool for 30 seconds unless a manual update occurs.
|
||||
if updated > 10 {
|
||||
lastUpdate = time.Now().Add(time.Second * 15)
|
||||
}
|
||||
|
||||
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (regular check)")
|
||||
}
|
||||
case <-b.needsUpdate:
|
||||
lastUpdate = time.Now()
|
||||
|
||||
updated, err := b.MakeCongruent(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated > 0 {
|
||||
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (publish)")
|
||||
}
|
||||
case <-motionTicker.C:
|
||||
for id, absenceTime := range absences {
|
||||
seconds := int(time.Since(absenceTime).Seconds())
|
||||
if seconds < 10 || seconds%60 >= 10 {
|
||||
continue
|
||||
}
|
||||
|
||||
eventCh <- models.Event{
|
||||
Name: models.ENSensorPresenceEnded,
|
||||
Payload: map[string]string{
|
||||
"deviceId": fmt.Sprint(id),
|
||||
"minutesElapsed": fmt.Sprint(seconds / 60),
|
||||
"secondsElapsed": fmt.Sprint(seconds),
|
||||
"lastUpdated": fmt.Sprint(absenceTime.Unix()),
|
||||
},
|
||||
}
|
||||
}
|
||||
case data, ok := <-sse:
|
||||
if !ok {
|
||||
return errors.New("SSE Disconnected")
|
||||
}
|
||||
b.applyPatches(data.Data)
|
||||
|
||||
updated, err := b.MakeCongruent(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated > 0 {
|
||||
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (SSE)")
|
||||
}
|
||||
|
||||
for _, patch := range data.Data {
|
||||
if patch.Owner == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
if b.resources[patch.Owner.ID] == nil {
|
||||
needFull = true
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
device, deviceOK := b.devices[patch.Owner.ID]
|
||||
if !deviceOK || device.ID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if patch.Button != nil {
|
||||
valid := false
|
||||
if patch.Button.LastEvent == "initial_press" || patch.Button.LastEvent == "repeat" {
|
||||
valid = true
|
||||
} else if patch.Button.LastEvent == "long_release" {
|
||||
valid = false
|
||||
} else {
|
||||
valid = data.CreationTime.Sub(lastPress[patch.ID]) >= time.Second*2
|
||||
}
|
||||
|
||||
if valid {
|
||||
lastPress[patch.ID] = data.CreationTime
|
||||
|
||||
b.mu.Lock()
|
||||
owner := b.resources[patch.Owner.ID]
|
||||
b.mu.Unlock()
|
||||
if owner != nil {
|
||||
index := owner.ServiceIndex("button", patch.ID)
|
||||
if index != -1 {
|
||||
eventCh <- models.Event{
|
||||
Name: models.ENButtonPressed,
|
||||
Payload: map[string]string{
|
||||
"deviceId": fmt.Sprint(device.ID),
|
||||
"hueButtonEvent": patch.Button.LastEvent,
|
||||
"buttonIndex": fmt.Sprint(index),
|
||||
"buttonName": device.ButtonNames[index],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.Temperature != nil && patch.Temperature.Valid {
|
||||
eventCh <- models.Event{
|
||||
Name: models.ENSensorTemperature,
|
||||
Payload: map[string]string{
|
||||
"deviceId": fmt.Sprint(device.ID),
|
||||
"deviceInternalId": patch.Owner.ID,
|
||||
"temperature": fmt.Sprint(patch.Temperature.Temperature),
|
||||
"lastUpdated": fmt.Sprint(data.CreationTime.Unix()),
|
||||
},
|
||||
}
|
||||
}
|
||||
if patch.Motion != nil && patch.Motion.Valid {
|
||||
if patch.Motion.Motion {
|
||||
eventCh <- models.Event{
|
||||
Name: models.ENSensorPresenceStarted,
|
||||
Payload: map[string]string{
|
||||
"deviceId": fmt.Sprint(device.ID),
|
||||
"deviceInternalId": patch.Owner.ID,
|
||||
},
|
||||
}
|
||||
|
||||
delete(absences, device.ID)
|
||||
} else {
|
||||
eventCh <- models.Event{
|
||||
Name: models.ENSensorPresenceEnded,
|
||||
Payload: map[string]string{
|
||||
"deviceId": fmt.Sprint(device.ID),
|
||||
"deviceInternalId": device.InternalID,
|
||||
"minutesElapsed": "0",
|
||||
"secondsElapsed": "0",
|
||||
"lastUpdated": fmt.Sprint(data.CreationTime.Unix()),
|
||||
},
|
||||
}
|
||||
|
||||
absences[device.ID] = data.CreationTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) Update(devices ...models.Device) {
|
||||
b.mu.Lock()
|
||||
for _, device := range devices {
|
||||
b.devices[device.InternalID] = device
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
select {
|
||||
case b.needsUpdate <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) {
|
||||
// Exhaust the channel to avoid more updates.
|
||||
exhausted := false
|
||||
for !exhausted {
|
||||
select {
|
||||
case <-b.needsUpdate:
|
||||
default:
|
||||
exhausted = true
|
||||
}
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
dur := time.Millisecond * 100
|
||||
updates := make(map[string]ResourceUpdate)
|
||||
for _, device := range b.devices {
|
||||
resource := b.resources[device.InternalID]
|
||||
if resource == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update device
|
||||
if resource.Metadata.Name != device.Name {
|
||||
name := device.Name
|
||||
updates["device/"+resource.ID] = ResourceUpdate{
|
||||
Name: &name,
|
||||
}
|
||||
}
|
||||
|
||||
// Update light
|
||||
if lightID := resource.ServiceID("light"); lightID != nil {
|
||||
light := b.resources[*lightID]
|
||||
update := ResourceUpdate{TransitionDuration: &dur}
|
||||
changed := false
|
||||
|
||||
lightsOut := light.Power != nil && !device.State.Power
|
||||
if !lightsOut {
|
||||
if light.ColorTemperature != nil && device.State.Color.IsKelvin() {
|
||||
mirek := 1000000 / *device.State.Color.K
|
||||
if mirek < light.ColorTemperature.MirekSchema.MirekMinimum {
|
||||
mirek = light.ColorTemperature.MirekSchema.MirekMinimum
|
||||
}
|
||||
if mirek > light.ColorTemperature.MirekSchema.MirekMaximum {
|
||||
mirek = light.ColorTemperature.MirekSchema.MirekMaximum
|
||||
}
|
||||
if light.ColorTemperature.Mirek == nil || mirek != *light.ColorTemperature.Mirek {
|
||||
update.Mirek = &mirek
|
||||
changed = true
|
||||
}
|
||||
} else if xyColor, ok := device.State.Color.ToXY(); ok && light.Color != nil {
|
||||
xy := light.Color.Gamut.Conform(*xyColor.XY).Round()
|
||||
if xy.DistanceTo(light.Color.XY) > 0.0009 || (light.ColorTemperature != nil && light.ColorTemperature.Mirek != nil) {
|
||||
update.ColorXY = &xy
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if light.Dimming != nil && math.Abs(light.Dimming.Brightness/100-device.State.Intensity) > 0.02 {
|
||||
brightness := math.Abs(math.Min(device.State.Intensity*100, 100))
|
||||
update.Brightness = &brightness
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if light.Power != nil && light.Power.On != device.State.Power {
|
||||
update.Power = &device.State.Power
|
||||
if device.State.Power {
|
||||
brightness := math.Abs(math.Min(device.State.Intensity*100, 100))
|
||||
update.Brightness = &brightness
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
updates["light/"+light.ID] = update
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
// Optimistically apply the updates to the states, so that the driver assumes they are set until
|
||||
// proven otherwise by the SSE client.
|
||||
newResources := make(map[string]*ResourceData, len(b.resources))
|
||||
for key, value := range b.resources {
|
||||
newResources[key] = value
|
||||
}
|
||||
for key, update := range updates {
|
||||
id := strings.SplitN(key, "/", 2)[1]
|
||||
newResources[id] = newResources[id].WithUpdate(update)
|
||||
}
|
||||
b.resources = newResources
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
if len(updates) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
for key := range updates {
|
||||
update := updates[key]
|
||||
split := strings.SplitN(key, "/", 2)
|
||||
link := ResourceLink{Kind: split[0], ID: split[1]}
|
||||
|
||||
eg.Go(func() error {
|
||||
return b.client.UpdateResource(ctx, link, update)
|
||||
})
|
||||
}
|
||||
|
||||
err := eg.Wait()
|
||||
if err != nil {
|
||||
// Try to restore light states.
|
||||
_ = b.Refresh(ctx, "light")
|
||||
|
||||
return len(updates), err
|
||||
}
|
||||
|
||||
return len(updates), nil
|
||||
}
|
||||
|
||||
func (b *Bridge) GenerateDevices() []models.Device {
|
||||
b.mu.Lock()
|
||||
resources := b.resources
|
||||
b.mu.Unlock()
|
||||
|
||||
devices := make([]models.Device, 0, 16)
|
||||
for _, resource := range resources {
|
||||
if resource.Type != "device" || strings.HasPrefix(resource.Metadata.Archetype, "bridge") {
|
||||
continue
|
||||
}
|
||||
|
||||
device := models.Device{
|
||||
BridgeID: b.externalID,
|
||||
InternalID: resource.ID,
|
||||
Name: resource.Metadata.Name,
|
||||
DriverProperties: map[string]interface{}{
|
||||
"archetype": resource.Metadata.Archetype,
|
||||
"name": resource.ProductData.ProductName,
|
||||
"product": resource.ProductData,
|
||||
"legacyId": resource.LegacyID,
|
||||
},
|
||||
}
|
||||
|
||||
// Set icon
|
||||
if resource.ProductData.ProductName == "Hue dimmer switch" {
|
||||
device.Icon = "switch"
|
||||
} else if resource.ProductData.ProductName == "Hue motion sensor" {
|
||||
device.Icon = "sensor"
|
||||
} else {
|
||||
device.Icon = "lightbulb"
|
||||
}
|
||||
|
||||
buttonCount := 0
|
||||
for _, ptr := range resource.Services {
|
||||
switch ptr.Kind {
|
||||
case "device_power":
|
||||
{
|
||||
device.DriverProperties["battery"] = resources[ptr.ID].PowerState
|
||||
}
|
||||
case "button":
|
||||
{
|
||||
buttonCount += 1
|
||||
}
|
||||
case "zigbee_connectivity":
|
||||
{
|
||||
device.DriverProperties["zigbee"] = resources[ptr.ID].Status
|
||||
}
|
||||
case "motion":
|
||||
{
|
||||
device.Capabilities = append(device.Capabilities, models.DCPresence)
|
||||
}
|
||||
case "temperature":
|
||||
{
|
||||
device.Capabilities = append(device.Capabilities, models.DCTemperatureSensor)
|
||||
}
|
||||
case "light":
|
||||
{
|
||||
light := resources[ptr.ID]
|
||||
|
||||
if light.Power != nil {
|
||||
device.State.Power = light.Power.On
|
||||
device.Capabilities = append(device.Capabilities, models.DCPower)
|
||||
}
|
||||
if light.Dimming != nil {
|
||||
device.State.Intensity = light.Dimming.Brightness / 100
|
||||
device.Capabilities = append(device.Capabilities, models.DCIntensity)
|
||||
}
|
||||
if light.ColorTemperature != nil {
|
||||
if light.ColorTemperature.Mirek != nil {
|
||||
device.State.Color.SetK(1000000 / *light.ColorTemperature.Mirek)
|
||||
}
|
||||
device.Capabilities = append(device.Capabilities, models.DCColorKelvin)
|
||||
device.DriverProperties["maxTemperature"] = 1000000 / light.ColorTemperature.MirekSchema.MirekMinimum
|
||||
device.DriverProperties["minTemperature"] = 1000000 / light.ColorTemperature.MirekSchema.MirekMaximum
|
||||
}
|
||||
if light.Color != nil {
|
||||
if device.State.Color.IsEmpty() {
|
||||
device.State.Color = color.Color{
|
||||
XY: &light.Color.XY,
|
||||
}
|
||||
}
|
||||
device.DriverProperties["colorGamut"] = light.Color.Gamut
|
||||
device.DriverProperties["colorGamutType"] = light.Color.GamutType
|
||||
device.Capabilities = append(device.Capabilities, models.DCColorHS, models.DCColorXY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if buttonCount == 4 {
|
||||
device.ButtonNames = []string{"On", "DimUp", "DimDown", "Off"}
|
||||
} else if buttonCount == 1 {
|
||||
device.ButtonNames = []string{"Button"}
|
||||
} else {
|
||||
for n := 1; n <= buttonCount; n++ {
|
||||
device.ButtonNames = append(device.ButtonNames, fmt.Sprint("Button", n))
|
||||
}
|
||||
}
|
||||
|
||||
devices = append(devices, device)
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
func (b *Bridge) Refresh(ctx context.Context, kind string) error {
|
||||
if kind == "device" {
|
||||
// Device refresh requires the full deal as services are taken for granted.
|
||||
return b.RefreshAll(ctx)
|
||||
}
|
||||
|
||||
resources, err := b.client.Resources(ctx, kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
oldResources := b.resources
|
||||
b.mu.Unlock()
|
||||
|
||||
newResources := make(map[string]*ResourceData, len(b.resources))
|
||||
for key, value := range oldResources {
|
||||
if value.Type != kind {
|
||||
newResources[key] = value
|
||||
}
|
||||
}
|
||||
for i := range resources {
|
||||
resource := resources[i]
|
||||
newResources[resource.ID] = &resource
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.resources = newResources
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bridge) RefreshAll(ctx context.Context) error {
|
||||
allResources, err := b.client.AllResources(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resources := make(map[string]*ResourceData, len(allResources))
|
||||
for i := range allResources {
|
||||
resource := allResources[i]
|
||||
resources[resource.ID] = &resource
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.resources = resources
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bridge) applyPatches(patches []ResourceData) {
|
||||
b.mu.Lock()
|
||||
newResources := make(map[string]*ResourceData, len(b.resources))
|
||||
for key, value := range b.resources {
|
||||
newResources[key] = value
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
for _, patch := range patches {
|
||||
if res := newResources[patch.ID]; res != nil {
|
||||
resCopy := *res
|
||||
|
||||
if patch.Power != nil && resCopy.Power != nil {
|
||||
cp := *resCopy.Power
|
||||
resCopy.Power = &cp
|
||||
resCopy.Power.On = patch.Power.On
|
||||
}
|
||||
if patch.Color != nil && resCopy.Color != nil {
|
||||
cp := *resCopy.Color
|
||||
resCopy.Color = &cp
|
||||
resCopy.Color.XY = patch.Color.XY
|
||||
|
||||
if resCopy.ColorTemperature != nil {
|
||||
cp2 := *resCopy.ColorTemperature
|
||||
resCopy.ColorTemperature = &cp2
|
||||
resCopy.ColorTemperature.Mirek = nil
|
||||
}
|
||||
}
|
||||
if patch.ColorTemperature != nil && resCopy.ColorTemperature != nil {
|
||||
cp := *resCopy.ColorTemperature
|
||||
resCopy.ColorTemperature = &cp
|
||||
resCopy.ColorTemperature.Mirek = patch.ColorTemperature.Mirek
|
||||
}
|
||||
if patch.Dimming != nil && resCopy.Dimming != nil {
|
||||
cp := *resCopy.Dimming
|
||||
resCopy.Dimming = &cp
|
||||
resCopy.Dimming.Brightness = patch.Dimming.Brightness
|
||||
}
|
||||
if patch.Dynamics != nil {
|
||||
resCopy.Dynamics = patch.Dynamics
|
||||
}
|
||||
if patch.Alert != nil {
|
||||
resCopy.Alert = patch.Alert
|
||||
}
|
||||
if patch.PowerState != nil {
|
||||
resCopy.PowerState = patch.PowerState
|
||||
}
|
||||
if patch.Temperature != nil {
|
||||
resCopy.Temperature = patch.Temperature
|
||||
}
|
||||
if patch.Status != nil {
|
||||
resCopy.Status = patch.Status
|
||||
}
|
||||
resCopy.Metadata.Name = patch.Metadata.Name
|
||||
|
||||
newResources[patch.ID] = &resCopy
|
||||
}
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.resources = newResources
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bridge) SearchDevices(ctx context.Context, timeout time.Duration) ([]models.Device, error) {
|
||||
// Record the current state.
|
||||
b.mu.Lock()
|
||||
before := b.resources
|
||||
b.mu.Unlock()
|
||||
|
||||
// Spend half the time waiting for devices
|
||||
// TODO: Wait for v2 endpoint
|
||||
err := b.client.LegacyDiscover(ctx, "sensors")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case <-time.After(timeout / 1):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// Spend half the time waiting for lights
|
||||
// TODO: Wait for v2 endpoint
|
||||
err = b.client.LegacyDiscover(ctx, "lights")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case <-time.After(timeout / 1):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// Perform a full refresh.
|
||||
err = b.RefreshAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for new devices
|
||||
devices := b.GenerateDevices()
|
||||
newDevices := make([]models.Device, 0)
|
||||
for _, device := range devices {
|
||||
if before[device.InternalID] == nil {
|
||||
newDevices = append(newDevices, device)
|
||||
}
|
||||
}
|
||||
|
||||
// Return said devices.
|
||||
return newDevices, nil
|
||||
}
|
||||
|
||||
func (b *Bridge) Forget(ctx context.Context, device models.Device) error {
|
||||
b.mu.Lock()
|
||||
resource := b.resources[device.InternalID]
|
||||
b.mu.Unlock()
|
||||
|
||||
if resource == nil {
|
||||
return errors.New("resource not found")
|
||||
}
|
||||
|
||||
return b.client.DeleteResource(ctx, ResourceLink{
|
||||
ID: device.InternalID,
|
||||
Kind: "device",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package hue2
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewClient(host, token string) *Client {
|
||||
ch := make(chan struct{}, 5)
|
||||
for i := 0; i < 2; i++ {
|
||||
ch <- struct{}{}
|
||||
}
|
||||
|
||||
return &Client{
|
||||
host: host,
|
||||
token: token,
|
||||
ch: ch,
|
||||
}
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
token string
|
||||
ch chan struct{}
|
||||
}
|
||||
|
||||
func (c *Client) Register(ctx context.Context) (string, error) {
|
||||
result := make([]CreateUserResponse, 0, 1)
|
||||
err := c.post(ctx, "api/", CreateUserInput{DeviceType: "git.aiterp.net/lucifer"}, &result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(result) == 0 || result[0].Error != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(time.Second):
|
||||
return c.Register(ctx)
|
||||
}
|
||||
}
|
||||
if result[0].Success == nil {
|
||||
return "", lerrors.ErrUnexpectedResponse
|
||||
}
|
||||
|
||||
c.token = result[0].Success.Username
|
||||
|
||||
return result[0].Success.Username, nil
|
||||
}
|
||||
|
||||
func (c *Client) AllResources(ctx context.Context) ([]ResourceData, error) {
|
||||
res := struct {
|
||||
Error interface{}
|
||||
Data []ResourceData
|
||||
}{}
|
||||
|
||||
err := c.get(ctx, "clip/v2/resource", &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) Resources(ctx context.Context, kind string) ([]ResourceData, error) {
|
||||
res := struct {
|
||||
Error interface{}
|
||||
Data []ResourceData
|
||||
}{}
|
||||
|
||||
err := c.get(ctx, "clip/v2/resource/"+kind, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateResource(ctx context.Context, link ResourceLink, update ResourceUpdate) error {
|
||||
return c.put(ctx, link.Path(), update, nil)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteResource(ctx context.Context, link ResourceLink) error {
|
||||
return c.delete(ctx, link.Path(), nil)
|
||||
}
|
||||
|
||||
func (c *Client) LegacyDiscover(ctx context.Context, kind string) error {
|
||||
return c.legacyPost(ctx, kind, nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) SSE(ctx context.Context) <-chan SSEUpdate {
|
||||
ch := make(chan SSEUpdate, 4)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
reader, err := c.getReader(ctx, "/eventstream/clip/v2", map[string]string{
|
||||
"Accept": "text/event-stream",
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("SSE Connect error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer reader.Close()
|
||||
|
||||
br := bufio.NewReader(reader)
|
||||
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Println("SSE Read error:", err)
|
||||
return
|
||||
}
|
||||
line = strings.Trim(line, " \t\r\n")
|
||||
kv := strings.SplitN(line, ": ", 2)
|
||||
if len(kv) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
switch kv[0] {
|
||||
case "data":
|
||||
var data []SSEUpdate
|
||||
err := json.Unmarshal([]byte(kv[1]), &data)
|
||||
if err != nil {
|
||||
log.Println("Parsing SSE event failed:", err)
|
||||
log.Println(" json:", kv[1])
|
||||
return
|
||||
}
|
||||
|
||||
for _, obj := range data {
|
||||
ch <- obj
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (c *Client) getReader(ctx context.Context, path string, headers map[string]string) (io.ReadCloser, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-c.ch:
|
||||
defer func() {
|
||||
c.ch <- struct{}{}
|
||||
}()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("https://%s/%s", c.host, path), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set("hue-application-key", c.token)
|
||||
|
||||
client := httpClient
|
||||
if headers["Accept"] == "text/event-stream" {
|
||||
client = sseClient
|
||||
}
|
||||
|
||||
res, err := client.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
_ = res.Body.Close()
|
||||
return nil, errors.New(res.Status)
|
||||
}
|
||||
|
||||
return res.Body, nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, target interface{}) error {
|
||||
body, err := c.getReader(ctx, path, map[string]string{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer body.Close()
|
||||
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(body).Decode(&target)
|
||||
}
|
||||
|
||||
func (c *Client) put(ctx context.Context, path string, body interface{}, target interface{}) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-c.ch:
|
||||
defer func() {
|
||||
c.ch <- struct{}{}
|
||||
}()
|
||||
}
|
||||
|
||||
rb, err := reqBody(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PUT", fmt.Sprintf("https://%s/%s", c.host, path), rb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("hue-application-key", c.token)
|
||||
|
||||
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 (c *Client) post(ctx context.Context, path string, body interface{}, target interface{}) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-c.ch:
|
||||
defer func() {
|
||||
c.ch <- struct{}{}
|
||||
}()
|
||||
}
|
||||
|
||||
rb, err := reqBody(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/%s", c.host, path), rb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.token != "" {
|
||||
req.Header.Set("hue-application-key", c.token)
|
||||
}
|
||||
|
||||
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 (c *Client) delete(ctx context.Context, path string, target interface{}) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-c.ch:
|
||||
defer func() {
|
||||
c.ch <- struct{}{}
|
||||
}()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PUT", fmt.Sprintf("https://%s/%s", c.host, path), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("hue-application-key", c.token)
|
||||
|
||||
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 (c *Client) legacyPost(ctx context.Context, resource string, body interface{}, target interface{}) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-c.ch:
|
||||
defer func() {
|
||||
c.ch <- struct{}{}
|
||||
}()
|
||||
}
|
||||
|
||||
rb, err := reqBody(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.token != "" {
|
||||
resource = c.token + "/" + resource
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://%s/api/%s", c.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 (c *Client) legacyDelete(ctx context.Context, resource string, target interface{}) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-c.ch:
|
||||
defer func() {
|
||||
c.ch <- struct{}{}
|
||||
}()
|
||||
}
|
||||
|
||||
if c.token != "" {
|
||||
resource = c.token + "/" + resource
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("DELETE", fmt.Sprintf("http://%s/api/%s", c.host, resource), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := httpClient.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.NewDecoder(res.Body).Decode(target)
|
||||
}
|
||||
|
||||
func 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 sseClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: time.Hour * 1000000,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 5,
|
||||
MaxIdleConnsPerHost: 1,
|
||||
IdleConnTimeout: 0,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
Timeout: time.Hour * 1000000,
|
||||
}
|
||||
|
||||
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,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
Timeout: time.Minute,
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package hue2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DeviceData struct {
|
||||
ID string `json:"id"`
|
||||
LegacyID string `json:"id_v1"`
|
||||
Metadata DeviceMetadata `json:"metadata"`
|
||||
Type string `json:"type"`
|
||||
|
||||
ProductData DeviceProductData `json:"product_data"`
|
||||
Services []ResourceLink `json:"services"`
|
||||
}
|
||||
|
||||
type DeviceMetadata struct {
|
||||
Archetype string `json:"archetype"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type DeviceProductData struct {
|
||||
Certified bool `json:"certified"`
|
||||
ManufacturerName string `json:"manufacturer_name"`
|
||||
ModelID string `json:"model_id"`
|
||||
ProductArchetype string `json:"product_archetype"`
|
||||
ProductName string `json:"product_name"`
|
||||
SoftwareVersion string `json:"software_version"`
|
||||
}
|
||||
|
||||
type SSEUpdate struct {
|
||||
CreationTime time.Time `json:"creationTime"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Data []ResourceData `json:"data"`
|
||||
}
|
||||
|
||||
type ResourceData struct {
|
||||
ID string `json:"id"`
|
||||
LegacyID string `json:"id_v1"`
|
||||
Metadata DeviceMetadata `json:"metadata"`
|
||||
Type string `json:"type"`
|
||||
|
||||
Mode *string `json:"mode"`
|
||||
|
||||
Owner *ResourceLink `json:"owner"`
|
||||
ProductData *DeviceProductData `json:"product_data"`
|
||||
Services []ResourceLink `json:"services"`
|
||||
Button *SensorButton `json:"button"`
|
||||
Power *LightPower `json:"on"`
|
||||
Color *LightColor `json:"color"`
|
||||
ColorTemperature *LightCT `json:"color_temperature"`
|
||||
Dimming *LightDimming `json:"dimming"`
|
||||
Dynamics *LightDynamics `json:"dynamics"`
|
||||
Alert *LightAlert `json:"alert"`
|
||||
PowerState *PowerState `json:"power_state"`
|
||||
Temperature *SensorTemperature `json:"temperature"`
|
||||
Motion *SensorMotion `json:"motion"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
|
||||
func (res *ResourceData) ServiceID(kind string) *string {
|
||||
for _, ptr := range res.Services {
|
||||
if ptr.Kind == kind {
|
||||
return &ptr.ID
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (res *ResourceData) ServiceIndex(kind string, id string) int {
|
||||
idx := 0
|
||||
for _, link := range res.Services {
|
||||
if link.ID == id {
|
||||
return idx
|
||||
} else if link.Kind == kind {
|
||||
idx += 1
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func (res *ResourceData) WithUpdate(update ResourceUpdate) *ResourceData {
|
||||
resCopy := *res
|
||||
|
||||
if update.Name != nil {
|
||||
resCopy.Metadata.Name = *update.Name
|
||||
}
|
||||
if update.Power != nil {
|
||||
cp := *resCopy.Power
|
||||
resCopy.Power = &cp
|
||||
resCopy.Power.On = *update.Power
|
||||
}
|
||||
if update.ColorXY != nil {
|
||||
cp := *resCopy.Color
|
||||
resCopy.Color = &cp
|
||||
resCopy.Color.XY = *update.ColorXY
|
||||
|
||||
if resCopy.ColorTemperature != nil {
|
||||
cp := *resCopy.ColorTemperature
|
||||
resCopy.ColorTemperature = &cp
|
||||
resCopy.ColorTemperature.Mirek = nil
|
||||
}
|
||||
}
|
||||
if update.Mirek != nil {
|
||||
cp := *resCopy.ColorTemperature
|
||||
resCopy.ColorTemperature = &cp
|
||||
mirek := *update.Mirek
|
||||
resCopy.ColorTemperature.Mirek = &mirek
|
||||
}
|
||||
if update.Brightness != nil {
|
||||
cp := *resCopy.Dimming
|
||||
resCopy.Dimming = &cp
|
||||
resCopy.Dimming.Brightness = *update.Brightness
|
||||
}
|
||||
|
||||
return &resCopy
|
||||
}
|
||||
|
||||
type SensorButton struct {
|
||||
LastEvent string `json:"last_event"`
|
||||
}
|
||||
|
||||
type SensorMotion struct {
|
||||
Motion bool `json:"motion"`
|
||||
Valid bool `json:"motion_valid"`
|
||||
}
|
||||
|
||||
type SensorTemperature struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
Valid bool `json:"temperature_valid"`
|
||||
}
|
||||
|
||||
type PowerState struct {
|
||||
BatteryState string `json:"battery_state"`
|
||||
BatteryLevel float64 `json:"battery_level"`
|
||||
}
|
||||
|
||||
type LightPower struct {
|
||||
On bool `json:"on"`
|
||||
}
|
||||
|
||||
type LightDimming struct {
|
||||
Brightness float64 `json:"brightness"`
|
||||
}
|
||||
|
||||
type LightColor struct {
|
||||
Gamut color.Gamut `json:"gamut"`
|
||||
GamutType string `json:"gamut_type"`
|
||||
XY color.XY `json:"xy"`
|
||||
}
|
||||
|
||||
type LightCT struct {
|
||||
Mirek *int `json:"mirek"`
|
||||
MirekSchema LightCTMirekSchema `json:"mirek_schema"`
|
||||
MirekValid bool `json:"mirek_valid"`
|
||||
}
|
||||
|
||||
type LightCTMirekSchema struct {
|
||||
MirekMaximum int `json:"mirek_maximum"`
|
||||
MirekMinimum int `json:"mirek_minimum"`
|
||||
}
|
||||
|
||||
type LightDynamics struct {
|
||||
Speed float64 `json:"speed"`
|
||||
SpeedValid bool `json:"speed_valid"`
|
||||
Status string `json:"status"`
|
||||
StatusValues []string `json:"status_values"`
|
||||
}
|
||||
|
||||
type LightAlert struct {
|
||||
ActionValues []string `json:"action_values"`
|
||||
}
|
||||
|
||||
type ResourceUpdate struct {
|
||||
Name *string
|
||||
Power *bool
|
||||
ColorXY *color.XY
|
||||
Brightness *float64
|
||||
Mirek *int
|
||||
TransitionDuration *time.Duration
|
||||
}
|
||||
|
||||
func (r ResourceUpdate) MarshalJSON() ([]byte, error) {
|
||||
chunks := make([]string, 0, 4)
|
||||
if r.Name != nil {
|
||||
s, _ := json.Marshal(*r.Name)
|
||||
chunks = append(chunks, fmt.Sprintf(`"metadata":{"name":%s}`, string(s)))
|
||||
}
|
||||
if r.Power != nil {
|
||||
chunks = append(chunks, fmt.Sprintf(`"on":{"on":%v}`, *r.Power))
|
||||
}
|
||||
if r.ColorXY != nil {
|
||||
chunks = append(chunks, fmt.Sprintf(`"color":{"xy":{"x":%f,"y":%f}}`, r.ColorXY.X, r.ColorXY.Y))
|
||||
}
|
||||
if r.Brightness != nil {
|
||||
chunks = append(chunks, fmt.Sprintf(`"dimming":{"brightness":%f}`, *r.Brightness))
|
||||
}
|
||||
if r.Mirek != nil {
|
||||
chunks = append(chunks, fmt.Sprintf(`"color_temperature":{"mirek":%d}`, *r.Mirek))
|
||||
}
|
||||
if r.TransitionDuration != nil {
|
||||
chunks = append(chunks, fmt.Sprintf(`"dynamics":{"duration":%d}`, r.TransitionDuration.Truncate(time.Millisecond*100).Milliseconds()))
|
||||
}
|
||||
|
||||
return []byte(fmt.Sprintf("{%s}", strings.Join(chunks, ","))), nil
|
||||
}
|
||||
|
||||
type ResourceLink struct {
|
||||
ID string `json:"rid"`
|
||||
Kind string `json:"rtype"`
|
||||
}
|
||||
|
||||
func (rl *ResourceLink) Path() string {
|
||||
return fmt.Sprintf("/clip/v2/resource/%s/%s", rl.Kind, rl.ID)
|
||||
}
|
||||
|
||||
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 DiscoveryEntry struct {
|
||||
Id string `json:"id"`
|
||||
InternalIPAddress string `json:"internalipaddress"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package hue2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
mu sync.Mutex
|
||||
bridges []*Bridge
|
||||
}
|
||||
|
||||
func (d *Driver) SearchBridge(ctx context.Context, address, token string, dryRun bool) ([]models.Bridge, error) {
|
||||
if address == "" {
|
||||
if !dryRun {
|
||||
return nil, lerrors.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.DTHue2,
|
||||
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.DTHue2,
|
||||
Address: address,
|
||||
Token: "",
|
||||
}
|
||||
|
||||
if !dryRun {
|
||||
client := NewClient(address, "")
|
||||
|
||||
timeout, cancel := context.WithTimeout(ctx, time.Second*30)
|
||||
defer cancel()
|
||||
|
||||
bridge.Token, err = client.Register(timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return []models.Bridge{bridge}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) {
|
||||
return d.ensureBridge(bridge).SearchDevices(ctx, timeout)
|
||||
}
|
||||
|
||||
func (d *Driver) ListDevices(_ context.Context, bridge models.Bridge) ([]models.Device, error) {
|
||||
return d.ensureBridge(bridge).GenerateDevices(), nil
|
||||
}
|
||||
|
||||
func (d *Driver) Publish(_ context.Context, bridge models.Bridge, devices []models.Device) error {
|
||||
d.ensureBridge(bridge).Update(devices...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error {
|
||||
return d.ensureBridge(bridge).Run(ctx, ch)
|
||||
}
|
||||
|
||||
func (d *Driver) ForgetDevice(ctx context.Context, bridge models.Bridge, device models.Device) error {
|
||||
return d.ensureBridge(bridge).Forget(ctx, device)
|
||||
}
|
||||
|
||||
func (d *Driver) ensureBridge(info models.Bridge) *Bridge {
|
||||
d.mu.Lock()
|
||||
for _, bridge := range d.bridges {
|
||||
if bridge.client.host == info.Address {
|
||||
d.mu.Unlock()
|
||||
return bridge
|
||||
}
|
||||
}
|
||||
bridge := NewBridge(NewClient(info.Address, info.Token))
|
||||
bridge.externalID = info.ID
|
||||
d.bridges = append(d.bridges, bridge)
|
||||
d.mu.Unlock()
|
||||
|
||||
return bridge
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package lifx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
"sync"
|
||||
@@ -20,7 +22,7 @@ type Bridge struct {
|
||||
func (b *Bridge) StartSearch(ctx context.Context) error {
|
||||
c := b.getClient()
|
||||
if c == nil {
|
||||
return models.ErrBridgeRunningRequired
|
||||
return lerrors.ErrBridgeRunningRequired
|
||||
}
|
||||
|
||||
_, err := c.HorribleBroadcast(ctx, &GetService{})
|
||||
@@ -129,9 +131,9 @@ func (b *Bridge) Run(ctx context.Context, debug bool) error {
|
||||
|
||||
for {
|
||||
target, seq, payload, err := client.Recv(time.Millisecond * 200)
|
||||
if err == models.ErrInvalidPacketSize || err == models.ErrPayloadTooShort || err == models.ErrUnrecognizedPacketType {
|
||||
if err == lerrors.ErrInvalidPacketSize || err == lerrors.ErrPayloadTooShort || err == lerrors.ErrUnrecognizedPacketType {
|
||||
log.Println("LIFX udp socket received something weird:", err)
|
||||
} else if err != nil && err != models.ErrReadTimeout {
|
||||
} else if err != nil && err != lerrors.ErrReadTimeout {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -168,10 +170,12 @@ func (b *Bridge) Run(ctx context.Context, debug bool) error {
|
||||
if state.deviceState == nil {
|
||||
state.deviceState = &models.DeviceState{
|
||||
Power: p.On,
|
||||
Color: models.ColorValue{
|
||||
Hue: p.Hue,
|
||||
Saturation: p.Sat,
|
||||
Kelvin: p.Kelvin,
|
||||
Color: color.Color{
|
||||
HS: &color.HueSat{
|
||||
Hue: p.Hue,
|
||||
Sat: p.Sat,
|
||||
},
|
||||
K: &p.Kelvin,
|
||||
},
|
||||
Intensity: p.Bri,
|
||||
}
|
||||
@@ -204,7 +208,7 @@ func (b *Bridge) Run(ctx context.Context, debug bool) error {
|
||||
b.checkAndUpdateState(state)
|
||||
}
|
||||
|
||||
if time.Since(state.discoveredTime) > time.Second*10 && time.Since(state.fwSpamTime) > time.Second * 30 {
|
||||
if time.Since(state.discoveredTime) > time.Second*10 && time.Since(state.fwSpamTime) > time.Second*30 {
|
||||
state.fwSpamTime = time.Now()
|
||||
|
||||
if state.firmware == nil {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
@@ -215,7 +215,7 @@ func (c *Client) Recv(timeout time.Duration) (target string, seq uint8, payload
|
||||
}
|
||||
if err != nil {
|
||||
if netErr, ok := err.(*net.OpError); ok && netErr.Timeout() {
|
||||
err = models.ErrReadTimeout
|
||||
err = lerrors.ErrReadTimeout
|
||||
return
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ func (c *Client) Recv(timeout time.Duration) (target string, seq uint8, payload
|
||||
|
||||
packet := Packet(c.buf[:n])
|
||||
if n < 2 || packet.Size() != n && packet.Protocol() != 1024 {
|
||||
err = models.ErrInvalidAddress
|
||||
err = lerrors.ErrInvalidAddress
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package lifx
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"log"
|
||||
"net"
|
||||
)
|
||||
@@ -60,7 +60,7 @@ func (p *Packet) SetTarget(v string) error {
|
||||
return err
|
||||
}
|
||||
if len(addr) != 6 {
|
||||
return models.ErrInvalidAddress
|
||||
return lerrors.ErrInvalidAddress
|
||||
}
|
||||
|
||||
copy((*p)[8:], addr)
|
||||
@@ -133,7 +133,7 @@ func (p *Packet) Payload() (res Payload, err error) {
|
||||
res = &SetLightPower{}
|
||||
err = res.Decode((*p)[36:])
|
||||
default:
|
||||
err = models.ErrUnrecognizedPacketType
|
||||
err = lerrors.ErrUnrecognizedPacketType
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,7 @@ package lifx
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
@@ -82,7 +82,7 @@ type StateHostFirmware struct {
|
||||
|
||||
func (p *StateHostFirmware) Decode(data []byte) error {
|
||||
if len(data) < 20 {
|
||||
return models.ErrPayloadTooShort
|
||||
return lerrors.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
ts := int64(binary.LittleEndian.Uint64(data[0:8]))
|
||||
@@ -123,7 +123,7 @@ type StateVersion struct {
|
||||
|
||||
func (p *StateVersion) Decode(data []byte) error {
|
||||
if len(data) < 8 {
|
||||
return models.ErrPayloadTooShort
|
||||
return lerrors.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
p.Vendor = binary.LittleEndian.Uint32(data[0:4])
|
||||
@@ -207,7 +207,7 @@ type SetColor struct {
|
||||
|
||||
func (p *SetColor) Decode(data []byte) error {
|
||||
if len(data) < 13 {
|
||||
return models.ErrPayloadTooShort
|
||||
return lerrors.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
hue := binary.LittleEndian.Uint16(data[1:3])
|
||||
@@ -263,7 +263,7 @@ type SetLightPower struct {
|
||||
|
||||
func (p *SetLightPower) Decode(data []byte) error {
|
||||
if len(data) < 6 {
|
||||
return models.ErrPayloadTooShort
|
||||
return lerrors.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
level := binary.LittleEndian.Uint16(data[0:2])
|
||||
@@ -314,7 +314,7 @@ func (p *StateService) String() string {
|
||||
|
||||
func (p *StateService) Decode(data []byte) error {
|
||||
if len(data) < 5 {
|
||||
return models.ErrPayloadTooShort
|
||||
return lerrors.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
p.Service = int(data[0])
|
||||
@@ -357,7 +357,7 @@ func (p *LightState) String() string {
|
||||
|
||||
func (p *LightState) Decode(data []byte) error {
|
||||
if len(data) < 52 {
|
||||
return models.ErrPayloadTooShort
|
||||
return lerrors.ErrPayloadTooShort
|
||||
}
|
||||
|
||||
hue := binary.LittleEndian.Uint16(data[0:2])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package lifx
|
||||
|
||||
import (
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"math"
|
||||
"time"
|
||||
@@ -36,17 +37,21 @@ func (s *State) generateUpdate() []Payload {
|
||||
return results
|
||||
}
|
||||
|
||||
c := s.deviceState.Color
|
||||
c, ok := s.deviceState.Color.ToHSK()
|
||||
if !ok {
|
||||
c, _ = color.Parse("hsk:0,0,4000")
|
||||
}
|
||||
|
||||
l := s.lightState
|
||||
di := s.deviceState.Intensity
|
||||
k := c.Kelvin
|
||||
k := *c.K
|
||||
if k == 0 {
|
||||
k = 4000
|
||||
}
|
||||
if !equalish(c.Hue, l.Hue) || !equalish(c.Saturation, l.Sat) || !equalish(di, l.Bri) || k != l.Kelvin {
|
||||
if !equalish(c.HS.Hue, l.Hue) || !equalish(c.HS.Sat, l.Sat) || !equalish(di, l.Bri) || k != l.Kelvin {
|
||||
results = append(results, &SetColor{
|
||||
Hue: c.Hue,
|
||||
Sat: c.Saturation,
|
||||
Hue: c.HS.Hue,
|
||||
Sat: c.HS.Sat,
|
||||
Bri: di,
|
||||
Kelvin: k,
|
||||
TransitionTime: time.Millisecond * 150,
|
||||
@@ -72,11 +77,16 @@ func (s *State) handleAck(seq uint8) {
|
||||
prevLabel = s.lightState.Label
|
||||
}
|
||||
|
||||
c, ok := s.deviceState.Color.ToHSK()
|
||||
if !ok {
|
||||
c, _ = color.Parse("hsk:0,0,4000")
|
||||
}
|
||||
|
||||
s.lightState = &LightState{
|
||||
Hue: s.deviceState.Color.Hue,
|
||||
Sat: s.deviceState.Color.Saturation,
|
||||
Hue: c.HS.Hue,
|
||||
Sat: c.HS.Sat,
|
||||
Bri: s.deviceState.Intensity,
|
||||
Kelvin: s.deviceState.Color.Kelvin,
|
||||
Kelvin: *c.K,
|
||||
On: s.deviceState.Power,
|
||||
Label: prevLabel,
|
||||
}
|
||||
|
||||
+103
-22
@@ -6,7 +6,9 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"io"
|
||||
"log"
|
||||
@@ -82,11 +84,16 @@ func (b *bridge) listDevices(ctx context.Context) ([]models.Device, error) {
|
||||
}
|
||||
|
||||
func (b *bridge) pushStateChange(ctx context.Context, deviceModel models.Device) error {
|
||||
if ip, ok := deviceModel.DriverProperties["ip"].(string); ok {
|
||||
return b.pushWifiStateChange(ctx, ip, deviceModel.State.Temperature)
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
if b.luciferMillIDMap == nil {
|
||||
b.luciferMillIDMap = make(map[int]int, 4)
|
||||
b.millLuciferIDMap = make(map[int]int, 4)
|
||||
}
|
||||
subDomain := deviceModel.DriverProperties["subDomain"].(string)
|
||||
|
||||
if b.luciferMillIDMap[deviceModel.ID] == 0 {
|
||||
millID, _ := strconv.Atoi(deviceModel.InternalID)
|
||||
@@ -99,28 +106,90 @@ func (b *bridge) pushStateChange(ctx context.Context, deviceModel models.Device)
|
||||
if deviceModel.State.Power {
|
||||
status = 1
|
||||
}
|
||||
powerReq := deviceControlReqBody{
|
||||
SubDomain: deviceModel.DriverProperties["subDomain"].(string),
|
||||
DeviceID: b.luciferMillIDMap[deviceModel.ID],
|
||||
TestStatus: 1,
|
||||
Status: status,
|
||||
|
||||
if subDomainIsGen2(subDomain) {
|
||||
powerReq := deviceControlReqBody{
|
||||
SubDomain: subDomain,
|
||||
DeviceID: b.luciferMillIDMap[deviceModel.ID],
|
||||
TestStatus: 1,
|
||||
Status: status,
|
||||
}
|
||||
err := b.command(ctx, "deviceControl", powerReq, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tempReq := changeInfoReqBody{
|
||||
DeviceID: b.luciferMillIDMap[deviceModel.ID],
|
||||
Value: deviceModel.State.Temperature,
|
||||
TimeZoneNum: "+02:00",
|
||||
Key: "holidayTemp",
|
||||
}
|
||||
err = b.command(ctx, "changeDeviceInfo", tempReq, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
sd, _ := strconv.Atoi(subDomain)
|
||||
|
||||
tempReq := deviceControlGen3Body{
|
||||
Operation: "SINGLE_CONTROL",
|
||||
Status: status,
|
||||
SubDomain: sd,
|
||||
DeviceId: b.luciferMillIDMap[deviceModel.ID],
|
||||
HoldTemp: deviceModel.State.Temperature,
|
||||
}
|
||||
err := b.command(ctx, "deviceControlGen3ForApp", tempReq, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
powerReq := deviceControlGen3Body{
|
||||
Operation: "SWITCH",
|
||||
Status: status,
|
||||
SubDomain: sd,
|
||||
DeviceId: b.luciferMillIDMap[deviceModel.ID],
|
||||
}
|
||||
err = b.command(ctx, "deviceControlGen3ForApp", powerReq, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err := b.command(ctx, "deviceControl", powerReq, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bridge) pushWifiStateChange(ctx context.Context, ip string, temperature int) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
data, err := json.Marshal(wifiSetTemperatureBody{
|
||||
Type: "Normal",
|
||||
Value: temperature,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tempReq := changeInfoReqBody{
|
||||
DeviceID: b.luciferMillIDMap[deviceModel.ID],
|
||||
Value: deviceModel.State.Temperature,
|
||||
TimeZoneNum: "+02:00",
|
||||
Key: "holidayTemp",
|
||||
}
|
||||
err = b.command(ctx, "changeDeviceInfo", tempReq, nil)
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
"POST",
|
||||
fmt.Sprintf("http://%s/set-temperature", ip),
|
||||
bytes.NewReader(data),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode > 299 {
|
||||
return errors.New("mill: negative response from " + ip)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -163,9 +232,9 @@ func (b *bridge) command(ctx context.Context, command string, payload interface{
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return models.ErrCannotForwardRequest
|
||||
return lerrors.ErrCannotForwardRequest
|
||||
} else if res.StatusCode != 200 {
|
||||
return models.ErrIncorrectToken
|
||||
return lerrors.ErrIncorrectToken
|
||||
}
|
||||
|
||||
if target == nil {
|
||||
@@ -174,7 +243,7 @@ func (b *bridge) command(ctx context.Context, command string, payload interface{
|
||||
|
||||
err = json.NewDecoder(res.Body).Decode(&target)
|
||||
if err != nil {
|
||||
return models.ErrUnexpectedResponse
|
||||
return lerrors.ErrUnexpectedResponse
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -190,27 +259,27 @@ func (b *bridge) authenticate(ctx context.Context) error {
|
||||
Password: b.password,
|
||||
})
|
||||
if err != nil {
|
||||
return models.ErrMissingToken
|
||||
return lerrors.ErrMissingToken
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", accountEndpoint + "login", bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", accountEndpoint+"login", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return models.ErrMissingToken
|
||||
return lerrors.ErrMissingToken
|
||||
}
|
||||
|
||||
addDefaultHeaders(req)
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return models.ErrCannotForwardRequest
|
||||
return lerrors.ErrCannotForwardRequest
|
||||
} else if res.StatusCode != 200 {
|
||||
return models.ErrIncorrectToken
|
||||
return lerrors.ErrIncorrectToken
|
||||
}
|
||||
|
||||
var resBody authResBody
|
||||
err = json.NewDecoder(res.Body).Decode(&resBody)
|
||||
if err != nil {
|
||||
return models.ErrBridgeSearchFailed
|
||||
return lerrors.ErrBridgeSearchFailed
|
||||
}
|
||||
|
||||
log.Printf("Mill: Authenticated as %s", resBody.NickName)
|
||||
@@ -238,3 +307,15 @@ func addDefaultHeaders(req *http.Request) {
|
||||
req.Header.Add("X-Zc-Seq-Id", "1")
|
||||
req.Header.Add("X-Zc-Version", "1")
|
||||
}
|
||||
|
||||
var gen2subDomains = []string{"863", "5316", "5317", "5332", "5333", "6933"}
|
||||
|
||||
func subDomainIsGen2(subDomain string) bool {
|
||||
for _, gen2sd := range gen2subDomains {
|
||||
if subDomain == gen2sd {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -65,6 +65,19 @@ type deviceControlReqBody struct {
|
||||
PowerLevel int `json:"powerLevel"`
|
||||
}
|
||||
|
||||
type deviceControlGen3Body struct {
|
||||
Operation string `json:"operation"`
|
||||
Status int `json:"status"`
|
||||
SubDomain int `json:"subDomain"`
|
||||
DeviceId int `json:"deviceId"`
|
||||
HoldTemp int `json:"holdTemp,omitempty"`
|
||||
}
|
||||
|
||||
type wifiSetTemperatureBody struct {
|
||||
Type string `json:"type"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
var location *time.Location
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -31,11 +32,12 @@ type bridge struct {
|
||||
func (b *bridge) Devices() []models.Device {
|
||||
results := make([]models.Device, 0, len(b.panels))
|
||||
for i, panel := range b.panels {
|
||||
// Find normalized RGB and intensity
|
||||
red := float64(panel.ColorRGBA[0]) / 255.0
|
||||
green := float64(panel.ColorRGBA[1]) / 255.0
|
||||
blue := float64(panel.ColorRGBA[2]) / 255.0
|
||||
|
||||
hue, sat, value := colorful.LinearRgb(red, green, blue).Hsv()
|
||||
hue, sat, value := colorful.Color{R: red, G: green, B: blue}.Hsv()
|
||||
rgb := colorful.Hsv(hue, sat, 1)
|
||||
|
||||
shapeType, shapeTypeOK := shapeTypeMap[panel.ShapeType]
|
||||
if !shapeTypeOK {
|
||||
@@ -70,10 +72,11 @@ func (b *bridge) Devices() []models.Device {
|
||||
UserProperties: nil,
|
||||
State: models.DeviceState{
|
||||
Power: panel.ColorRGBA[3] == 0,
|
||||
Color: models.ColorValue{
|
||||
Hue: math.Mod(hue, 360),
|
||||
Saturation: sat,
|
||||
},
|
||||
Color: color.Color{RGB: &color.RGB{
|
||||
Red: rgb.R,
|
||||
Green: rgb.G,
|
||||
Blue: rgb.B,
|
||||
}},
|
||||
Intensity: value,
|
||||
Temperature: 0,
|
||||
},
|
||||
@@ -139,9 +142,9 @@ func (b *bridge) Overview(ctx context.Context) (*Overview, error) {
|
||||
|
||||
switch res.StatusCode {
|
||||
case 400, 403, 500, 503:
|
||||
return nil, models.ErrUnexpectedResponse
|
||||
return nil, lerrors.ErrUnexpectedResponse
|
||||
case 401:
|
||||
return nil, models.ErrIncorrectToken
|
||||
return nil, lerrors.ErrIncorrectToken
|
||||
}
|
||||
|
||||
overview := Overview{}
|
||||
@@ -172,9 +175,20 @@ func (b *bridge) Update(devices []models.Device) {
|
||||
for _, panel := range b.panels {
|
||||
if panel.ID == uint16(id) {
|
||||
if device.State.Power {
|
||||
color := colorful.Hsv(device.State.Color.Hue, device.State.Color.Saturation, device.State.Intensity)
|
||||
rgbColor, ok := device.State.Color.ToRGB()
|
||||
if !ok {
|
||||
newColor := [4]byte{255, 255, 255, 255}
|
||||
if newColor != panel.ColorRGBA {
|
||||
panel.update(newColor, time.Now().Add(time.Millisecond*220))
|
||||
}
|
||||
|
||||
red, green, blue := color.RGB255()
|
||||
continue
|
||||
}
|
||||
|
||||
rgb := rgbColor.RGB.AtIntensity(device.State.Intensity)
|
||||
red := byte(rgb.Red * 255.9)
|
||||
green := byte(rgb.Green * 255.9)
|
||||
blue := byte(rgb.Blue * 255.9)
|
||||
newColor := [4]byte{red, green, blue, 255}
|
||||
if newColor != panel.ColorRGBA {
|
||||
panel.update(newColor, time.Now().Add(time.Millisecond*220))
|
||||
@@ -321,7 +335,7 @@ func (b *bridge) updateEffect(ctx context.Context) error {
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != 204 {
|
||||
return models.ErrUnexpectedResponse
|
||||
return lerrors.ErrUnexpectedResponse
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"net/http"
|
||||
"sync"
|
||||
@@ -30,7 +31,7 @@ func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun boo
|
||||
return nil, err
|
||||
}
|
||||
if deviceInfo.ModelNumber == "" {
|
||||
return nil, models.ErrUnexpectedResponse
|
||||
return nil, lerrors.ErrUnexpectedResponse
|
||||
}
|
||||
|
||||
token := ""
|
||||
@@ -47,7 +48,7 @@ func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun boo
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return nil, models.ErrBridgeSearchFailed
|
||||
return nil, lerrors.ErrBridgeSearchFailed
|
||||
}
|
||||
|
||||
tokenResponse := TokenResponse{}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package drivers
|
||||
|
||||
import "git.aiterp.net/lucifer/new-server/models"
|
||||
import (
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"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 nil, lerrors.ErrUnknownDriver
|
||||
}
|
||||
|
||||
return m[kind], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package models
|
||||
package lerrors
|
||||
|
||||
import "errors"
|
||||
|
||||
@@ -16,6 +16,7 @@ var ErrUnexpectedResponse = errors.New("driver api returned unexpected response
|
||||
var ErrBridgeSearchFailed = errors.New("bridge search failed")
|
||||
var ErrAddressOnlyDryRunnable = errors.New("this address may only be used for a dry run")
|
||||
var ErrCannotForwardRequest = errors.New("driver is not able to forward requests")
|
||||
var ErrCannotForget = errors.New("forget not supported on this bridge type")
|
||||
|
||||
var ErrInvalidAddress = errors.New("invalid mac address")
|
||||
var ErrPayloadTooShort = errors.New("payload too short")
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/jmoiron/sqlx"
|
||||
@@ -30,6 +31,7 @@ type deviceStateRecord struct {
|
||||
Power bool `db:"power"`
|
||||
Intensity float64 `db:"intensity"`
|
||||
Temperature int `db:"temperature"`
|
||||
Color string `db:"color"`
|
||||
}
|
||||
|
||||
type devicePropertyRecord struct {
|
||||
@@ -228,13 +230,14 @@ func (r *DeviceRepo) SaveMany(ctx context.Context, mode models.SaveMode, devices
|
||||
|
||||
if mode == 0 || mode&models.SMState != 0 {
|
||||
_, err = tx.NamedExecContext(ctx, `
|
||||
REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity, temperature)
|
||||
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity, :temperature)
|
||||
REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity, color, temperature)
|
||||
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity, :color, :temperature)
|
||||
`, deviceStateRecord{
|
||||
DeviceID: record.ID,
|
||||
Hue: device.State.Color.Hue,
|
||||
Saturation: device.State.Color.Saturation,
|
||||
Kelvin: device.State.Color.Kelvin,
|
||||
Hue: 40,
|
||||
Saturation: 0,
|
||||
Kelvin: 0,
|
||||
Color: device.State.Color.String(),
|
||||
Power: device.State.Power,
|
||||
Intensity: device.State.Intensity,
|
||||
Temperature: device.State.Temperature,
|
||||
@@ -373,14 +376,12 @@ func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]mo
|
||||
|
||||
for _, state := range states {
|
||||
if state.DeviceID == record.ID {
|
||||
color, _ := color.Parse(state.Color)
|
||||
|
||||
device.State = models.DeviceState{
|
||||
Power: state.Power,
|
||||
Color: models.ColorValue{
|
||||
Hue: state.Hue,
|
||||
Saturation: state.Saturation,
|
||||
Kelvin: state.Kelvin,
|
||||
},
|
||||
Intensity: state.Intensity,
|
||||
Power: state.Power,
|
||||
Color: color,
|
||||
Intensity: state.Intensity,
|
||||
Temperature: state.Temperature,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
@@ -12,6 +13,7 @@ type presetRecord struct {
|
||||
Hue float64 `db:"hue"`
|
||||
Saturation float64 `db:"saturation"`
|
||||
Kelvin int `db:"kelvin"`
|
||||
Value string `db:"value"`
|
||||
}
|
||||
|
||||
type ColorPresetRepo struct {
|
||||
@@ -42,8 +44,8 @@ func (c *ColorPresetRepo) Save(ctx context.Context, preset *models.ColorPreset)
|
||||
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,
|
||||
"UPDATE color_preset SET name = ?, value = ? WHERE id = ?",
|
||||
preset.Name, preset.Value.String(), preset.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -52,8 +54,8 @@ func (c *ColorPresetRepo) Save(ctx context.Context, preset *models.ColorPreset)
|
||||
} 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,
|
||||
"INSERT INTO color_preset (name, value, hue, saturation, kelvin) VALUES (?, ?, 0, 0, 0)",
|
||||
preset.Name, preset.Value.String(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -84,14 +86,12 @@ func (c *ColorPresetRepo) Delete(ctx context.Context, preset *models.ColorPreset
|
||||
func (c *ColorPresetRepo) fromRecords(records ...presetRecord) []models.ColorPreset {
|
||||
newList := make([]models.ColorPreset, len(records), len(records))
|
||||
for i, record := range records {
|
||||
color, _ := color.Parse(record.Value)
|
||||
|
||||
newList[i] = models.ColorPreset{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
Value: models.ColorValue{
|
||||
Hue: record.Hue,
|
||||
Saturation: record.Saturation,
|
||||
Kelvin: record.Kelvin,
|
||||
},
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
Value: color,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,16 @@ func (r *SceneRepo) Find(ctx context.Context, id int) (*models.Scene, error) {
|
||||
return r.populateOne(&scene)
|
||||
}
|
||||
|
||||
func (r *SceneRepo) FindName(ctx context.Context, name string) (*models.Scene, error) {
|
||||
var scene sceneRecord
|
||||
err := r.DBX.GetContext(ctx, &scene, "SELECT * FROM scene WHERE name = ?", name)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
|
||||
return r.populateOne(&scene)
|
||||
}
|
||||
|
||||
func (r *SceneRepo) FetchAll(ctx context.Context) ([]models.Scene, error) {
|
||||
scenes := make([]sceneRecord, 0, 8)
|
||||
err := r.DBX.SelectContext(ctx, &scenes, "SELECT * FROM scene")
|
||||
|
||||
@@ -2,16 +2,16 @@ package mysql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"log"
|
||||
)
|
||||
|
||||
func dbErr(err error) error {
|
||||
if err == sql.ErrNoRows {
|
||||
return models.ErrNotFound
|
||||
return lerrors.ErrNotFound
|
||||
} else if err != nil {
|
||||
log.Printf("Internal error: %s", err.Error())
|
||||
return models.ErrInternal
|
||||
return lerrors.ErrInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -21,6 +21,7 @@ type DriverKind string
|
||||
|
||||
var (
|
||||
DTHue DriverKind = "Hue"
|
||||
DTHue2 DriverKind = "Hue2"
|
||||
DTNanoLeaf DriverKind = "Nanoleaf"
|
||||
DTLIFX DriverKind = "LIFX"
|
||||
DTMill DriverKind = "Mill"
|
||||
|
||||
@@ -2,13 +2,14 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ColorPreset struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value ColorValue `json:"value"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value color.Color `json:"value"`
|
||||
}
|
||||
|
||||
type ColorPresetRepository interface {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ColorValue struct {
|
||||
Hue float64 `json:"h,omitempty"` // 0..360
|
||||
Saturation float64 `json:"s,omitempty"` // 0..=1
|
||||
Kelvin int `json:"kelvin,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ColorValue) IsHueSat() bool {
|
||||
return !c.IsKelvin()
|
||||
}
|
||||
|
||||
func (c *ColorValue) IsKelvin() bool {
|
||||
return c.Kelvin > 0
|
||||
}
|
||||
|
||||
func (c *ColorValue) String() string {
|
||||
if c.Kelvin > 0 {
|
||||
return fmt.Sprintf("k:%d", c.Kelvin)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("hs:%.4g,%.3g", c.Hue, c.Saturation)
|
||||
}
|
||||
|
||||
|
||||
|
||||
func ParseColorValue(raw string) (ColorValue, error) {
|
||||
tokens := strings.SplitN(raw, ":", 2)
|
||||
if len(tokens) != 2 {
|
||||
return ColorValue{}, ErrBadInput
|
||||
}
|
||||
|
||||
if tokens[0] == "kelvin" || tokens[0] == "k" {
|
||||
parsedPart, err := strconv.Atoi(tokens[1])
|
||||
if err != nil {
|
||||
return ColorValue{}, ErrBadInput
|
||||
}
|
||||
|
||||
return ColorValue{Kelvin: parsedPart}, nil
|
||||
}
|
||||
|
||||
if tokens[0] == "hs" {
|
||||
parts := strings.Split(tokens[1], ",")
|
||||
if len(parts) < 2 {
|
||||
return ColorValue{}, ErrUnknownColorFormat
|
||||
}
|
||||
|
||||
part1, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
part2, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return ColorValue{}, ErrBadInput
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+22
-15
@@ -2,6 +2,8 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/internal/color"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -34,10 +36,10 @@ type DeviceUpdate struct {
|
||||
// - Intensity: e.g. brightness, range 0..=1
|
||||
// - Temperature: e.g. for thermostats
|
||||
type DeviceState struct {
|
||||
Power bool `json:"power"`
|
||||
Color ColorValue `json:"color,omitempty"`
|
||||
Intensity float64 `json:"intensity,omitempty"`
|
||||
Temperature int `json:"temperature"`
|
||||
Power bool `json:"power"`
|
||||
Color color.Color `json:"color,omitempty"`
|
||||
Intensity float64 `json:"intensity,omitempty"`
|
||||
Temperature int `json:"temperature"`
|
||||
}
|
||||
|
||||
type DeviceScene struct {
|
||||
@@ -83,9 +85,11 @@ func DeviceCapabilitiesToStrings(caps []DeviceCapability) []string {
|
||||
|
||||
var (
|
||||
DCPower DeviceCapability = "Power"
|
||||
DCColorHS DeviceCapability = "ColorHS"
|
||||
DCColorHS DeviceCapability = "HueSat"
|
||||
DCColorHSK DeviceCapability = "ColorHSK"
|
||||
DCColorKelvin DeviceCapability = "ColorKelvin"
|
||||
DCColorXY DeviceCapability = "XY"
|
||||
DCColorRGB DeviceCapability = "RGB"
|
||||
DCButtons DeviceCapability = "Buttons"
|
||||
DCPresence DeviceCapability = "Presence"
|
||||
DCIntensity DeviceCapability = "Intensity"
|
||||
@@ -126,7 +130,7 @@ func (d *Device) ApplyUpdate(update DeviceUpdate) {
|
||||
func (d *Device) Validate() error {
|
||||
d.Name = strings.Trim(d.Name, " \t\n ")
|
||||
if d.Name == "" {
|
||||
return ErrInvalidName
|
||||
return lerrors.ErrInvalidName
|
||||
}
|
||||
|
||||
newCaps := make([]DeviceCapability, 0, len(d.Capabilities))
|
||||
@@ -184,12 +188,19 @@ func (d *Device) SetState(newState NewDeviceState) error {
|
||||
}
|
||||
|
||||
if newState.Color != nil {
|
||||
parsed, err := ParseColorValue(*newState.Color)
|
||||
parsed, err := color.Parse(*newState.Color)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if (parsed.IsKelvin() && d.HasCapability(DCColorKelvin, DCColorHSK)) || (parsed.IsHueSat() && d.HasCapability(DCColorHS)) {
|
||||
switch {
|
||||
case (parsed.RGB != nil || parsed.XY != nil || parsed.HS != nil) || d.HasCapability(DCColorHS, DCColorXY, DCColorRGB):
|
||||
d.State.Color = parsed
|
||||
case parsed.K != nil && d.HasCapability(DCColorKelvin, DCColorHSK):
|
||||
if !d.HasCapability(DCColorKelvin) {
|
||||
d.State.Color.HS = &color.HueSat{Hue: 0, Sat: 0}
|
||||
}
|
||||
|
||||
d.State.Color = parsed
|
||||
}
|
||||
}
|
||||
@@ -228,14 +239,10 @@ func (s *NewDeviceState) Interpolate(other NewDeviceState, fac float64) NewDevic
|
||||
}
|
||||
|
||||
if s.Color != nil && other.Color != nil {
|
||||
sc, err := ParseColorValue(*s.Color)
|
||||
oc, err2 := ParseColorValue(*other.Color)
|
||||
sc, err := color.Parse(*s.Color)
|
||||
oc, err2 := color.Parse(*other.Color)
|
||||
if err == nil && err2 == nil {
|
||||
rc := ColorValue{}
|
||||
rc.Hue = interpolateFloat(sc.Hue, oc.Hue, fac)
|
||||
rc.Saturation = interpolateFloat(sc.Saturation, oc.Saturation, fac)
|
||||
rc.Kelvin = interpolateInt(sc.Kelvin, oc.Kelvin, fac)
|
||||
|
||||
rc := sc.Interpolate(oc, fac)
|
||||
rcStr := rc.String()
|
||||
n.Color = &rcStr
|
||||
}
|
||||
|
||||
@@ -16,3 +16,7 @@ type Driver interface {
|
||||
Publish(ctx context.Context, bridge Bridge, devices []Device) error
|
||||
Run(ctx context.Context, bridge Bridge, ch chan<- Event) error
|
||||
}
|
||||
|
||||
type ForgettableDriver interface {
|
||||
ForgetDevice(ctx context.Context, bridge Bridge, device Device) error
|
||||
}
|
||||
|
||||
+10
-7
@@ -2,6 +2,7 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.aiterp.net/lucifer/new-server/internal/lerrors"
|
||||
"math"
|
||||
"math/rand"
|
||||
"sort"
|
||||
@@ -18,10 +19,10 @@ type Scene struct {
|
||||
|
||||
func (s *Scene) Validate() error {
|
||||
if s.IntervalMS < 0 {
|
||||
return ErrSceneInvalidInterval
|
||||
return lerrors.ErrSceneInvalidInterval
|
||||
}
|
||||
if len(s.Roles) == 0 {
|
||||
return ErrSceneNoRoles
|
||||
return lerrors.ErrSceneNoRoles
|
||||
}
|
||||
|
||||
for _, role := range s.Roles {
|
||||
@@ -118,31 +119,31 @@ func (d *SceneRunContext) IntervalFac() float64 {
|
||||
|
||||
func (r *SceneRole) Validate() error {
|
||||
if len(r.States) == 0 {
|
||||
return ErrSceneRoleNoStates
|
||||
return lerrors.ErrSceneRoleNoStates
|
||||
}
|
||||
|
||||
switch r.TargetKind {
|
||||
case RKTag, RKBridgeID, RKDeviceID, RKName, RKAll:
|
||||
default:
|
||||
return ErrBadInput
|
||||
return lerrors.ErrBadInput
|
||||
}
|
||||
|
||||
switch r.PowerMode {
|
||||
case SPScene, SPDevice, SPBoth:
|
||||
default:
|
||||
return ErrSceneRoleUnknownPowerMode
|
||||
return lerrors.ErrSceneRoleUnknownPowerMode
|
||||
}
|
||||
|
||||
switch r.Effect {
|
||||
case SEStatic, SERandom, SEGradient, SEWalkingGradient, SETransition, SEMotion, SETemperature:
|
||||
default:
|
||||
return ErrSceneRoleUnknownEffect
|
||||
return lerrors.ErrSceneRoleUnknownEffect
|
||||
}
|
||||
|
||||
switch r.Order {
|
||||
case "", "-name", "name", "+name", "-id", "id", "+id":
|
||||
default:
|
||||
return ErrSceneRoleUnsupportedOrdering
|
||||
return lerrors.ErrSceneRoleUnsupportedOrdering
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -299,6 +300,7 @@ const (
|
||||
// instance is created for the group.
|
||||
type DeviceSceneAssignment struct {
|
||||
SceneID int `json:"sceneId"`
|
||||
SceneName string `json:"sceneName"`
|
||||
Group string `json:"group"`
|
||||
StartTime time.Time `json:"start"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
@@ -306,6 +308,7 @@ type DeviceSceneAssignment struct {
|
||||
|
||||
type SceneRepository interface {
|
||||
Find(ctx context.Context, id int) (*Scene, error)
|
||||
FindName(ctx context.Context, name string) (*Scene, error)
|
||||
FetchAll(ctx context.Context) ([]Scene, error)
|
||||
Save(ctx context.Context, bridge *Scene) error
|
||||
Delete(ctx context.Context, bridge *Scene) error
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE UNIQUE INDEX scene_name ON scene (name);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP INDEX IF EXISTS scene_name;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
TRUNCATE color_preset;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
SELECT 'down SQL query';
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE color_preset ADD COLUMN value VARCHAR(255) NOT NULL DEFAULT 'hs:0,0';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE color_preset DROP COLUMN value;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE device_state ADD COLUMN color VARCHAR(255) NOT NULL DEFAULT 'hs:0,0';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE device_state DROP COLUMN IF EXISTS color;
|
||||
-- +goose StatementEnd
|
||||
Reference in New Issue
Block a user