Gisle Aune
3 years ago
7 changed files with 894 additions and 38 deletions
-
39cmd/xy/main.go
-
257internal/drivers/hue2/bridge.go
-
167internal/drivers/hue2/client.go
-
144internal/drivers/hue2/data.go
-
120models/colorvalue.go
-
204models/colorxy.go
-
1models/device.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())) |
||||
|
} |
@ -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) |
||||
|
} |
@ -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) |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue