diff --git a/.gitignore b/.gitignore index 5b90e79..ad0f181 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ go.work.sum # env file .env +# logogen cache +cmd/logogen/cache/*.bin + diff --git a/README.md b/README.md index bcd4cf0..94e51fc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,70 @@ # badges +TinyGo badge firmware for multiple hardware targets (GopherBadge, NiceBadge, GoBadge/PyBadge). + +## Hardware targets + +| Target | Build tag | Resolution | +|--------|-----------|------------| +| GopherBadge | `gopher_badge` | 320×240 | +| NiceBadge | `nicenano` | 240×135 | +| GoBadge / PyBadge | `pybadge` | 160×128 | + +## Generating logo.bin + +The badge firmware embeds a `logo.bin` file displayed at boot (`showLogoBin` in `badge.go`). +Use the `logogen` command to convert any image to the required hex format. + +### Setup + +```sh +cd cmd/logogen +go mod download +``` + +### Usage + +```sh +# By target name +go run . -size nicebadge -image tinygo +go run . -size gopherbadge -image gopherconeu +go run . -size gobadge -image golab + +# By explicit resolution +go run . -size 320x240 -image /path/to/logo.jpg + +# Write directly to the project root logo.bin +go run . -size nicebadge -image tinygo -output ../../logo.bin +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `-size` | *(required)* | Target name (`nicebadge`, `gopherbadge`, `gobadge`, `pybadge`) or explicit resolution (`320x240`) | +| `-image` | *(required)* | File path (relative or absolute) or short name | +| `-output` | `logo.bin` | Output file | +| `-images-dir` | `images` | Directory with source images | +| `-cache-dir` | `cache` | Directory for cached `.bin` files | + +### Adding images + +Place source images inside `cmd/logogen/images/`: + +``` +cmd/logogen/images/ + tinygo.jpg ← source image for short name "tinygo" + tinygo_240x135.png ← predefined variant for nicebadge (skip resize) + tinygo_320x240.png ← predefined variant for gopherbadge (skip resize) + gopherconeu.jpg + golab.png + ... +``` + +**Resolution priority** for a given short name + target: + +1. `_.png` / `.jpg` — predefined resized variant, used as-is (no resize) +2. `.png` / `.jpg` / `.jpeg` — base image, resized with BiLinear interpolation +3. Cache hit (`cache/_.bin`) — skips all image processing on repeat runs + +Generated `.bin` files in `cache/` are gitignored. diff --git a/cmd/logogen/cache/.gitkeep b/cmd/logogen/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/logogen/go.mod b/cmd/logogen/go.mod new file mode 100644 index 0000000..f528c45 --- /dev/null +++ b/cmd/logogen/go.mod @@ -0,0 +1,5 @@ +module github.com/conejoninja/gopherbadge/cmd/logogen + +go 1.22.1 + +require golang.org/x/image v0.23.0 diff --git a/cmd/logogen/go.sum b/cmd/logogen/go.sum new file mode 100644 index 0000000..8ec2c9e --- /dev/null +++ b/cmd/logogen/go.sum @@ -0,0 +1,2 @@ +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= diff --git a/cmd/logogen/images/.gitkeep b/cmd/logogen/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/logogen/logogen b/cmd/logogen/logogen new file mode 100755 index 0000000..eddcbc8 Binary files /dev/null and b/cmd/logogen/logogen differ diff --git a/cmd/logogen/main.go b/cmd/logogen/main.go new file mode 100644 index 0000000..6b9188e --- /dev/null +++ b/cmd/logogen/main.go @@ -0,0 +1,208 @@ +// logogen converts an image to a .bin file suitable for embedding in badge firmware. +// +// Usage: +// +// logogen -size -image [-output logo.bin] [-images-dir images] [-cache-dir cache] +// +// Examples: +// +// logogen -size nicebadge -image tinygo +// logogen -size gopherbadge -image gopherconeu +// logogen -size 320x240 -image /path/to/logo.jpg +// logogen -size gobadge -image ./my-logo.png -output logo.bin +// +// Short names resolve to image files inside -images-dir. +// If a predefined resized variant exists (e.g. tinygo_320x240.png), it is used directly. +// Generated .bin files are cached in -cache-dir to avoid reprocessing. +package main + +import ( + "flag" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "log" + "os" + "path/filepath" + "strconv" + "strings" + + draw2 "golang.org/x/image/draw" +) + +type resolution struct { + width, height int +} + +func (r resolution) String() string { + return fmt.Sprintf("%dx%d", r.width, r.height) +} + +var predefinedTargets = map[string]resolution{ + "nicebadge": {240, 135}, + "gopherbadge": {320, 240}, + "gobadge": {160, 128}, + "pybadge": {160, 128}, +} + +func main() { + size := flag.String("size", "", "Resolution: WxH or target name (nicebadge, gopherbadge, gobadge, pybadge)") + imageArg := flag.String("image", "", "Image: file path (relative/absolute) or short name (tinygo, gopherconeu, golab, ...)") + output := flag.String("output", "logo.bin", "Output .bin file path") + imagesDir := flag.String("images-dir", "images", "Directory with source images and predefined variants") + cacheDir := flag.String("cache-dir", "cache", "Directory for cached .bin files") + flag.Parse() + + if *size == "" { + flag.Usage() + log.Fatal("\nMust specify -size (e.g. -size nicebadge or -size 320x240)") + } + if *imageArg == "" { + flag.Usage() + log.Fatal("\nMust specify -image (file path or short name)") + } + + res := parseSize(*size) + + // Determine whether -image is a file path or a short name. + // Heuristic: contains path separator, starts with '.' or '..' , or has a file extension → path. + isPath := strings.ContainsAny(*imageArg, "/\\") || + strings.HasPrefix(*imageArg, ".") || + filepath.Ext(*imageArg) != "" + + var baseName string // used as cache/predefined key + var srcPath string // resolved source image file + + if isPath { + absPath, err := filepath.Abs(*imageArg) + if err != nil { + log.Fatalf("invalid image path %q: %v", *imageArg, err) + } + srcPath = absPath + base := filepath.Base(absPath) + baseName = strings.TrimSuffix(base, filepath.Ext(base)) + } else { + baseName = *imageArg + srcPath = findSourceImage(*imageArg, res, *imagesDir) + } + + cacheFile := filepath.Join(*cacheDir, fmt.Sprintf("%s_%s.bin", baseName, res)) + + // 1. Check cache. + if data, err := os.ReadFile(cacheFile); err == nil { + fmt.Printf("Using cached: %s\n", cacheFile) + writeOutput(*output, data) + fmt.Printf("Written: %s (%s)\n", *output, res) + return + } + + // 2. Load, resize if needed, convert. + img := loadAndResize(srcPath, res) + binData := convertToBin(img, res) + + // 3. Save cache. + if err := os.MkdirAll(*cacheDir, 0755); err != nil { + log.Printf("Warning: cannot create cache dir %s: %v", *cacheDir, err) + } else if err := os.WriteFile(cacheFile, binData, 0644); err != nil { + log.Printf("Warning: cannot save cache %s: %v", cacheFile, err) + } else { + fmt.Printf("Cached: %s\n", cacheFile) + } + + writeOutput(*output, binData) + fmt.Printf("Generated: %s (%s, %d bytes)\n", *output, res, len(binData)) +} + +// parseSize accepts either a target name (nicebadge, gopherbadge, ...) or WxH format. +func parseSize(s string) resolution { + if res, ok := predefinedTargets[strings.ToLower(s)]; ok { + return res + } + parts := strings.SplitN(strings.ToLower(s), "x", 2) + if len(parts) != 2 { + log.Fatalf("invalid size %q: must be WxH (e.g. 320x240) or a target name (nicebadge, gopherbadge, gobadge, pybadge)", s) + } + w, err1 := strconv.Atoi(parts[0]) + h, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || w <= 0 || h <= 0 { + log.Fatalf("invalid size %q: width and height must be positive integers", s) + } + return resolution{w, h} +} + +// findSourceImage looks for the best source image for a short name in imagesDir. +// Priority: +// 1. _.png / .jpg — predefined resized variant (no resize needed) +// 2. .png / .jpg / .jpeg — base source image (will be resized) +func findSourceImage(name string, res resolution, imagesDir string) string { + resStr := res.String() + extensions := []string{".png", ".jpg", ".jpeg"} + + // Try predefined resized variant first. + for _, ext := range extensions { + p := filepath.Join(imagesDir, fmt.Sprintf("%s_%s%s", name, resStr, ext)) + if _, err := os.Stat(p); err == nil { + fmt.Printf("Using predefined: %s\n", p) + return p + } + } + + // Try base source image. + for _, ext := range extensions { + p := filepath.Join(imagesDir, name+ext) + if _, err := os.Stat(p); err == nil { + return p + } + } + + log.Fatalf("image not found for short name %q in directory %q\n"+ + "Expected one of: %s_%s.png, %s.png, %s.jpg, ...", + name, imagesDir, name, resStr, name, name) + return "" +} + +// loadAndResize opens a JPEG or PNG and scales it to the target resolution. +// If the image is already the exact target size, scaling is skipped. +func loadAndResize(path string, res resolution) image.Image { + f, err := os.Open(path) + if err != nil { + log.Fatalf("cannot open image %s: %v", path, err) + } + defer f.Close() + + img, format, err := image.Decode(f) + if err != nil { + log.Fatalf("cannot decode image %s: %v", path, err) + } + fmt.Printf("Loaded: %s (%s, %dx%d)\n", path, format, img.Bounds().Dx(), img.Bounds().Dy()) + + if img.Bounds().Dx() == res.width && img.Bounds().Dy() == res.height { + return img + } + + fmt.Printf("Resizing to %s...\n", res) + dst := image.NewRGBA(image.Rect(0, 0, res.width, res.height)) + draw2.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw2.Over, nil) + return dst +} + +// convertToBin encodes the image pixels as a hex string: "RRGGBB" per pixel, row-major. +// This matches the format expected by showLogoBin() in badge.go. +func convertToBin(img image.Image, res resolution) []byte { + var sb strings.Builder + sb.Grow(res.width * res.height * 6) + for y := 0; y < res.height; y++ { + for x := 0; x < res.width; x++ { + r, g, b, _ := img.At(x, y).RGBA() + fmt.Fprintf(&sb, "%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8)) + } + } + return []byte(sb.String()) +} + +func writeOutput(path string, data []byte) { + if err := os.WriteFile(path, data, 0644); err != nil { + log.Fatalf("cannot write output %s: %v", path, err) + } +}