diff --git a/app/config/driver.go b/app/config/driver.go index 9b08e6d..8e7974f 100644 --- a/app/config/driver.go +++ b/app/config/driver.go @@ -3,6 +3,7 @@ package config import ( "git.aiterp.net/lucifer/new-server/internal/drivers" "git.aiterp.net/lucifer/new-server/internal/drivers/hue" + "git.aiterp.net/lucifer/new-server/internal/drivers/hue2" "git.aiterp.net/lucifer/new-server/internal/drivers/lifx" "git.aiterp.net/lucifer/new-server/internal/drivers/mill" "git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf" @@ -18,6 +19,7 @@ func DriverProvider() models.DriverProvider { dp = drivers.DriverMap{ models.DTNanoLeaf: &nanoleaf.Driver{}, models.DTHue: &hue.Driver{}, + models.DTHue2: &hue2.Driver{}, models.DTLIFX: &lifx.Driver{}, models.DTMill: &mill.Driver{}, } diff --git a/cmd/xy/main.go b/cmd/xy/main.go index 72cbc96..a164ee8 100644 --- a/cmd/xy/main.go +++ b/cmd/xy/main.go @@ -20,20 +20,30 @@ func main() { j, _ := json.Marshal(bridge.GenerateDevices()) fmt.Println(string(j)) - for _, device := range bridge.GenerateDevices() { + ch := make(chan models.Event) + go func() { + for event := range ch { + log.Println("EVENT", event.Name, event.Payload) + } + }() + + for i, dev := range bridge.GenerateDevices() { + device := dev switch device.InternalID { case "6d5a45b0-ec69-4417-8588-717358b05086": c, _ := models.ParseColorValue("xy:0.22,0.18") device.State.Color = c - device.State.Intensity = 1 - bridge.Update(device) + device.State.Intensity = 0.3 case "a71128f4-5295-4ae4-9fbc-5541abc8739b": - c, _ := models.ParseColorValue("k:2000") + c, _ := models.ParseColorValue("k:6500") device.State.Color = c device.State.Intensity = 0.2 - bridge.Update(device) } + + device.ID = i + 1 + bridge.Update(device) } - fmt.Println(bridge.MakeCongruent(context.Background())) + err = bridge.Run(context.Background(), ch) + log.Println(err) } diff --git a/internal/drivers/hue2/bridge.go b/internal/drivers/hue2/bridge.go index 8e36c8e..e087e06 100644 --- a/internal/drivers/hue2/bridge.go +++ b/internal/drivers/hue2/bridge.go @@ -2,9 +2,11 @@ package hue2 import ( "context" + "errors" "fmt" "git.aiterp.net/lucifer/new-server/models" "golang.org/x/sync/errgroup" + "log" "math" "strings" "sync" @@ -12,17 +14,197 @@ import ( ) type Bridge struct { - mu sync.Mutex - client *Client - devices map[string]models.Device - resources map[string]*ResourceData + mu sync.Mutex + externalID int + client *Client + needsUpdate chan struct{} + devices map[string]models.Device + resources map[string]*ResourceData } func NewBridge(client *Client) *Bridge { return &Bridge{ - client: client, - devices: make(map[string]models.Device, 64), - resources: make(map[string]*ResourceData, 256), + client: client, + needsUpdate: make(chan struct{}, 4), + devices: make(map[string]models.Device, 64), + resources: make(map[string]*ResourceData, 256), + } +} + +func (b *Bridge) Run(ctx context.Context, eventCh chan<- models.Event) error { + log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Connecting SSE...") + sse := b.client.SSE(ctx) + + log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Refreshing...") + err := b.RefreshAll(ctx) + if err != nil { + return err + } + log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Running updates...") + updated, err := b.MakeCongruent(ctx) + if err != nil { + return err + } + log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services at startup.") + + lightRefreshTimer := time.NewTicker(time.Second * 5) + defer lightRefreshTimer.Stop() + + motionTicker := time.NewTicker(time.Second * 10) + defer motionTicker.Stop() + + absences := make(map[int]time.Time) + lastPress := make(map[string]time.Time) + needFull := false + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-lightRefreshTimer.C: + if needFull { + err := b.RefreshAll(ctx) + if err != nil { + return err + } + } else { + err := b.Refresh(ctx, "light") + if err != nil { + return err + } + } + + updated, err := b.MakeCongruent(ctx) + if err != nil { + return err + } + if updated > 0 { + log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (regular check)") + } + case <-b.needsUpdate: + updated, err := b.MakeCongruent(ctx) + if err != nil { + return err + } + if updated > 0 { + log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (publish)") + } + case <-motionTicker.C: + for id, absenceTime := range absences { + seconds := int(time.Since(absenceTime).Seconds()) + if seconds < 10 || seconds%60 >= 10 { + continue + } + + eventCh <- models.Event{ + Name: models.ENSensorPresenceEnded, + Payload: map[string]string{ + "deviceId": fmt.Sprint(id), + "minutesElapsed": fmt.Sprint(seconds / 60), + "secondsElapsed": fmt.Sprint(seconds), + "lastUpdated": fmt.Sprint(absenceTime.Unix()), + }, + } + } + case data, ok := <-sse: + if !ok { + return errors.New("SSE Disconnected") + } + b.applyPatches(data.Data) + + updated, err := b.MakeCongruent(ctx) + if err != nil { + return err + } + if updated > 0 { + log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (SSE)") + } + + for _, patch := range data.Data { + if patch.Owner == nil { + continue + } + + if b.resources[patch.Owner.ID] == nil { + needFull = true + } + + device, deviceOK := b.devices[patch.Owner.ID] + if !deviceOK || device.ID == 0 { + continue + } + + if patch.Button != nil { + valid := false + if patch.Button.LastEvent == "initial_press" || patch.Button.LastEvent == "repeat" { + valid = true + } else if patch.Button.LastEvent == "long_release" { + valid = false + } else { + valid = lastPress[patch.ID].Unix() != data.CreationTime.Unix() + } + + if valid { + lastPress[patch.ID] = data.CreationTime + + b.mu.Lock() + owner := b.resources[patch.Owner.ID] + b.mu.Unlock() + if owner != nil { + index := owner.ServiceIndex("button", patch.ID) + if index != -1 { + eventCh <- models.Event{ + Name: models.ENButtonPressed, + Payload: map[string]string{ + "deviceId": fmt.Sprint(device.ID), + "hueButtonEvent": patch.Button.LastEvent, + "buttonIndex": fmt.Sprint(index), + "buttonName": device.ButtonNames[index], + }, + } + } + } + } + } + if patch.Temperature != nil && patch.Temperature.Valid { + eventCh <- models.Event{ + Name: models.ENSensorTemperature, + Payload: map[string]string{ + "deviceId": fmt.Sprint(device.ID), + "deviceInternalId": patch.Owner.ID, + "temperature": fmt.Sprint(patch.Temperature.Temperature), + "lastUpdated": fmt.Sprint(data.CreationTime.Unix()), + }, + } + } + if patch.Motion != nil && patch.Motion.Valid { + if patch.Motion.Motion { + eventCh <- models.Event{ + Name: models.ENSensorPresenceStarted, + Payload: map[string]string{ + "deviceId": fmt.Sprint(device.ID), + "deviceInternalId": patch.Owner.ID, + }, + } + + delete(absences, device.ID) + } else { + eventCh <- models.Event{ + Name: models.ENSensorPresenceEnded, + Payload: map[string]string{ + "deviceId": fmt.Sprint(device.ID), + "deviceInternalId": device.InternalID, + "minutesElapsed": "0", + "secondsElapsed": "0", + "lastUpdated": fmt.Sprint(data.CreationTime.Unix()), + }, + } + + absences[device.ID] = data.CreationTime + } + } + } + } } } @@ -32,9 +214,20 @@ func (b *Bridge) Update(devices ...models.Device) { b.devices[device.InternalID] = device } b.mu.Unlock() + + select { + case b.needsUpdate <- struct{}{}: + default: + } } func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) { + // Eat the event if there's a pending update. + select { + case <-b.needsUpdate: + default: + } + b.mu.Lock() dur := time.Millisecond * 200 updates := make(map[string]ResourceUpdate) @@ -45,32 +238,40 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) { 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 + lightsOut := light.Power != nil && !device.State.Power + if !lightsOut { + if light.ColorTemperature != nil && device.State.Color.IsKelvin() { + mirek := 1000000 / device.State.Color.Kelvin + if mirek < light.ColorTemperature.MirekSchema.MirekMinimum { + mirek = light.ColorTemperature.MirekSchema.MirekMinimum + } + if mirek > light.ColorTemperature.MirekSchema.MirekMaximum { + mirek = light.ColorTemperature.MirekSchema.MirekMaximum + } + if light.ColorTemperature.Mirek == nil || mirek != *light.ColorTemperature.Mirek { + update.Mirek = &mirek + changed = true + } + } else if xy, ok := device.State.Color.ToXY(); ok && light.Color != nil { + xy = light.Color.Gamut.Conform(xy).Round() + if xy.DistanceTo(light.Color.XY) > 0.0009 || (light.ColorTemperature != nil && light.ColorTemperature.Mirek != nil) { + update.ColorXY = &xy + changed = true + } } - } 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 + if light.Dimming != nil && math.Abs(light.Dimming.Brightness/100-device.State.Intensity) > 0.02 { + brightness := math.Abs(math.Min(device.State.Intensity*100, 100)) + update.Brightness = &brightness changed = true } } + if light.Power != nil && light.Power.On != device.State.Power { update.Power = &device.State.Power - 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 + if device.State.Power { + brightness := math.Abs(math.Min(device.State.Intensity*100, 100)) + update.Brightness = &brightness + } changed = true } @@ -85,6 +286,8 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) { return 0, nil } + log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updating", len(updates), "services...") + eg, ctx := errgroup.WithContext(ctx) for key := range updates { update := updates[key] @@ -95,7 +298,28 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) { }) } - return len(updates), eg.Wait() + err := eg.Wait() + if err != nil { + return len(updates), err + } + + if len(updates) > 0 { + // Optimistically apply the updates to the states, so that the driver assumes they are set until + // proven otherwise by the SSE client. + b.mu.Lock() + newResources := make(map[string]*ResourceData, len(b.resources)) + for key, value := range b.resources { + newResources[key] = value + } + for key, update := range updates { + id := strings.SplitN(key, "/", 2)[1] + newResources[id] = newResources[id].WithUpdate(update) + } + b.resources = newResources + b.mu.Unlock() + } + + return len(updates), nil } func (b *Bridge) GenerateDevices() []models.Device { @@ -243,7 +467,6 @@ func (b *Bridge) RefreshAll(ctx context.Context) error { } resources := make(map[string]*ResourceData, len(allResources)) - for i := range allResources { resource := allResources[i] resources[resource.ID] = &resource @@ -255,3 +478,64 @@ func (b *Bridge) RefreshAll(ctx context.Context) error { return nil } + +func (b *Bridge) applyPatches(patches []ResourceData) { + b.mu.Lock() + newResources := make(map[string]*ResourceData, len(b.resources)) + for key, value := range b.resources { + newResources[key] = value + } + b.mu.Unlock() + + for _, patch := range patches { + if res := newResources[patch.ID]; res != nil { + resCopy := *res + + if patch.Power != nil && resCopy.Power != nil { + cp := *resCopy.Power + resCopy.Power = &cp + resCopy.Power.On = patch.Power.On + } + if patch.Color != nil && resCopy.Color != nil { + cp := *resCopy.Color + resCopy.Color = &cp + resCopy.Color.XY = patch.Color.XY + + cp2 := *resCopy.ColorTemperature + resCopy.ColorTemperature = &cp2 + resCopy.ColorTemperature.Mirek = nil + } + if patch.ColorTemperature != nil && resCopy.ColorTemperature != nil { + cp := *resCopy.ColorTemperature + resCopy.ColorTemperature = &cp + resCopy.ColorTemperature.Mirek = patch.ColorTemperature.Mirek + } + if patch.Dimming != nil && resCopy.Dimming != nil { + cp := *resCopy.Dimming + resCopy.Dimming = &cp + resCopy.Dimming.Brightness = patch.Dimming.Brightness + } + if patch.Dynamics != nil { + resCopy.Dynamics = patch.Dynamics + } + if patch.Alert != nil { + resCopy.Alert = patch.Alert + } + if patch.PowerState != nil { + resCopy.PowerState = patch.PowerState + } + if patch.Temperature != nil { + resCopy.Temperature = patch.Temperature + } + if patch.Status != nil { + resCopy.Status = patch.Status + } + + newResources[patch.ID] = &resCopy + } + } + + b.mu.Lock() + b.resources = newResources + b.mu.Unlock() +} diff --git a/internal/drivers/hue2/client.go b/internal/drivers/hue2/client.go index 356f607..3980cd3 100644 --- a/internal/drivers/hue2/client.go +++ b/internal/drivers/hue2/client.go @@ -1,12 +1,16 @@ package hue2 import ( + "bufio" "bytes" "context" "crypto/tls" "encoding/json" + "errors" "fmt" + "git.aiterp.net/lucifer/new-server/models" "io" + "log" "net" "net/http" "strings" @@ -32,6 +36,30 @@ type Client struct { ch chan struct{} } +func (c *Client) Register(ctx context.Context) (string, error) { + result := make([]CreateUserResponse, 0, 1) + err := c.post(ctx, "api/", CreateUserInput{DeviceType: "git.aiterp.net/lucifer"}, &result) + if err != nil { + return "", err + } + + if len(result) == 0 || result[0].Error != nil { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(time.Second): + return c.Register(ctx) + } + } + if result[0].Success == nil { + return "", models.ErrUnexpectedResponse + } + + c.token = result[0].Success.Username + + return result[0].Success.Username, nil +} + func (c *Client) AllResources(ctx context.Context) ([]ResourceData, error) { res := struct { Error interface{} @@ -64,7 +92,107 @@ func (c *Client) UpdateResource(ctx context.Context, link ResourceLink, update R return c.put(ctx, link.Path(), update, nil) } +func (c *Client) SSE(ctx context.Context) <-chan SSEUpdate { + ch := make(chan SSEUpdate, 4) + go func() { + defer close(ch) + + reader, err := c.getReader(ctx, "/eventstream/clip/v2", map[string]string{ + "Accept": "text/event-stream", + }) + if err != nil { + log.Println("SSE Connect error:", err) + return + } + + defer reader.Close() + + br := bufio.NewReader(reader) + + for { + line, err := br.ReadString('\n') + if err != nil { + log.Println("SSE Read error:", err) + return + } + line = strings.Trim(line, "  \t\r\n") + kv := strings.SplitN(line, ": ", 2) + if len(kv) < 2 { + continue + } + + switch kv[0] { + case "data": + var data []SSEUpdate + err := json.Unmarshal([]byte(kv[1]), &data) + if err != nil { + log.Println("Parsing SSE event failed:", err) + log.Println(" json:", kv[1]) + return + } + + for _, obj := range data { + ch <- obj + } + } + } + }() + + return ch +} + +func (c *Client) getReader(ctx context.Context, path string, headers map[string]string) (io.ReadCloser, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-c.ch: + defer func() { + c.ch <- struct{}{} + }() + } + + req, err := http.NewRequest("GET", fmt.Sprintf("https://%s/%s", c.host, path), nil) + if err != nil { + return nil, err + } + + for key, value := range headers { + req.Header.Set(key, value) + } + req.Header.Set("hue-application-key", c.token) + + client := httpClient + if headers["Accept"] == "text/event-stream" { + client = sseClient + } + + res, err := client.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + if res.StatusCode != 200 { + _ = res.Body.Close() + return nil, errors.New(res.Status) + } + + return res.Body, nil +} + func (c *Client) get(ctx context.Context, path string, target interface{}) error { + body, err := c.getReader(ctx, path, map[string]string{}) + if err != nil { + return err + } + + defer body.Close() + + if target == nil { + return nil + } + return json.NewDecoder(body).Decode(&target) +} + +func (c *Client) put(ctx context.Context, path string, body interface{}, target interface{}) error { select { case <-ctx.Done(): return ctx.Err() @@ -74,7 +202,12 @@ func (c *Client) get(ctx context.Context, path string, target interface{}) error }() } - req, err := http.NewRequest("GET", fmt.Sprintf("https://%s/%s", c.host, path), nil) + 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 } @@ -94,7 +227,7 @@ func (c *Client) get(ctx context.Context, path string, target interface{}) error return json.NewDecoder(res.Body).Decode(&target) } -func (c *Client) put(ctx context.Context, path string, body interface{}, target interface{}) error { +func (c *Client) post(ctx context.Context, path string, body interface{}, target interface{}) error { select { case <-ctx.Done(): return ctx.Err() @@ -109,12 +242,14 @@ func (c *Client) put(ctx context.Context, path string, body interface{}, target return err } - req, err := http.NewRequest("PUT", fmt.Sprintf("https://%s/%s", c.host, path), rb) + req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/%s", c.host, path), rb) if err != nil { return err } - req.Header.Set("hue-application-key", c.token) + if c.token != "" { + req.Header.Set("hue-application-key", c.token) + } res, err := httpClient.Do(req.WithContext(ctx)) if err != nil { @@ -151,6 +286,21 @@ func reqBody(body interface{}) (io.Reader, error) { } } +var sseClient = &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: time.Hour * 1000000, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 5, + MaxIdleConnsPerHost: 1, + IdleConnTimeout: 0, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + Timeout: time.Hour * 1000000, +} + var httpClient = &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, diff --git a/internal/drivers/hue2/data.go b/internal/drivers/hue2/data.go index 1c7c9a2..fae6e76 100644 --- a/internal/drivers/hue2/data.go +++ b/internal/drivers/hue2/data.go @@ -1,6 +1,7 @@ package hue2 import ( + "encoding/xml" "fmt" "git.aiterp.net/lucifer/new-server/models" "strings" @@ -31,6 +32,13 @@ type DeviceProductData struct { SoftwareVersion string `json:"software_version"` } +type SSEUpdate struct { + CreationTime time.Time `json:"creationTime"` + ID string `json:"id"` + Type string `json:"type"` + Data []ResourceData `json:"data"` +} + type ResourceData struct { ID string `json:"id"` LegacyID string `json:"id_v1"` @@ -42,6 +50,7 @@ type ResourceData struct { Owner *ResourceLink `json:"owner"` ProductData *DeviceProductData `json:"product_data"` Services []ResourceLink `json:"services"` + Button *SensorButton `json:"button"` Power *LightPower `json:"on"` Color *LightColor `json:"color"` ColorTemperature *LightCT `json:"color_temperature"` @@ -49,6 +58,8 @@ type ResourceData struct { Dynamics *LightDynamics `json:"dynamics"` Alert *LightAlert `json:"alert"` PowerState *PowerState `json:"power_state"` + Temperature *SensorTemperature `json:"temperature"` + Motion *SensorMotion `json:"motion"` Status *string `json:"status"` } @@ -62,6 +73,61 @@ func (res *ResourceData) ServiceID(kind string) *string { return nil } +func (res *ResourceData) ServiceIndex(kind string, id string) int { + idx := 0 + for _, link := range res.Services { + if link.ID == id { + return idx + } else if link.Kind == kind { + idx += 1 + } + } + + return -1 +} + +func (res *ResourceData) WithUpdate(update ResourceUpdate) *ResourceData { + resCopy := *res + + if update.Power != nil { + cp := *resCopy.Power + resCopy.Power = &cp + resCopy.Power.On = *update.Power + } + if update.ColorXY != nil { + cp := *resCopy.Color + resCopy.Color = &cp + resCopy.Color.XY = *update.ColorXY + } + if update.Mirek != nil { + cp := *resCopy.ColorTemperature + resCopy.ColorTemperature = &cp + mirek := *update.Mirek + resCopy.ColorTemperature.Mirek = &mirek + } + if update.Brightness != nil { + cp := *resCopy.Dimming + resCopy.Dimming = &cp + resCopy.Dimming.Brightness = *update.Brightness + } + + return &resCopy +} + +type SensorButton struct { + LastEvent string `json:"last_event"` +} + +type SensorMotion struct { + Motion bool `json:"motion"` + Valid bool `json:"motion_valid"` +} + +type SensorTemperature struct { + Temperature float64 `json:"temperature"` + Valid bool `json:"temperature_valid"` +} + type PowerState struct { BatteryState string `json:"battery_state"` BatteryLevel float64 `json:"battery_level"` @@ -129,8 +195,6 @@ func (r ResourceUpdate) MarshalJSON() ([]byte, error) { 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 } @@ -142,3 +206,60 @@ type ResourceLink struct { func (rl *ResourceLink) Path() string { return fmt.Sprintf("/clip/v2/resource/%s/%s", rl.Kind, rl.ID) } + +type CreateUserInput struct { + DeviceType string `json:"devicetype"` +} + +type CreateUserResponse struct { + Success *struct { + Username string `json:"username"` + } `json:"success"` + Error *struct { + Type int `json:"type"` + Address string `json:"address"` + Description string `json:"description"` + } `json:"error"` +} + +type DiscoveryEntry struct { + Id string `json:"id"` + InternalIPAddress string `json:"internalipaddress"` +} + +type BridgeDeviceInfo struct { + XMLName xml.Name `xml:"root"` + Text string `xml:",chardata"` + Xmlns string `xml:"xmlns,attr"` + SpecVersion struct { + Text string `xml:",chardata"` + Major string `xml:"major"` + Minor string `xml:"minor"` + } `xml:"specVersion"` + URLBase string `xml:"URLBase"` + Device struct { + Text string `xml:",chardata"` + DeviceType string `xml:"deviceType"` + FriendlyName string `xml:"friendlyName"` + Manufacturer string `xml:"manufacturer"` + ManufacturerURL string `xml:"manufacturerURL"` + ModelDescription string `xml:"modelDescription"` + ModelName string `xml:"modelName"` + ModelNumber string `xml:"modelNumber"` + ModelURL string `xml:"modelURL"` + SerialNumber string `xml:"serialNumber"` + UDN string `xml:"UDN"` + PresentationURL string `xml:"presentationURL"` + IconList struct { + Text string `xml:",chardata"` + Icon struct { + Text string `xml:",chardata"` + Mimetype string `xml:"mimetype"` + Height string `xml:"height"` + Width string `xml:"width"` + Depth string `xml:"depth"` + URL string `xml:"url"` + } `xml:"icon"` + } `xml:"iconList"` + } `xml:"device"` +} diff --git a/internal/drivers/hue2/driver.go b/internal/drivers/hue2/driver.go new file mode 100644 index 0000000..cf89914 --- /dev/null +++ b/internal/drivers/hue2/driver.go @@ -0,0 +1,116 @@ +package hue2 + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "git.aiterp.net/lucifer/new-server/models" + "net/http" + "sync" + "time" +) + +type Driver struct { + mu sync.Mutex + bridges []*Bridge +} + +func (d *Driver) SearchBridge(ctx context.Context, address, token string, dryRun bool) ([]models.Bridge, error) { + if address == "" { + if !dryRun { + return nil, models.ErrAddressOnlyDryRunnable + } + + res, err := http.Get("https://discovery.meethue.com") + if err != nil { + return nil, err + } + defer res.Body.Close() + + entries := make([]DiscoveryEntry, 0, 8) + err = json.NewDecoder(res.Body).Decode(&entries) + if err != nil { + return nil, err + } + + bridges := make([]models.Bridge, 0, len(entries)) + for _, entry := range entries { + bridges = append(bridges, models.Bridge{ + ID: -1, + Name: entry.Id, + Driver: models.DTHue2, + Address: entry.InternalIPAddress, + Token: "", + }) + } + + return bridges, nil + } + + deviceInfo := BridgeDeviceInfo{} + res, err := http.Get(fmt.Sprintf("http://%s/description.xml", address)) + if err != nil { + return nil, err + } + defer res.Body.Close() + err = xml.NewDecoder(res.Body).Decode(&deviceInfo) + if err != nil { + return nil, err + } + + bridge := models.Bridge{ + ID: -1, + Name: deviceInfo.Device.FriendlyName, + Driver: models.DTHue2, + Address: address, + Token: "", + } + + if !dryRun { + client := NewClient(address, "") + + timeout, cancel := context.WithTimeout(ctx, time.Second*30) + defer cancel() + + bridge.Token, err = client.Register(timeout) + if err != nil { + return nil, err + } + } + + return []models.Bridge{bridge}, nil +} + +func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) { + return nil, nil +} + +func (d *Driver) ListDevices(_ context.Context, bridge models.Bridge) ([]models.Device, error) { + return d.ensureBridge(bridge).GenerateDevices(), nil +} + +func (d *Driver) Publish(_ context.Context, bridge models.Bridge, devices []models.Device) error { + d.ensureBridge(bridge).Update(devices...) + return nil +} + +func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error { + return d.ensureBridge(bridge).Run(ctx, ch) +} + +func (d *Driver) ensureBridge(info models.Bridge) *Bridge { + d.mu.Lock() + for _, bridge := range d.bridges { + if bridge.client.host == info.Address { + d.mu.Unlock() + return bridge + } + } + bridge := NewBridge(NewClient(info.Address, info.Token)) + bridge.externalID = info.ID + d.bridges = append(d.bridges, bridge) + d.mu.Unlock() + + return bridge +} diff --git a/models/bridge.go b/models/bridge.go index 374697d..f44839e 100644 --- a/models/bridge.go +++ b/models/bridge.go @@ -21,6 +21,7 @@ type DriverKind string var ( DTHue DriverKind = "Hue" + DTHue2 DriverKind = "Hue2" DTNanoLeaf DriverKind = "Nanoleaf" DTLIFX DriverKind = "LIFX" DTMill DriverKind = "Mill" diff --git a/models/colorxy.go b/models/colorxy.go index 3a17083..2dc2e8b 100644 --- a/models/colorxy.go +++ b/models/colorxy.go @@ -127,8 +127,8 @@ func (cg *ColorGamut) Conform(color ColorXY) ColorXY { 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 { + for x := best.X - 0.001; x < best.X+0.001; x += 0.0002 { + for y := best.Y - 0.001; y < best.Y+0.001; y += 0.0002 { color2 := ColorXY{X: x, Y: y} if cg.atTheEdge(color2) { @@ -139,8 +139,8 @@ func (cg *ColorGamut) Conform(color ColorXY) ColorXY { } } - 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 { + for x := best.X - 0.0001; x < best.X+0.0001; x += 0.00003 { + for y := best.Y - 0.0001; y < best.Y+0.0001; y += 0.00003 { color2 := ColorXY{X: x, Y: y} if cg.atTheEdge(color2) {