package hue import ( "context" "encoding/json" "encoding/xml" "fmt" "git.aiterp.net/lucifer/new-server/internal/lerrors" "git.aiterp.net/lucifer/new-server/models" "log" "net/http" "strconv" "sync" "sync/atomic" "time" ) type Driver struct { mu sync.Mutex bridges []*Bridge } func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) { if address == "" { if !dryRun { return nil, lerrors.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.DTHue, 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.DTHue, Address: address, Token: "", } if !dryRun { b := &Bridge{host: address} timeout, cancel := context.WithTimeout(ctx, time.Second*30) defer cancel() ticker := time.NewTicker(time.Second) defer ticker.Stop() for range ticker.C { token, err := b.getToken(timeout) if err != nil { if err == errLinkButtonNotPressed { continue } return nil, err } bridge.Token = token b.token = token break } } return []models.Bridge{bridge}, nil } func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) { b, err := d.ensureBridge(ctx, bridge) if err != nil { return nil, err } before, err := d.ListDevices(ctx, bridge) if err != nil { return nil, err } if timeout.Seconds() < 10 { timeout = time.Second * 10 } halfTime := timeout / 2 err = b.StartDiscovery(ctx, "sensors") if err != nil { return nil, err } select { case <-time.After(halfTime): case <-ctx.Done(): return nil, ctx.Err() } err = b.StartDiscovery(ctx, "lights") if err != nil { return nil, err } select { case <-time.After(halfTime): case <-ctx.Done(): return nil, ctx.Err() } err = b.Refresh(ctx) if err != nil { return nil, err } after, err := d.ListDevices(ctx, bridge) if err != nil { return nil, err } intersection := make([]models.Device, 0, 4) for _, device := range after { found := false for _, device2 := range before { if device2.InternalID == device.InternalID { found = true break } } if !found { intersection = append(intersection, device) } } return intersection, nil } func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) { b, err := d.ensureBridge(ctx, bridge) if err != nil { return nil, err } devices := make([]models.Device, 0, 8) lightMap, err := b.getLights(ctx) if err != nil { return nil, err } for _, lightInfo := range lightMap { device := models.Device{ ID: -1, BridgeID: b.externalID, InternalID: lightInfo.Uniqueid, Icon: "lightbulb", Name: lightInfo.Name, Capabilities: []models.DeviceCapability{ models.DCPower, }, ButtonNames: nil, DriverProperties: map[string]interface{}{ "modelId": lightInfo.Modelid, "productName": lightInfo.Productname, "swVersion": lightInfo.Swversion, "hueLightType": lightInfo.Type, }, UserProperties: nil, State: models.DeviceState{}, Tags: nil, } hasDimming := false hasCT := false hasColor := false switch lightInfo.Type { case "On/off light": // Always take DCPower for granted anyway. case "Dimmable light": hasDimming = true case "Color temperature light": hasDimming = true hasCT = true case "Color light": hasDimming = true hasColor = true case "Extended color light": hasDimming = true hasColor = true hasCT = true } ctrl := lightInfo.Capabilities.Control if hasDimming { device.Capabilities = append(device.Capabilities, models.DCIntensity) } if hasCT { device.Capabilities = append(device.Capabilities, models.DCColorKelvin) device.DriverProperties["minKelvin"] = 1000000 / ctrl.CT.Max device.DriverProperties["maxKelvin"] = 1000000 / ctrl.CT.Min } if hasColor { device.Capabilities = append(device.Capabilities, models.DCColorHS) device.DriverProperties["gamutType"] = ctrl.Colorgamuttype device.DriverProperties["gamutData"] = ctrl.Colorgamut } device.DriverProperties["maxLumen"] = strconv.Itoa(ctrl.Maxlumen) devices = append(devices, device) } sensorMap, err := b.getSensors(ctx) if err != nil { return nil, err } for _, sensorInfo := range sensorMap { device := models.Device{ ID: -1, BridgeID: b.externalID, InternalID: sensorInfo.UniqueID, Name: sensorInfo.Name, Capabilities: []models.DeviceCapability{}, ButtonNames: []string{}, DriverProperties: map[string]interface{}{ "modelId": sensorInfo.Modelid, "productName": sensorInfo.Productname, "swVersion": sensorInfo.Swversion, "hueLightType": sensorInfo.Type, }, UserProperties: nil, State: models.DeviceState{}, Tags: nil, } switch sensorInfo.Type { case "ZLLSwitch": device.Capabilities = append(device.Capabilities, models.DCButtons) device.ButtonNames = append(buttonNames[:0:0], buttonNames...) device.Icon = "lightswitch" case "ZLLPresence": device.Capabilities = append(device.Capabilities, models.DCPresence) device.Icon = "sensor" case "ZLLTemperature": device.Capabilities = append(device.Capabilities, models.DCTemperatureSensor) device.Icon = "thermometer" case "Daylight": continue } devices = append(devices, device) } return devices, nil } func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error { b, err := d.ensureBridge(ctx, bridge) if err != nil { return err } err = b.Refresh(ctx) if err != nil { return err } b.mu.Lock() for _, device := range devices { for _, state := range b.lightStates { if device.InternalID == state.uniqueID { state.externalID = device.ID state.Update(device.State) break } } for _, state := range b.sensorStates { if device.InternalID == state.uniqueID { state.externalID = device.ID break } } } b.mu.Unlock() atomic.StoreUint32(&b.syncingPublish, 1) defer atomic.StoreUint32(&b.syncingPublish, 0) return b.SyncStale(ctx) } func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error { b, err := d.ensureBridge(ctx, bridge) if err != nil { return err } fastTicker := time.NewTicker(time.Second / 5) slowTicker := time.NewTicker(time.Second / 2) selectedTicker := fastTicker ticksUntilRefresh := 0 ticksSinceChange := 0 for { select { case <-selectedTicker.C: if atomic.LoadUint32(&b.syncingPublish) == 1 { continue } if ticksUntilRefresh <= 0 { err := b.Refresh(ctx) if err != nil { return err } err = b.SyncStale(ctx) if err != nil { return err } ticksUntilRefresh = 60 } events, err := b.SyncSensors(ctx) if err != nil { return err } for _, event := range events { ch <- event ticksSinceChange = 0 } if ticksSinceChange > 30 { selectedTicker = slowTicker } else if ticksSinceChange == 0 { selectedTicker = fastTicker } case <-ctx.Done(): return ctx.Err() } ticksUntilRefresh -= 1 ticksSinceChange += 1 } } func (d *Driver) ForgetDevice(ctx context.Context, bridge models.Bridge, device models.Device) error { b, err := d.ensureBridge(ctx, bridge) if err != nil { return err } return b.ForgetDevice(ctx, device) } func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*Bridge, error) { d.mu.Lock() for _, bridge := range d.bridges { if bridge.host == info.Address { d.mu.Unlock() return bridge, nil } } d.mu.Unlock() bridge := &Bridge{ host: info.Address, token: info.Token, externalID: info.ID, } // If this call succeeds, then the token is ok. lightMap, err := bridge.getLights(ctx) if err != nil { return nil, err } log.Printf("Found %d lights on bridge %d", len(lightMap), bridge.externalID) // To avoid a potential duplicate, try looking for it again before inserting d.mu.Lock() for _, bridge := range d.bridges { if bridge.host == info.Address { d.mu.Unlock() return bridge, nil } } d.bridges = append(d.bridges, bridge) d.mu.Unlock() return bridge, nil }