diff --git a/cmd/xy/main.go b/cmd/xy/main.go new file mode 100644 index 0000000..72cbc96 --- /dev/null +++ b/cmd/xy/main.go @@ -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())) +} diff --git a/internal/drivers/hue2/bridge.go b/internal/drivers/hue2/bridge.go new file mode 100644 index 0000000..8e36c8e --- /dev/null +++ b/internal/drivers/hue2/bridge.go @@ -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 +} diff --git a/internal/drivers/hue2/client.go b/internal/drivers/hue2/client.go new file mode 100644 index 0000000..356f607 --- /dev/null +++ b/internal/drivers/hue2/client.go @@ -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, +} diff --git a/internal/drivers/hue2/data.go b/internal/drivers/hue2/data.go new file mode 100644 index 0000000..1c7c9a2 --- /dev/null +++ b/internal/drivers/hue2/data.go @@ -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) +} diff --git a/models/colorvalue.go b/models/colorvalue.go index 119e170..68ae594 100644 --- a/models/colorvalue.go +++ b/models/colorvalue.go @@ -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 + } - part1, err1 := strconv.ParseFloat(parts[0], 64) - part2, err2 := strconv.ParseFloat(parts[1], 64) - if err1 != nil || err2 != nil { - return ColorValue{}, ErrBadInput + return ColorValue{XY: &ColorXY{X: x, Y: y}}, nil } - return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil - } + case "hs": + { + parts := strings.Split(tokens[1], ",") + if len(parts) < 2 { + 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) + if err1 != nil || err2 != nil { + return ColorValue{}, ErrBadInput + } - 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)}, nil } - return ColorValue{ - Hue: math.Mod(part1, 360), - Saturation: math.Min(math.Max(part2, 0), 1), - Kelvin: part3, - }, nil + case "hsk": + { + parts := strings.Split(tokens[1], ",") + if len(parts) < 3 { + return ColorValue{}, ErrUnknownColorFormat + } + + part1, err1 := strconv.ParseFloat(parts[0], 64) + part2, err2 := strconv.ParseFloat(parts[1], 64) + part3, err3 := strconv.Atoi(parts[2]) + if err1 != nil || err2 != nil || err3 != nil { + return ColorValue{}, ErrBadInput + } + + return ColorValue{ + Hue: math.Mod(part1, 360), + Saturation: math.Min(math.Max(part2, 0), 1), + Kelvin: part3, + }, nil + } } return ColorValue{}, ErrUnknownColorFormat diff --git a/models/colorxy.go b/models/colorxy.go new file mode 100644 index 0000000..3a17083 --- /dev/null +++ b/models/colorxy.go @@ -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) +} diff --git a/models/device.go b/models/device.go index 23960ad..b8cc0d3 100644 --- a/models/device.go +++ b/models/device.go @@ -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"