Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
582bf3f46b | ||
|
|
c72a7c3f43 | ||
|
|
8a20bab709 | ||
|
|
27fd858c04 | ||
|
|
d19a8e6ca0 | ||
|
|
107b29620d | ||
|
|
73e6cba770 | ||
|
|
44bd327a71 | ||
|
|
cedd0d1d28 | ||
|
|
7471102413 | ||
|
|
bf4d000455 | ||
|
|
6947fc3461 |
+62
-7
@@ -7,6 +7,7 @@ import (
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -217,14 +218,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 +280,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, models.ErrCannotForget
|
||||
}
|
||||
err = forgettableDriver.ForgetDevice(ctxOf(c), bridge, *device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
err = config.DeviceRepository().Delete(ctxOf(c), device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}))
|
||||
}
|
||||
|
||||
func withSceneState(devices []models.Device) []models.Device {
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
+2
-2
@@ -37,8 +37,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)
|
||||
}
|
||||
|
||||
+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,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/internal/drivers/hue2"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client := hue2.NewClient("10.80.8.8", "o2XKGgmVUGNBghYFdLUCVuinOTMxFH4pHV9PuTbU")
|
||||
bridge := hue2.NewBridge(client)
|
||||
|
||||
err := bridge.RefreshAll(context.Background())
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
j, _ := json.Marshal(bridge.GenerateDevices())
|
||||
fmt.Println(string(j))
|
||||
|
||||
ch := make(chan models.Event)
|
||||
go func() {
|
||||
for event := range ch {
|
||||
log.Println("EVENT", event.Name, event.Payload)
|
||||
}
|
||||
}()
|
||||
|
||||
for i, dev := range bridge.GenerateDevices() {
|
||||
device := dev
|
||||
switch device.InternalID {
|
||||
case "6d5a45b0-ec69-4417-8588-717358b05086":
|
||||
c, _ := models.ParseColorValue("xy:0.22,0.18")
|
||||
device.State.Color = c
|
||||
device.State.Intensity = 0.3
|
||||
case "a71128f4-5295-4ae4-9fbc-5541abc8739b":
|
||||
c, _ := models.ParseColorValue("k:6500")
|
||||
device.State.Color = c
|
||||
device.State.Intensity = 0.2
|
||||
}
|
||||
|
||||
device.ID = i + 1
|
||||
bridge.Update(device)
|
||||
}
|
||||
|
||||
err = bridge.Run(context.Background(), ch)
|
||||
log.Println(err)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type Bridge struct {
|
||||
externalID int
|
||||
lightStates []*hueLightState
|
||||
sensorStates []*hueSensorState
|
||||
quarantine map[string]time.Time
|
||||
syncingPublish uint32
|
||||
}
|
||||
|
||||
@@ -34,6 +35,10 @@ func (b *Bridge) Refresh(ctx context.Context) error {
|
||||
|
||||
b.mu.Lock()
|
||||
for index, light := range lightMap {
|
||||
if time.Now().Before(b.quarantine[light.Uniqueid]) {
|
||||
continue
|
||||
}
|
||||
|
||||
var state *hueLightState
|
||||
for _, existingState := range b.lightStates {
|
||||
if existingState.index == index {
|
||||
@@ -68,6 +73,10 @@ func (b *Bridge) Refresh(ctx context.Context) error {
|
||||
|
||||
b.mu.Lock()
|
||||
for index, sensor := range sensorMap {
|
||||
if time.Now().Before(b.quarantine[sensor.UniqueID]) {
|
||||
continue
|
||||
}
|
||||
|
||||
var state *hueSensorState
|
||||
for _, existingState := range b.sensorStates {
|
||||
if existingState.index == index {
|
||||
@@ -137,6 +146,44 @@ func (b *Bridge) SyncStale(ctx context.Context) error {
|
||||
return eg.Wait()
|
||||
}
|
||||
|
||||
func (b *Bridge) ForgetDevice(ctx context.Context, device models.Device) error {
|
||||
// Find index
|
||||
b.mu.Lock()
|
||||
found := false
|
||||
index := -1
|
||||
for i, ls := range b.lightStates {
|
||||
if ls.uniqueID == device.InternalID {
|
||||
found = true
|
||||
index = i
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if !found {
|
||||
return models.ErrNotFound
|
||||
}
|
||||
|
||||
// Delete light from bridge
|
||||
err := b.deleteLight(ctx, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove light state from local list. I don't know if the quarantine is necessary, but let's have it anyway.
|
||||
b.mu.Lock()
|
||||
for i, ls := range b.lightStates {
|
||||
if ls.uniqueID == device.InternalID {
|
||||
b.lightStates = append(b.lightStates[:i], b.lightStates[i+1:]...)
|
||||
}
|
||||
}
|
||||
if b.quarantine == nil {
|
||||
b.quarantine = make(map[string]time.Time, 1)
|
||||
}
|
||||
b.quarantine[device.InternalID] = time.Now().Add(time.Second * 30)
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bridge) SyncSensors(ctx context.Context) ([]models.Event, error) {
|
||||
sensorMap, err := b.getSensors(ctx)
|
||||
if err != nil {
|
||||
@@ -175,6 +222,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)
|
||||
|
||||
@@ -322,8 +322,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 +372,15 @@ func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) ForgetDevice(ctx context.Context, bridge models.Bridge, device models.Device) error {
|
||||
b, err := d.ensureBridge(ctx, bridge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.ForgetDevice(ctx, device)
|
||||
}
|
||||
|
||||
func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*Bridge, error) {
|
||||
d.mu.Lock()
|
||||
for _, bridge := range d.bridges {
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
package hue2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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*10 {
|
||||
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]
|
||||
|
||||
// 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.Kelvin
|
||||
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 xy, ok := device.State.Color.ToXY(); ok && light.Color != nil {
|
||||
xy = light.Color.Gamut.Conform(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 = models.ColorValue{
|
||||
Kelvin: int(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 = models.ColorValue{
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package hue2
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"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 "", models.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) 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) 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 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/models"
|
||||
"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 models.ColorGamut `json:"gamut"`
|
||||
GamutType string `json:"gamut_type"`
|
||||
XY models.ColorXY `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 *models.ColorXY
|
||||
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,116 @@
|
||||
package hue2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"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, models.ErrAddressOnlyDryRunnable
|
||||
}
|
||||
|
||||
res, err := http.Get("https://discovery.meethue.com")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
entries := make([]DiscoveryEntry, 0, 8)
|
||||
err = json.NewDecoder(res.Body).Decode(&entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bridges := make([]models.Bridge, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
bridges = append(bridges, models.Bridge{
|
||||
ID: -1,
|
||||
Name: entry.Id,
|
||||
Driver: models.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) 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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -21,6 +21,7 @@ type DriverKind string
|
||||
|
||||
var (
|
||||
DTHue DriverKind = "Hue"
|
||||
DTHue2 DriverKind = "Hue2"
|
||||
DTNanoLeaf DriverKind = "Nanoleaf"
|
||||
DTLIFX DriverKind = "LIFX"
|
||||
DTMill DriverKind = "Mill"
|
||||
|
||||
+83
-39
@@ -8,9 +8,14 @@ import (
|
||||
)
|
||||
|
||||
type ColorValue struct {
|
||||
Hue float64 `json:"h,omitempty"` // 0..360
|
||||
Saturation float64 `json:"s,omitempty"` // 0..=1
|
||||
Kelvin int `json:"kelvin,omitempty"`
|
||||
Hue float64 `json:"h,omitempty"` // 0..360
|
||||
Saturation float64 `json:"s,omitempty"` // 0..=1
|
||||
Kelvin int `json:"kelvin,omitempty"`
|
||||
XY *ColorXY `json:"xy,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ColorValue) IsEmpty() bool {
|
||||
return c.XY == nil && c.Kelvin == 0 && c.Saturation == 0 && c.Hue == 0
|
||||
}
|
||||
|
||||
func (c *ColorValue) IsHueSat() bool {
|
||||
@@ -18,7 +23,27 @@ func (c *ColorValue) IsHueSat() bool {
|
||||
}
|
||||
|
||||
func (c *ColorValue) IsKelvin() bool {
|
||||
return c.Kelvin > 0
|
||||
return !c.IsXY() && c.Kelvin > 0
|
||||
}
|
||||
|
||||
func (c *ColorValue) IsXY() bool {
|
||||
return c.XY != nil
|
||||
}
|
||||
|
||||
// ToXY converts the color to XY if possible. If the color already is XY, it returns
|
||||
// a copy of its held value. There are no guarantees of conforming to a gamut, however.
|
||||
func (c *ColorValue) ToXY() (xy ColorXY, ok bool) {
|
||||
if c.XY != nil {
|
||||
xy = *c.XY
|
||||
ok = true
|
||||
} else if c.Kelvin > 0 && c.Hue < 0.001 && c.Saturation <= 0.001 {
|
||||
ok = false
|
||||
} else {
|
||||
xy = hsToXY(c.Hue, c.Saturation)
|
||||
ok = true
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *ColorValue) String() string {
|
||||
@@ -29,56 +54,75 @@ func (c *ColorValue) String() string {
|
||||
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
|
||||
switch tokens[0] {
|
||||
case "kelvin", "k":
|
||||
{
|
||||
parsedPart, err := strconv.Atoi(tokens[1])
|
||||
if err != nil {
|
||||
return ColorValue{}, ErrBadInput
|
||||
}
|
||||
|
||||
return ColorValue{Kelvin: parsedPart}, nil
|
||||
}
|
||||
|
||||
return ColorValue{Kelvin: parsedPart}, nil
|
||||
}
|
||||
case "xy":
|
||||
{
|
||||
parts := strings.Split(tokens[1], ",")
|
||||
if len(parts) < 2 {
|
||||
return ColorValue{}, ErrUnknownColorFormat
|
||||
}
|
||||
|
||||
if tokens[0] == "hs" {
|
||||
parts := strings.Split(tokens[1], ",")
|
||||
if len(parts) < 2 {
|
||||
return ColorValue{}, ErrUnknownColorFormat
|
||||
x, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
y, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return ColorValue{}, ErrBadInput
|
||||
}
|
||||
|
||||
return ColorValue{XY: &ColorXY{X: x, Y: y}}, nil
|
||||
}
|
||||
|
||||
part1, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
part2, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return ColorValue{}, ErrBadInput
|
||||
case "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
|
||||
}
|
||||
|
||||
return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil
|
||||
}
|
||||
case "hsk":
|
||||
{
|
||||
parts := strings.Split(tokens[1], ",")
|
||||
if len(parts) < 3 {
|
||||
return ColorValue{}, ErrUnknownColorFormat
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
"math"
|
||||
)
|
||||
|
||||
const eps = 0.0001
|
||||
const epsSquare = eps * eps
|
||||
|
||||
type ColorGamut struct {
|
||||
Red ColorXY `json:"red"`
|
||||
Green ColorXY `json:"green"`
|
||||
Blue ColorXY `json:"blue"`
|
||||
}
|
||||
|
||||
func (cg *ColorGamut) side(x1, y1, x2, y2, x, y float64) float64 {
|
||||
return (y2-y1)*(x-x1) + (-x2+x1)*(y-y1)
|
||||
}
|
||||
|
||||
func (cg *ColorGamut) naiveContains(color ColorXY) 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 *ColorGamut) 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 *ColorGamut) isInBounds(color ColorXY) bool {
|
||||
x, y := color.X, color.Y
|
||||
xMin, xMax, yMin, yMax := cg.getBounds()
|
||||
|
||||
return !(x < xMin || xMax < x || y < yMin || yMax < y)
|
||||
}
|
||||
|
||||
func (cg *ColorGamut) 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 *ColorGamut) atTheEdge(color ColorXY) 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 *ColorGamut) Contains(color ColorXY) bool {
|
||||
if cg == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return cg.isInBounds(color) && (cg.naiveContains(color) || cg.atTheEdge(color))
|
||||
}
|
||||
|
||||
func (cg *ColorGamut) Conform(color ColorXY) ColorXY {
|
||||
if cg.Contains(color) {
|
||||
return color
|
||||
}
|
||||
|
||||
var best *ColorXY
|
||||
|
||||
xMin, xMax, yMin, yMax := cg.getBounds()
|
||||
|
||||
for x := xMin; x < xMax; x += 0.001 {
|
||||
for y := yMin; y < yMax; y += 0.001 {
|
||||
color2 := ColorXY{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 := ColorXY{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 := ColorXY{X: x, Y: y}
|
||||
|
||||
if cg.atTheEdge(color2) {
|
||||
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
|
||||
best = &color2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return *best
|
||||
}
|
||||
|
||||
type ColorXY struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
func (xy ColorXY) DistanceTo(other ColorXY) float64 {
|
||||
return math.Sqrt(math.Pow(xy.X-other.X, 2) + math.Pow(xy.Y-other.Y, 2))
|
||||
}
|
||||
|
||||
func (xy ColorXY) Round() ColorXY {
|
||||
return ColorXY{
|
||||
X: math.Round(xy.X*10000) / 10000,
|
||||
Y: math.Round(xy.Y*10000) / 10000,
|
||||
}
|
||||
}
|
||||
|
||||
func hsToXY(hue, sat float64) ColorXY {
|
||||
c := colorful.Hsv(hue, sat, 1)
|
||||
red255, green255, blue255 := c.RGB255()
|
||||
red := float64(red255) / 255.0
|
||||
green := float64(green255) / 255.0
|
||||
blue := float64(blue255) / 255.0
|
||||
|
||||
return rgbToXY(red, green, blue)
|
||||
}
|
||||
|
||||
func rgbToXY(red float64, green float64, blue float64) ColorXY {
|
||||
x := red*0.649926 + green*0.103455 + blue*0.197109
|
||||
y := red*0.234327 + green*0.743075 + blue*0.022598
|
||||
z := green*0.053077 + blue*1.035763
|
||||
|
||||
return ColorXY{
|
||||
X: x / (x + y + z),
|
||||
Y: y / (x + y + z),
|
||||
}
|
||||
}
|
||||
|
||||
func screenRGBToXY(red, green, blue float64) ColorXY {
|
||||
for _, component := range []*float64{&red, &green, &blue} {
|
||||
if *component > 0.04045 {
|
||||
*component = math.Pow((*component+0.055)/(1.055), 2.4)
|
||||
} else {
|
||||
*component /= 12.92
|
||||
}
|
||||
}
|
||||
|
||||
return rgbToXY(red, green, blue)
|
||||
}
|
||||
@@ -86,6 +86,7 @@ var (
|
||||
DCColorHS DeviceCapability = "ColorHS"
|
||||
DCColorHSK DeviceCapability = "ColorHSK"
|
||||
DCColorKelvin DeviceCapability = "ColorKelvin"
|
||||
DCColorXY DeviceCapability = "ColorXY"
|
||||
DCButtons DeviceCapability = "Buttons"
|
||||
DCPresence DeviceCapability = "Presence"
|
||||
DCIntensity DeviceCapability = "Intensity"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -299,6 +299,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 +307,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
|
||||
Reference in New Issue
Block a user