package nanoleaf import ( "bytes" "context" "encoding/json" "fmt" "git.aiterp.net/lucifer/new-server/models" "github.com/lucasb-eyer/go-colorful" "io" "io/ioutil" "log" "math" "net" "net/http" "strconv" "strings" "sync" "time" ) type bridge struct { mu sync.Mutex externalID int host string apiKey string panels []*panel panelIDMap map[uint16]int } func (b *bridge) Devices() []models.Device { results := make([]models.Device, 0, len(b.panels)) for i, panel := range b.panels { red := float64(panel.ColorRGBA[0]) / 255.0 green := float64(panel.ColorRGBA[1]) / 255.0 blue := float64(panel.ColorRGBA[2]) / 255.0 hue, sat, value := colorful.LinearRgb(red, green, blue).Hsv() shapeType, shapeTypeOK := shapeTypeMap[panel.ShapeType] if !shapeTypeOK { shapeType = "NanoLeaf" } shapeIcon, shapeIconOK := shapeIconMap[panel.ShapeType] if !shapeIconOK { shapeIcon = "lightbulb" } results = append(results, models.Device{ ID: -1, BridgeID: b.externalID, InternalID: strconv.Itoa(int(panel.ID)), Icon: shapeIcon, Name: fmt.Sprintf("%s %d", shapeType, i+1), Capabilities: []models.DeviceCapability{ models.DCPower, models.DCColorHS, models.DCIntensity, models.DCButtons, }, ButtonNames: []string{"Touch"}, DriverProperties: map[string]interface{}{ "x": panel.X, "y": panel.Y, "o": panel.O, "shapeType": shapeTypeMap[panel.ShapeType], "shapeWidth": shapeWidthMap[panel.ShapeType], }, UserProperties: nil, State: models.DeviceState{ Power: panel.ColorRGBA[3] == 0, Color: models.ColorValue{ Hue: math.Mod(hue, 360), Saturation: sat, }, Intensity: value, Temperature: 0, }, Tags: nil, }) } return results } func (b *bridge) Refresh(ctx context.Context) error { overview, err := b.Overview(ctx) if err != nil { return err } b.mu.Lock() PanelLoop: for _, panelInfo := range overview.PanelLayout.Data.PositionData { if shapeTypeMap[panelInfo.ShapeType] == "Shapes Controller" { continue } for _, existingPanel := range b.panels { if existingPanel.ID == panelInfo.PanelID { existingPanel.O = panelInfo.O existingPanel.X = panelInfo.X existingPanel.Y = panelInfo.Y existingPanel.ShapeType = panelInfo.ShapeType continue PanelLoop } } b.panels = append(b.panels, &panel{ ID: panelInfo.PanelID, ColorRGBA: [4]byte{0, 0, 0, 0}, TransitionAt: time.Time{}, O: panelInfo.O, X: panelInfo.X, Y: panelInfo.Y, ShapeType: panelInfo.ShapeType, Stale: true, SlowUpdates: 5, }) } b.mu.Unlock() return nil } func (b *bridge) Overview(ctx context.Context) (*Overview, error) { req, err := http.NewRequest("GET", b.URL(), nil) if err != nil { return nil, err } res, err := http.DefaultClient.Do(req.WithContext(ctx)) if err != nil { return nil, err } defer res.Body.Close() switch res.StatusCode { case 400, 403, 500, 503: return nil, models.ErrUnexpectedResponse case 401: return nil, models.ErrIncorrectToken } overview := Overview{} err = json.NewDecoder(res.Body).Decode(&overview) if err != nil { return nil, err } return &overview, nil } func (b *bridge) URL(resource ...string) string { return fmt.Sprintf("http://%s:16021/api/v1/%s/%s", b.host, b.apiKey, strings.Join(resource, "/")) } func (b *bridge) Update(devices []models.Device) { b.mu.Lock() defer b.mu.Unlock() for _, device := range devices { id, err := strconv.Atoi(device.InternalID) if err != nil { continue } b.panelIDMap[uint16(id)] = device.ID for _, panel := range b.panels { if panel.ID == uint16(id) { if device.State.Power { color := colorful.Hsv(device.State.Color.Hue, device.State.Color.Saturation, device.State.Intensity) red, green, blue := color.RGB255() newColor := [4]byte{red, green, blue, 255} if newColor != panel.ColorRGBA { panel.update(newColor, time.Now().Add(time.Millisecond*220)) } } else { panel.update([4]byte{0, 0, 0, 0}, time.Now()) } break } } } } func (b *bridge) Run(ctx context.Context, info models.Bridge, ch chan<- models.Event) error { err := b.updateEffect(ctx) if err != nil { return err } conn, err := net.DialUDP("udp", nil, &net.UDPAddr{ IP: net.ParseIP(info.Address), Port: 60222, }) if err != nil { return err } defer conn.Close() // Notify connections and disconnections ch <- models.BridgeConnectedEvent(info) defer func() { ch <- models.BridgeDisconnectedEvent(info) }() // Start touch listener. This one should go down together with this one, though, so it needs a new context. ctx2, cancel := context.WithCancel(ctx) defer cancel() go b.runTouchListener(ctx2, info.Address, info.Token, ch) go func() { ticker := time.NewTicker(time.Second * 5) hadErrors := false for { select { case <-ticker.C: case <-ctx2.Done(): } reqTimeout, cancel := context.WithTimeout(ctx2, time.Second*4) err := b.updateEffect(reqTimeout) cancel() if err != nil { log.Println("Failed to update effects:", err, "This error is non-fatal, and it will be retried shortly.") hadErrors = true } else if hadErrors { b.mu.Lock() for _, panel := range b.panels { panel.Stale = true panel.SlowUpdates = 3 panel.TicksUntilSlowUpdate = 10 } b.mu.Unlock() hadErrors = false } } }() ticker := time.NewTicker(time.Millisecond * 100) defer ticker.Stop() strikes := 0 for range ticker.C { if ctx.Err() != nil { break } panelUpdate := make(PanelUpdate, 2) b.mu.Lock() for _, panel := range b.panels { if !panel.Stale { if panel.SlowUpdates > 0 { panel.TicksUntilSlowUpdate -= 1 if panel.TicksUntilSlowUpdate > 0 { continue } panel.TicksUntilSlowUpdate = 10 panel.SlowUpdates -= 1 } else { continue } } else { panel.Stale = false } panelUpdate.Add(panel.message()) if panelUpdate.Len() > 150 { break } } b.mu.Unlock() if panelUpdate.Len() == 0 { continue } _, err := conn.Write(panelUpdate) if err != nil { strikes++ if strikes >= 3 { return err } } else { strikes = 0 } } return nil } func (b *bridge) updateEffect(ctx context.Context) error { overview, err := b.Overview(ctx) if err != nil { return err } if overview.Effects.Select == "*Dynamic*" && overview.State.ColorMode == "effect" { return nil } req, err := http.NewRequest("PUT", b.URL("effects"), bytes.NewReader(httpMessage)) if err != nil { return err } res, err := http.DefaultClient.Do(req.WithContext(ctx)) if err != nil { return err } defer res.Body.Close() if res.StatusCode != 204 { return models.ErrUnexpectedResponse } b.mu.Lock() for _, panel := range b.panels { panel.Stale = true } b.mu.Unlock() return nil } func (b *bridge) runTouchListener(ctx context.Context, host, apiKey string, ch chan<- models.Event) { cooldownID := 0 cooldownUntil := time.Now() message := make(PanelEventMessage, 65536) reqCloser := io.Closer(nil) for { // Set up touch event receiver touchListener, err := net.ListenUDP("udp4", &net.UDPAddr{ Port: 0, IP: net.IPv4(0, 0, 0, 0), }) if err != nil { log.Println("Socket error:", err) goto teardownAndRetry } { // Create touch event sender on the remote end. req, err := http.NewRequest("GET", fmt.Sprintf("http://%s:16021/api/v1/%s/events?id=4", host, apiKey), nil) if err != nil { log.Println("HTTP error:", err) goto teardownAndRetry } req.Header["TouchEventsPort"] = []string{strconv.Itoa(touchListener.LocalAddr().(*net.UDPAddr).Port)} res, err := http.DefaultClient.Do(req.WithContext(ctx)) if err != nil { log.Println("HTTP error:", err) goto teardownAndRetry } // Discard all data coming over http. reqCloser = res.Body go io.Copy(ioutil.Discard, res.Body) for { if ctx.Err() != nil { goto teardownAndRetry } // Check in with the context every so often _ = touchListener.SetReadDeadline(time.Now().Add(time.Second)) n, _, err := touchListener.ReadFromUDP(message) if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { continue } else if ctx.Err() == nil { log.Println("UDP error:", err) } goto teardownAndRetry } if !message[:n].ValidateLength() { log.Println("Bad message length field") continue } for i := 0; i < message.Count(); i += 1 { b.mu.Lock() externalID, hasExternalID := b.panelIDMap[message.PanelID(i)] swipedFromID, hasSwipedFromID := b.panelIDMap[message.SwipedFromPanelID(i)] b.mu.Unlock() if !hasExternalID || (externalID == cooldownID && time.Now().Before(cooldownUntil)) { continue } event := models.Event{ Name: models.ENButtonPressed, Payload: map[string]string{ "buttonIndex": "0", "buttonName": "Touch", "deviceId": strconv.Itoa(externalID), }, } if hasSwipedFromID { event.Payload["swipedFromDeviceId"] = strconv.Itoa(swipedFromID) } ch <- event cooldownID = externalID cooldownUntil = time.Now().Add(time.Second) } } } teardownAndRetry: if touchListener != nil { _ = touchListener.Close() } if reqCloser != nil { _ = reqCloser.Close() reqCloser = nil } if ctx.Err() != nil { break } time.Sleep(time.Second * 3) } }