// Command epdctl is a small CLI for the epd7in3e panel. Every // command runs Init, draws, then Sleep. With -dry-run out.png a // command instead writes the quantised 800x480 frame to out.png, so // the pipeline can be checked without hardware. package main import ( "context" "errors" "flag" "fmt" "image" "image/png" "math" "os" _ "image/jpeg" // register JPEG with image.Decode _ "image/png" // register PNG with image.Decode "go-epd7in3e" "golang.org/x/image/draw" ) // panelColours lists the six panel colours with their Palette index. var panelColours = []struct { name string col epd7in3e.Color index uint8 }{ {"black", epd7in3e.Black, 0}, {"white", epd7in3e.White, 1}, {"yellow", epd7in3e.Yellow, 2}, {"red", epd7in3e.Red, 3}, {"blue", epd7in3e.Blue, 4}, {"green", epd7in3e.Green, 5}, } func main() { if err := run(os.Args[1:]); err != nil { fmt.Fprintf(os.Stderr, "epdctl: %v\n", err) os.Exit(1) } } func run(args []string) error { if len(args) == 0 { usage() return errors.New("no command given") } cmd, rest := args[0], args[1:] switch cmd { case "clear": return doClear(rest) case "bars": return doBars(rest) case "show": return doShow(rest) case "help", "-h", "--help": usage() return nil } return fmt.Errorf("unknown command %q (want clear, bars, show or help)", cmd) } func usage() { fmt.Fprint(os.Stderr, `epdctl drives the Waveshare epd7in3e e-paper panel. Usage: epdctl clear [black|white|yellow|red|blue|green] fill the panel (default white) epdctl bars six vertical colour bars epdctl show letterbox, dither and display Each command initialises the panel, draws, then puts it to sleep. Add -dry-run out.png to write the quantised 800x480 frame to out.png instead of touching the hardware. Add -v to log every command and busy-wait duration to stderr (bring-up diagnostics, HARDWARE_CHECK.md). `) } // dryRunFlag registers the -dry-run flag: when it names a file, the // quantised frame is written there and the panel is never opened. func dryRunFlag(fs *flag.FlagSet) *string { return fs.String("dry-run", "", "write the quantised frame to this PNG file instead of touching the panel") } func verboseFlag(fs *flag.FlagSet) *bool { return fs.Bool("v", false, "log every command and busy-wait duration to stderr") } // onPanel opens the HAT and runs the fixed lifecycle: Init, op, // Sleep, then power off. Dither: true is set explicitly so the // zero-value Opts field cannot turn it off. func onPanel(v bool, op func(d *epd7in3e.Dev, ctx context.Context) error) error { d, err := epd7in3e.NewHAT(&epd7in3e.Opts{Dither: true, Verbose: v}) if err != nil { return err } ctx := context.Background() if err := d.Init(ctx); err != nil { return err } if err := op(d, ctx); err != nil { return err } if err := d.Sleep(ctx); err != nil { return err } return d.Halt() } // writeFrame saves the quantised frame as a PNG. func writeFrame(path string, frame *image.Paletted) error { f, err := os.Create(path) if err != nil { return fmt.Errorf("create %s: %w", path, err) } if err := png.Encode(f, frame); err != nil { f.Close() return fmt.Errorf("encode %s: %w", path, err) } if err := f.Close(); err != nil { return fmt.Errorf("close %s: %w", path, err) } b := frame.Bounds() fmt.Printf("wrote %dx%d preview to %s\n", b.Dx(), b.Dy(), path) return nil } func doClear(args []string) error { fs := flag.NewFlagSet("clear", flag.ExitOnError) out := dryRunFlag(fs) v := verboseFlag(fs) fs.Parse(args) name := "white" if fs.NArg() == 1 { name = fs.Arg(0) } var c epd7in3e.Color found := false for _, pc := range panelColours { if pc.name == name { c, found = pc.col, true break } } if !found { return fmt.Errorf("unknown colour %q (want black, white, yellow, red, blue or green)", name) } if *out != "" { return writeFrame(*out, solidFrame(c)) } return onPanel(*v, func(d *epd7in3e.Dev, ctx context.Context) error { return d.Clear(ctx, c) }) } func doBars(args []string) error { fs := flag.NewFlagSet("bars", flag.ExitOnError) out := dryRunFlag(fs) v := verboseFlag(fs) fs.Parse(args) if fs.NArg() != 0 { return errors.New("bars takes no arguments") } frame := barsFrame() if *out != "" { return writeFrame(*out, frame) } return onPanel(*v, func(d *epd7in3e.Dev, ctx context.Context) error { return d.Display(ctx, frame) }) } // barsFrame builds six vertical bars, one per panel colour. func barsFrame() *image.Paletted { img := image.NewPaletted(image.Rect(0, 0, epd7in3e.Width, epd7in3e.Height), epd7in3e.Palette) for i, pc := range panelColours { x0, x1 := i*epd7in3e.Width/6, (i+1)*epd7in3e.Width/6 for x := x0; x < x1; x++ { for y := 0; y < epd7in3e.Height; y++ { img.Pix[y*img.Stride+x] = pc.index } } } return img } func doShow(args []string) error { fs := flag.NewFlagSet("show", flag.ExitOnError) out := dryRunFlag(fs) v := verboseFlag(fs) fs.Parse(args) if fs.NArg() != 1 { return errors.New("show needs one image file (png or jpg)") } f, err := os.Open(fs.Arg(0)) if err != nil { return fmt.Errorf("open image: %w", err) } defer f.Close() src, format, err := image.Decode(f) if err != nil { return fmt.Errorf("decode %s: %w", fs.Arg(0), err) } if format != "png" && format != "jpeg" { return fmt.Errorf("%s: %s image, want png or jpg", fs.Arg(0), format) } frame := letterboxDithered(src) if *out != "" { return writeFrame(*out, frame) } return onPanel(*v, func(d *epd7in3e.Dev, ctx context.Context) error { return d.Display(ctx, frame) }) } // letterboxDithered scales src to fit within 800x480 preserving its // aspect ratio, centers it on a white field, then dithers the result // to the panel's palette. func letterboxDithered(src image.Image) *image.Paletted { b := src.Bounds() w, h := b.Dx(), b.Dy() if w <= 0 || h <= 0 { w, h = 1, 1 } scale := math.Min(float64(epd7in3e.Width)/float64(w), float64(epd7in3e.Height)/float64(h)) sw := int(math.Round(float64(w) * scale)) sh := int(math.Round(float64(h) * scale)) if sw < 1 { sw = 1 } if sh < 1 { sh = 1 } scaled := scaleTo(src, sw, sh) canvas := image.NewNRGBA(image.Rect(0, 0, epd7in3e.Width, epd7in3e.Height)) for i := range canvas.Pix { canvas.Pix[i] = 255 // white letterbox } // Center the scaled image. The offset goes on the destination // rectangle: draw.Draw's source point is a point in the source's // coordinate system that is translated to the destination's // top-left, so passing the offset there would crop the source // instead of shifting it. ox := (epd7in3e.Width - sw) / 2 oy := (epd7in3e.Height - sh) / 2 draw.Draw(canvas, image.Rect(ox, oy, ox+sw, oy+sh), scaled, scaled.Bounds().Min, draw.Over) return epd7in3e.Quantise(canvas, true) } // scaleTo resizes src to exactly sw x sh with Catmull-Rom filtering. func scaleTo(src image.Image, sw, sh int) *image.NRGBA { b := src.Bounds() if n, ok := src.(*image.NRGBA); ok && n.Rect.Dx() == b.Dx() && n.Rect.Dy() == b.Dy() { return scaleToNRGBA(n, b, sw, sh) } n := image.NewNRGBA(b) draw.Draw(n, b, src, b.Min, draw.Src) return scaleToNRGBA(n, b, sw, sh) } func scaleToNRGBA(src *image.NRGBA, sr image.Rectangle, sw, sh int) *image.NRGBA { dst := image.NewNRGBA(image.Rect(0, 0, sw, sh)) draw.CatmullRom.Scale(dst, dst.Bounds(), src, sr, draw.Src, nil) return dst } // solidFrame builds an 800x480 frame in one colour, for the // dry-run preview. func solidFrame(c epd7in3e.Color) *image.Paletted { img := image.NewPaletted(image.Rect(0, 0, epd7in3e.Width, epd7in3e.Height), epd7in3e.Palette) var idx uint8 for _, pc := range panelColours { if pc.col == c { idx = pc.index break } } for i := range img.Pix { img.Pix[i] = idx } return img }