This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user