package nanoleaf import ( "bytes" "context" "encoding/binary" "encoding/hex" "encoding/json" "fmt" lucifer3 "git.aiterp.net/lucifer3/server" "git.aiterp.net/lucifer3/server/commands" "git.aiterp.net/lucifer3/server/device" "git.aiterp.net/lucifer3/server/events" "git.aiterp.net/lucifer3/server/internal/color" "git.aiterp.net/lucifer3/server/internal/gentools" "io" "io/ioutil" "log" "net" "net/http" "strconv" "strings" "sync" "time" ) type bridge struct { mu sync.Mutex host string apiKey string panels []*panel panelIDMap map[uint16]string } func (b *bridge) HardwareEvents() []lucifer3.Event { results := make([]lucifer3.Event, 0, len(b.panels)*2) for i, panel := range b.panels { // Find normalized RGB and intensity rgb := color.RGB{ Red: float64(panel.ColorRGBA[0]) / 255.0, Green: float64(panel.ColorRGBA[1]) / 255.0, Blue: float64(panel.ColorRGBA[2]) / 255.0, } normalized, intensity := rgb.Normalize() col := color.Color{RGB: &normalized} shapeType, shapeTypeOK := shapeTypeMap[panel.ShapeType] if !shapeTypeOK { shapeType = "Unknown" } shapeIcon, shapeIconOK := shapeIconMap[panel.ShapeType] if !shapeIconOK { shapeIcon = "lightbulb" } state := device.State{} if panel.ColorRGBA != [4]byte{0, 0, 0, 0} { state = device.State{ Power: gentools.Ptr(panel.ColorRGBA[3] == 0), Color: &col, Intensity: &intensity, } } results = append(results, events.HardwareState{ ID: panel.FullID, InternalName: fmt.Sprintf("%s %d (%s)", shapeType, i+1, strings.SplitN(panel.FullID, ":", 3)[2]), SupportFlags: device.SFlagPower | device.SFlagIntensity | device.SFlagColor, ColorFlags: device.CFlagRGB, Buttons: []string{"Touch"}, State: state, }, events.HardwareMetadata{ ID: panel.FullID, X: panel.X, Y: panel.Y, ShapeType: shapeType, Icon: shapeIcon, }) } return append(results, events.DevicesReady{ ID: fmt.Sprintf("nanoleaf:%s", b.host), }) } func (b *bridge) Refresh(ctx context.Context) (bool, error) { overview, err := b.Overview(ctx) if err != nil { return false, err } changed := false 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 } } changed = true idBytes := [2]byte{0, 0} binary.BigEndian.PutUint16(idBytes[:], panelInfo.PanelID) hexID := hex.EncodeToString(idBytes[:]) fullID := fmt.Sprintf("nanoleaf:%s:%s", b.host, hexID) b.panelIDMap[panelInfo.PanelID] = fullID b.panels = append(b.panels, &panel{ ID: panelInfo.PanelID, FullID: fullID, 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 changed, 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, lucifer3.ErrUnexpectedResponse case 401: return nil, lucifer3.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(id string, change device.State) { transitionTime := time.Now().Add(time.Millisecond * 255) b.mu.Lock() for _, panel := range b.panels { if panel.FullID == id { panel.apply(change, transitionTime) break } } b.mu.Unlock() } func (b *bridge) UpdateBatch(batch commands.SetStateBatch) { transitionTime := time.Now().Add(time.Millisecond * 255) b.mu.Lock() for _, panel := range b.panels { if change, ok := batch[panel.FullID]; ok { panel.apply(change, transitionTime) } } b.mu.Unlock() } func (b *bridge) Run(ctx context.Context, bus *lucifer3.EventBus) error { err := b.updateEffect(ctx) if err != nil { return err } conn, err := net.DialUDP("udp", nil, &net.UDPAddr{ IP: net.ParseIP(b.host), Port: 60222, }) if err != nil { return err } defer conn.Close() // Notify connections and disconnections bus.RunEvent(events.DeviceConnected{ Prefix: fmt.Sprintf("nanoleaf:%s", b.host), }) _, err = b.Refresh(ctx) if err != nil { return err } // 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, bus) go func() { ticker := time.NewTicker(time.Second * 5) hadErrors := false for { select { case <-ticker.C: case <-ctx2.Done(): return } 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 _, event := range b.HardwareEvents() { bus.RunEvent(event) } 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 lucifer3.ErrUnexpectedResponse } b.mu.Lock() for _, panel := range b.panels { panel.Stale = true } b.mu.Unlock() return nil } func (b *bridge) runTouchListener(ctx context.Context, bus *lucifer3.EventBus) { cooldownID := "" 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", b.host, b.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() id, hasID := b.panelIDMap[message.PanelID(i)] swipedFromID := b.panelIDMap[message.SwipedFromPanelID(i)] b.mu.Unlock() if !hasID || (id == cooldownID && time.Now().Before(cooldownUntil)) { continue } bus.RunEvent(events.ButtonPressed{ ID: id, SwipedID: swipedFromID, Name: "Touch", }) cooldownID = id cooldownUntil = time.Now().Add(time.Second) } } } teardownAndRetry: if touchListener != nil { _ = touchListener.Close() } if reqCloser != nil { _ = reqCloser.Close() reqCloser = nil } select { case <-time.After(time.Second * 3): case <-ctx.Done(): return } } }