From 753d2125b4c1c033bd31373ca127b62923a2b0a1 Mon Sep 17 00:00:00 2001 From: Eliot Horowitz Date: Sun, 14 Dec 2025 21:34:10 -0500 Subject: [PATCH] add support for lcd on the plus --- cmd/demo/demo-cmd.go | 25 +++++ config.go | 20 ++++ streamdeck.go | 252 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 296 insertions(+), 1 deletion(-) diff --git a/cmd/demo/demo-cmd.go b/cmd/demo/demo-cmd.go index bfb43ed..583e36b 100644 --- a/cmd/demo/demo-cmd.go +++ b/cmd/demo/demo-cmd.go @@ -46,6 +46,31 @@ func realMain() error { return err } + // If this is a Stream Deck Plus, demonstrate the LCD panel + if sd.Config.HasLCD() { + log.Printf("Stream Deck Plus detected - demonstrating LCD panel") + + // Fill each LCD segment with a different color and label + segments := []struct { + color color.RGBA + label string + }{ + {color.RGBA{255, 0, 0, 255}, "Red"}, + {color.RGBA{0, 255, 0, 255}, "Green"}, + {color.RGBA{0, 0, 255, 255}, "Blue"}, + {color.RGBA{255, 255, 0, 255}, "Yellow"}, + } + + for i, seg := range segments { + err = sd.WriteTextToLCDSegment(i, seg.color, []streamdeck.LCDTextLine{ + {Text: seg.label, PosX: 50, PosY: 40, FontSize: 24, FontColor: color.RGBA{255, 255, 255, 255}}, + }) + if err != nil { + return err + } + } + } + // capture Streamdeck events sd.SetBtnEventCb(func(s streamdeck.State, e streamdeck.Event) { log.Printf("got event: %v state: %v", e, s) diff --git a/config.go b/config.go index 2eb3c79..9341386 100644 --- a/config.go +++ b/config.go @@ -13,6 +13,10 @@ type Config struct { ImageFormat string ImageRotate bool ConvertKey bool + // LCD touchscreen panel (Stream Deck Plus only) + LCDWidth int // Width of the LCD touchscreen panel (0 if not present) + LCDHeight int // Height of the LCD touchscreen panel + NumEncoders int // Number of rotary encoders (for calculating LCD segment widths) } func (c Config) NumButtons() int { @@ -29,6 +33,19 @@ func (c *Config) PanelHeight() int { return c.NumButtonRows*c.ButtonSize + c.Spacer*(c.NumButtonRows-1) } +// HasLCD returns true if this Stream Deck model has an LCD touchscreen panel. +func (c *Config) HasLCD() bool { + return c.LCDWidth > 0 && c.LCDHeight > 0 +} + +// LCDSegmentWidth returns the width of each LCD segment (area behind each encoder). +func (c *Config) LCDSegmentWidth() int { + if c.NumEncoders == 0 { + return 0 + } + return c.LCDWidth / c.NumEncoders +} + func (c *Config) fixKey(key int) int { if c.ConvertKey { keyCol := key % c.NumButtonColumns @@ -76,6 +93,9 @@ var Plus = Config{ Spacer: 19, ButtonSize: 120, ImageFormat: "jpg", + LCDWidth: 800, + LCDHeight: 100, + NumEncoders: 4, } var AllConfigs = []Config{Original, OriginalMk1, Original2, Plus} diff --git a/streamdeck.go b/streamdeck.go index 7edfdde..00f7ce4 100644 --- a/streamdeck.go +++ b/streamdeck.go @@ -163,7 +163,7 @@ func (sd *StreamDeck) read(ctx context.Context) { continue } - debug("read data:", data) + debug("read data: %v", data) events, err := myState.Update(sd.Config, data) if err != nil { @@ -457,6 +457,166 @@ func (sd *StreamDeck) FillPanelFromFile(path string) error { return sd.FillPanel(img) } +// FillLCD fills the entire LCD touchscreen panel with an image (Stream Deck Plus only). +// The image will be resized to fit the LCD dimensions (800x100 pixels). +func (sd *StreamDeck) FillLCD(img image.Image) error { + if !sd.Config.HasLCD() { + return fmt.Errorf("this Stream Deck model does not have an LCD panel") + } + + return sd.fillLCDRegion(img, 0, sd.Config.LCDWidth, sd.Config.LCDHeight) +} + +// FillLCDSegment fills a specific segment of the LCD panel (the area behind each encoder). +// Segment index is 0-3 for Stream Deck Plus (left to right). +func (sd *StreamDeck) FillLCDSegment(segmentIndex int, img image.Image) error { + if !sd.Config.HasLCD() { + return fmt.Errorf("this Stream Deck model does not have an LCD panel") + } + + if segmentIndex < 0 || segmentIndex >= sd.Config.NumEncoders { + return fmt.Errorf("invalid segment index %d, must be 0-%d", segmentIndex, sd.Config.NumEncoders-1) + } + + segmentWidth := sd.Config.LCDSegmentWidth() + xOffset := segmentIndex * segmentWidth + + return sd.fillLCDRegion(img, xOffset, segmentWidth, sd.Config.LCDHeight) +} + +// fillLCDRegion writes an image to a specific region of the LCD panel. +func (sd *StreamDeck) fillLCDRegion(img image.Image, xOffset, width, height int) error { + // Resize image to fit the target region + rect := img.Bounds() + if rect.Dx() != width || rect.Dy() != height { + img = resize(img, width, height) + } + + // Encode as JPEG + buf := bytes.Buffer{} + err := jpeg.Encode(&buf, img, nil) + if err != nil { + return err + } + imgBuf := buf.Bytes() + + sd.lock.Lock() + defer sd.lock.Unlock() + + // LCD uses 16-byte header with 1024-byte packets + headerSize := 16 + bytesLeft := len(imgBuf) + pos := 0 + chunkIndex := uint16(0) + + for bytesLeft > 0 { + imgToSend := min(bytesLeft, 1024-headerSize) + bytesLeft -= imgToSend + + packet := make([]byte, 1024) + packet[0] = 0x02 + packet[1] = 0x0C + binary.LittleEndian.PutUint16(packet[2:], uint16(xOffset)) // X offset + packet[4] = 0x00 // constant + packet[5] = 0x00 // constant + binary.LittleEndian.PutUint16(packet[6:], uint16(width)) // image width + binary.LittleEndian.PutUint16(packet[8:], uint16(height)) // image height + if bytesLeft == 0 { + packet[10] = 0x01 // final chunk + } else { + packet[10] = 0x00 + } + binary.LittleEndian.PutUint16(packet[11:], chunkIndex) // chunk index + binary.LittleEndian.PutUint16(packet[13:], uint16(imgToSend)) // payload length + packet[15] = 0x00 // constant + + copy(packet[16:], imgBuf[pos:(pos+imgToSend)]) + + debug("LCD write: xOffset=%d width=%d height=%d chunk=%d bytesLeft=%d imgToSend=%d", + xOffset, width, height, chunkIndex, bytesLeft, imgToSend) + + n, err := sd.device.Write(packet) + if err != nil { + return err + } + if n != len(packet) { + return fmt.Errorf("only wrote %d of %d", n, len(packet)) + } + + chunkIndex++ + pos += imgToSend + } + + return nil +} + +// FillLCDFromFile fills the entire LCD panel with an image from a file. +func (sd *StreamDeck) FillLCDFromFile(path string) error { + reader, err := os.Open(path) + if err != nil { + return err + } + defer reader.Close() + + img, _, err := image.Decode(reader) + if err != nil { + return err + } + + return sd.FillLCD(img) +} + +// FillLCDSegmentFromFile fills a specific LCD segment with an image from a file. +func (sd *StreamDeck) FillLCDSegmentFromFile(segmentIndex int, path string) error { + reader, err := os.Open(path) + if err != nil { + return err + } + defer reader.Close() + + img, _, err := image.Decode(reader) + if err != nil { + return err + } + + return sd.FillLCDSegment(segmentIndex, img) +} + +// ClearLCD fills the entire LCD panel with black. +func (sd *StreamDeck) ClearLCD() error { + if !sd.Config.HasLCD() { + return fmt.Errorf("this Stream Deck model does not have an LCD panel") + } + + img := image.NewRGBA(image.Rect(0, 0, sd.Config.LCDWidth, sd.Config.LCDHeight)) + draw.Draw(img, img.Bounds(), image.NewUniform(color.Black), image.Point{0, 0}, draw.Src) + + return sd.FillLCD(img) +} + +// FillLCDColor fills the entire LCD panel with a solid color. +func (sd *StreamDeck) FillLCDColor(r, g, b int) error { + if !sd.Config.HasLCD() { + return fmt.Errorf("this Stream Deck model does not have an LCD panel") + } + + if err := checkRGB(r); err != nil { + return err + } + if err := checkRGB(g); err != nil { + return err + } + if err := checkRGB(b); err != nil { + return err + } + + img := image.NewRGBA(image.Rect(0, 0, sd.Config.LCDWidth, sd.Config.LCDHeight)) + c := color.RGBA{uint8(r), uint8(g), uint8(b), 255} + draw.Draw(img, img.Bounds(), image.NewUniform(c), image.Point{0, 0}, draw.Src) + + return sd.FillLCD(img) +} + // WriteText can write several lines of Text to a button. It is up to the // user to ensure that the lines fit properly on the button. func (sd *StreamDeck) WriteText(btnIndex int, textBtn TextButton) error { @@ -508,6 +668,96 @@ func (sd *StreamDeck) checkValidKeyIndex(keyIndex int) error { return nil } +// LCDTextLine holds the content of one text line for LCD display. +type LCDTextLine struct { + Text string + PosX int + PosY int + Font *truetype.Font + FontSize float64 + FontColor color.Color +} + +// WriteTextToLCD writes text lines to the entire LCD panel with a background color. +func (sd *StreamDeck) WriteTextToLCD(bgColor color.Color, lines []LCDTextLine) error { + if !sd.Config.HasLCD() { + return fmt.Errorf("this Stream Deck model does not have an LCD panel") + } + + img := image.NewRGBA(image.Rect(0, 0, sd.Config.LCDWidth, sd.Config.LCDHeight)) + draw.Draw(img, img.Bounds(), image.NewUniform(bgColor), image.Point{0, 0}, draw.Src) + + return sd.WriteTextToLCDOnImage(img, lines) +} + +// WriteTextToLCDOnImage writes text lines to the LCD panel on top of a provided image. +func (sd *StreamDeck) WriteTextToLCDOnImage(imgIn image.Image, lines []LCDTextLine) error { + if !sd.Config.HasLCD() { + return fmt.Errorf("this Stream Deck model does not have an LCD panel") + } + + img := resize(imgIn, sd.Config.LCDWidth, sd.Config.LCDHeight) + + for _, line := range lines { + font := line.Font + if font == nil { + font = MonoRegular + } + fontColor := image.NewUniform(line.FontColor) + c := freetype.NewContext() + c.SetDPI(72) + c.SetFont(font) + c.SetFontSize(line.FontSize) + c.SetClip(img.Bounds()) + c.SetDst(img) + c.SetSrc(fontColor) + pt := freetype.Pt(line.PosX, line.PosY+int(c.PointToFixed(line.FontSize)>>6)) + + if _, err := c.DrawString(line.Text, pt); err != nil { + return err + } + } + + return sd.FillLCD(img) +} + +// WriteTextToLCDSegment writes text lines to a specific LCD segment with a background color. +func (sd *StreamDeck) WriteTextToLCDSegment(segmentIndex int, bgColor color.Color, lines []LCDTextLine) error { + if !sd.Config.HasLCD() { + return fmt.Errorf("this Stream Deck model does not have an LCD panel") + } + + if segmentIndex < 0 || segmentIndex >= sd.Config.NumEncoders { + return fmt.Errorf("invalid segment index %d, must be 0-%d", segmentIndex, sd.Config.NumEncoders-1) + } + + segmentWidth := sd.Config.LCDSegmentWidth() + img := image.NewRGBA(image.Rect(0, 0, segmentWidth, sd.Config.LCDHeight)) + draw.Draw(img, img.Bounds(), image.NewUniform(bgColor), image.Point{0, 0}, draw.Src) + + for _, line := range lines { + font := line.Font + if font == nil { + font = MonoRegular + } + fontColor := image.NewUniform(line.FontColor) + c := freetype.NewContext() + c.SetDPI(72) + c.SetFont(font) + c.SetFontSize(line.FontSize) + c.SetClip(img.Bounds()) + c.SetDst(img) + c.SetSrc(fontColor) + pt := freetype.Pt(line.PosX, line.PosY+int(c.PointToFixed(line.FontSize)>>6)) + + if _, err := c.DrawString(line.Text, pt); err != nil { + return err + } + } + + return sd.FillLCDSegment(segmentIndex, img) +} + // b 0 -> 100 func (sd *StreamDeck) SetBrightness(b uint16) error {