This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
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))
|
||||||
|
|
||||||
|
for _, device := range bridge.GenerateDevices() {
|
||||||
|
switch device.InternalID {
|
||||||
|
case "6d5a45b0-ec69-4417-8588-717358b05086":
|
||||||
|
c, _ := models.ParseColorValue("xy:0.22,0.18")
|
||||||
|
device.State.Color = c
|
||||||
|
device.State.Intensity = 1
|
||||||
|
bridge.Update(device)
|
||||||
|
case "a71128f4-5295-4ae4-9fbc-5541abc8739b":
|
||||||
|
c, _ := models.ParseColorValue("k:2000")
|
||||||
|
device.State.Color = c
|
||||||
|
device.State.Intensity = 0.2
|
||||||
|
bridge.Update(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(bridge.MakeCongruent(context.Background()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package hue2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Bridge struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
client *Client
|
||||||
|
devices map[string]models.Device
|
||||||
|
resources map[string]*ResourceData
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBridge(client *Client) *Bridge {
|
||||||
|
return &Bridge{
|
||||||
|
client: client,
|
||||||
|
devices: make(map[string]models.Device, 64),
|
||||||
|
resources: make(map[string]*ResourceData, 256),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Update(devices ...models.Device) {
|
||||||
|
b.mu.Lock()
|
||||||
|
for _, device := range devices {
|
||||||
|
b.devices[device.InternalID] = device
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
dur := time.Millisecond * 200
|
||||||
|
updates := make(map[string]ResourceUpdate)
|
||||||
|
for _, device := range b.devices {
|
||||||
|
resource := b.resources[device.InternalID]
|
||||||
|
if lightID := resource.ServiceID("light"); lightID != nil {
|
||||||
|
light := b.resources[*lightID]
|
||||||
|
update := ResourceUpdate{TransitionDuration: &dur}
|
||||||
|
changed := false
|
||||||
|
|
||||||
|
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.00015 || (light.ColorTemperature != nil && light.ColorTemperature.Mirek != nil) {
|
||||||
|
update.ColorXY = &xy
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if light.Power != nil && light.Power.On != device.State.Power {
|
||||||
|
update.Power = &device.State.Power
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if light.Dimming != nil && math.Abs(light.Dimming.Brightness/100-device.State.Intensity) > 0.005 {
|
||||||
|
brightness := math.Abs(math.Min(device.State.Intensity*100, 100))
|
||||||
|
update.Brightness = &brightness
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
updates["light/"+light.ID] = update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(updates), eg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package hue2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewClient(host, token string) *Client {
|
||||||
|
ch := make(chan struct{}, 5)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
ch <- struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
token: token,
|
||||||
|
ch: ch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
token string
|
||||||
|
ch chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) get(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("GET", 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) 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 reqBody(body interface{}) (io.Reader, error) {
|
||||||
|
if body == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := body.(type) {
|
||||||
|
case []byte:
|
||||||
|
return bytes.NewReader(v), nil
|
||||||
|
case string:
|
||||||
|
return strings.NewReader(v), nil
|
||||||
|
case io.Reader:
|
||||||
|
return v, nil
|
||||||
|
default:
|
||||||
|
jsonData, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.NewReader(jsonData), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpClient = &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
MaxIdleConns: 256,
|
||||||
|
MaxIdleConnsPerHost: 16,
|
||||||
|
IdleConnTimeout: 10 * time.Minute,
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
},
|
||||||
|
Timeout: time.Minute,
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package hue2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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 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"`
|
||||||
|
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"`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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.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()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(fmt.Sprintf("{%s}", strings.Join(chunks, ",")))
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
+50
-6
@@ -11,6 +11,11 @@ type ColorValue struct {
|
|||||||
Hue float64 `json:"h,omitempty"` // 0..360
|
Hue float64 `json:"h,omitempty"` // 0..360
|
||||||
Saturation float64 `json:"s,omitempty"` // 0..=1
|
Saturation float64 `json:"s,omitempty"` // 0..=1
|
||||||
Kelvin int `json:"kelvin,omitempty"`
|
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 {
|
func (c *ColorValue) IsHueSat() bool {
|
||||||
@@ -18,7 +23,27 @@ func (c *ColorValue) IsHueSat() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *ColorValue) IsKelvin() 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 {
|
func (c *ColorValue) String() string {
|
||||||
@@ -29,15 +54,15 @@ func (c *ColorValue) String() string {
|
|||||||
return fmt.Sprintf("hs:%.4g,%.3g", c.Hue, c.Saturation)
|
return fmt.Sprintf("hs:%.4g,%.3g", c.Hue, c.Saturation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func ParseColorValue(raw string) (ColorValue, error) {
|
func ParseColorValue(raw string) (ColorValue, error) {
|
||||||
tokens := strings.SplitN(raw, ":", 2)
|
tokens := strings.SplitN(raw, ":", 2)
|
||||||
if len(tokens) != 2 {
|
if len(tokens) != 2 {
|
||||||
return ColorValue{}, ErrBadInput
|
return ColorValue{}, ErrBadInput
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokens[0] == "kelvin" || tokens[0] == "k" {
|
switch tokens[0] {
|
||||||
|
case "kelvin", "k":
|
||||||
|
{
|
||||||
parsedPart, err := strconv.Atoi(tokens[1])
|
parsedPart, err := strconv.Atoi(tokens[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ColorValue{}, ErrBadInput
|
return ColorValue{}, ErrBadInput
|
||||||
@@ -46,7 +71,24 @@ func ParseColorValue(raw string) (ColorValue, error) {
|
|||||||
return ColorValue{Kelvin: parsedPart}, nil
|
return ColorValue{Kelvin: parsedPart}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokens[0] == "hs" {
|
case "xy":
|
||||||
|
{
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
case "hs":
|
||||||
|
{
|
||||||
parts := strings.Split(tokens[1], ",")
|
parts := strings.Split(tokens[1], ",")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return ColorValue{}, ErrUnknownColorFormat
|
return ColorValue{}, ErrUnknownColorFormat
|
||||||
@@ -61,7 +103,8 @@ func ParseColorValue(raw string) (ColorValue, error) {
|
|||||||
return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil
|
return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokens[0] == "hsk" {
|
case "hsk":
|
||||||
|
{
|
||||||
parts := strings.Split(tokens[1], ",")
|
parts := strings.Split(tokens[1], ",")
|
||||||
if len(parts) < 3 {
|
if len(parts) < 3 {
|
||||||
return ColorValue{}, ErrUnknownColorFormat
|
return ColorValue{}, ErrUnknownColorFormat
|
||||||
@@ -80,6 +123,7 @@ func ParseColorValue(raw string) (ColorValue, error) {
|
|||||||
Kelvin: part3,
|
Kelvin: part3,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ColorValue{}, ErrUnknownColorFormat
|
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.0001 {
|
||||||
|
for y := best.Y - 0.001; y < best.Y+0.001; y += 0.0001 {
|
||||||
|
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.00001 {
|
||||||
|
for y := best.Y - 0.0001; y < best.Y+0.0001; y += 0.00001 {
|
||||||
|
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 screenRGBToXY(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"
|
DCColorHS DeviceCapability = "ColorHS"
|
||||||
DCColorHSK DeviceCapability = "ColorHSK"
|
DCColorHSK DeviceCapability = "ColorHSK"
|
||||||
DCColorKelvin DeviceCapability = "ColorKelvin"
|
DCColorKelvin DeviceCapability = "ColorKelvin"
|
||||||
|
DCColorXY DeviceCapability = "ColorXY"
|
||||||
DCButtons DeviceCapability = "Buttons"
|
DCButtons DeviceCapability = "Buttons"
|
||||||
DCPresence DeviceCapability = "Presence"
|
DCPresence DeviceCapability = "Presence"
|
||||||
DCIntensity DeviceCapability = "Intensity"
|
DCIntensity DeviceCapability = "Intensity"
|
||||||
|
|||||||
Reference in New Issue
Block a user