diff --git a/.gitignore b/.gitignore index 9e0918c..0688f46 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ include/version.h /net-commander docs data -test \ No newline at end of file +test +dist/ diff --git a/scripts/build_release.sh b/scripts/build_release.sh new file mode 100755 index 0000000..0a5ac73 --- /dev/null +++ b/scripts/build_release.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# scripts/build_release.sh +# Build firmware and assemble release artifacts: +# - -merged.bin (single-file flash at 0x0) +# - -firmware.bin (app-only image at 0x10000) +# - -checksums.txt (SHA-256 sums) +# Output lands in dist/ + +set -euo pipefail + +ENV="${1:-Esp32dev}" +BUILD_DIR=".pio/build/$ENV" +OUT_DIR="dist" + +# Resolve version from git describe +VERSION="$(git describe --tags --dirty 2>/dev/null || echo unknown)" +if [[ "$VERSION" == "unknown" ]]; then + echo "warn: not in a git repo or no tags found; using 'unknown' as version" >&2 +fi + +# Locate pio — prefer PATH, fall back to PlatformIO's penv +if command -v pio >/dev/null 2>&1; then + PIO=pio +elif [[ -x "$HOME/.platformio/penv/bin/pio" ]]; then + PIO="$HOME/.platformio/penv/bin/pio" +else + echo "error: pio not found on PATH or in ~/.platformio/penv/bin" >&2 + exit 1 +fi + +# Build first (no-op if everything up to date) +echo "==> Building $ENV" +"$PIO" run -e "$ENV" + +# Sanity check build artifacts +for f in firmware.bin bootloader.bin partitions.bin; do + if [[ ! -f "$BUILD_DIR/$f" ]]; then + echo "error: missing $BUILD_DIR/$f after build" >&2 + exit 1 + fi +done + +# Locate boot_app0.bin in the framework package (not in the build dir) +BOOT_APP0=$(find "$HOME/.platformio/packages/framework-arduinoespressif32" \ + -path "*/tools/partitions/boot_app0.bin" 2>/dev/null | head -1) +if [[ -z "$BOOT_APP0" ]]; then + echo "error: boot_app0.bin not found in framework-arduinoespressif32 package" >&2 + exit 1 +fi + +# Locate esptool — prefer PATH, fall back to PlatformIO-bundled +if command -v esptool.py >/dev/null 2>&1; then + ESPTOOL_CMD=(esptool.py) +elif [[ -f "$HOME/.platformio/packages/tool-esptoolpy/esptool.py" ]]; then + ESPTOOL_CMD=(python3 "$HOME/.platformio/packages/tool-esptoolpy/esptool.py") +else + echo "error: esptool.py not found on PATH or in tool-esptoolpy package" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" + +MERGED="$OUT_DIR/${VERSION}-merged.bin" +APPONLY="$OUT_DIR/${VERSION}-firmware.bin" +CHECKSUMS="$OUT_DIR/${VERSION}-checksums.txt" + +echo "==> Merging into $MERGED" +"${ESPTOOL_CMD[@]}" --chip esp32 merge_bin -o "$MERGED" \ + --flash_mode dio --flash_freq 40m --flash_size 4MB \ + 0x1000 "$BUILD_DIR/bootloader.bin" \ + 0x8000 "$BUILD_DIR/partitions.bin" \ + 0xe000 "$BOOT_APP0" \ + 0x10000 "$BUILD_DIR/firmware.bin" + +cp "$BUILD_DIR/firmware.bin" "$APPONLY" + +echo "==> Generating checksums" +( cd "$OUT_DIR" && sha256sum \ + "$(basename "$MERGED")" \ + "$(basename "$APPONLY")" \ + > "$(basename "$CHECKSUMS")" ) + +echo "" +echo "Release artifacts:" +ls -lh "$MERGED" "$APPONLY" "$CHECKSUMS" +echo "" +echo "Flash with:" +echo " esptool.py --chip esp32 --port /dev/ttyUSB0 write_flash 0x0 $MERGED" \ No newline at end of file diff --git a/scripts/icon_prep.sh b/scripts/icon_prep.sh new file mode 100755 index 0000000..4a73a06 --- /dev/null +++ b/scripts/icon_prep.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# icon_prep.sh — batch-prep PNG icons for GUIslice Image2C +# +# Reads one or more source PNGs, outputs bilevel white-on-transparent PNGs +# (PNG32 RGBA on disk to bypass Java decoder bug with 1-bit greyscale + tRNS), +# optionally generating Image2C-compatible 1bpp C arrays in the same pass. +# +# Output direction is the contract; input direction is auto-detected by mean +# brightness and inverted only when needed. +# +# Works with both ImageMagick v6 (convert/identify) and v7 (magick). +# C array generation requires python3 and pbm_to_gslc_c.py alongside this script. +# +# Usage: +# ./icon_prep.sh [-s WxH] [-i auto|on|off] [-c] [file.png ...] +# ./icon_prep.sh -s 40x40 -c *.png +# +# Output: +# ./prepped/_px.png +# ./prepped/_px.c (only with -c) +# +# Re-runs silently overwrite existing outputs. + +set -euo pipefail + +# --- defaults --- +OUT_DIR="prepped" +SIZE="40x40" # default; override with -s +INVERT_MODE="auto" # auto | on | off +THRESHOLD="50%" # b/w split point; lower = thinner strokes, higher = fatter +EMIT_C=false # -c flag: also emit Image2C-format .c file + +usage() { + cat < [file.png ...] + + -s WxH Resize to WxH pixels. Default: 40x40. + -i MODE Invert behaviour: + auto detect from mean brightness (default) + on force invert + off skip invert + -c Also emit C array (.c file) for direct firmware use. + Requires python3 + pbm_to_gslc_c.py in script directory. + -h Show this help. + +Output: ./${OUT_DIR}/_px.png (+ .c with -c) +EOF + exit "${1:-0}" +} + +# --- args --- +while getopts ":s:i:ch" opt; do + case "$opt" in + s) SIZE="$OPTARG" ;; + i) INVERT_MODE="$OPTARG" ;; + c) EMIT_C=true ;; + h) usage 0 ;; + \?) echo "unknown flag: -$OPTARG" >&2; usage 1 ;; + :) echo "flag -$OPTARG needs an argument" >&2; usage 1 ;; + esac +done +shift $((OPTIND - 1)) + +case "$INVERT_MODE" in + auto|on|off) ;; + *) echo "invalid -i mode: $INVERT_MODE (use auto|on|off)" >&2; exit 1 ;; +esac + +[[ $# -ge 1 ]] || usage 1 + +# --- preflight: pick ImageMagick binary (v7 magick > v6 convert) --- +if command -v magick >/dev/null 2>&1; then + IM_CONVERT=(magick) + IM_IDENTIFY=(magick identify) +elif command -v convert >/dev/null 2>&1 && command -v identify >/dev/null 2>&1; then + IM_CONVERT=(convert) + IM_IDENTIFY=(identify) +else + echo "error: ImageMagick not found." >&2 + echo " v6 (convert/identify): sudo apt install imagemagick" >&2 + echo " v7 (magick): sudo snap install imagemagick" >&2 + exit 1 +fi + +# --- preflight for -c flag --- +if [[ "$EMIT_C" == true ]]; then + command -v python3 >/dev/null 2>&1 \ + || { echo "error: python3 not found (needed for -c)" >&2; exit 1; } + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + PY_SCRIPT="$SCRIPT_DIR/pbm_to_gslc_c.py" + [[ -f "$PY_SCRIPT" ]] \ + || { echo "error: pbm_to_gslc_c.py not found at $PY_SCRIPT" >&2; exit 1; } +fi + +mkdir -p "$OUT_DIR" + +# --- process --- +for src in "$@"; do + if [[ ! -f "$src" ]]; then + echo "skip (not a file): $src" + continue + fi + + # effective size — flag wins; otherwise read source dimensions + if [[ -n "$SIZE" ]]; then + eff_size="$SIZE" + else + eff_size=$("${IM_IDENTIFY[@]}" -format "%wx%h" "$src") + fi + + # decide invert direction + case "$INVERT_MODE" in + on) + do_invert=true + tag="forced→invert" + ;; + off) + do_invert=false + tag="forced→keep" + ;; + auto) + mean_pct=$("${IM_CONVERT[@]}" "$src" \ + -background white -alpha remove -alpha off \ + -colorspace Gray \ + -format "%[fx:int(mean*100)]" info:) + if (( mean_pct > 50 )); then + do_invert=true + tag="bright→invert" + else + do_invert=false + tag="dark→keep" + fi + ;; + esac + + # build output paths + base=$(basename "$src") + stem="${base%.*}" + out="$OUT_DIR/${stem}_${eff_size}px.png" + + # PNG pipeline: PNG32 RGBA on disk dodges Java's 1-bit greyscale + tRNS bug + args=("$src" -background white -alpha remove -alpha off) + [[ -n "$SIZE" ]] && args+=(-resize "${SIZE}!") + [[ "$do_invert" == true ]] && args+=(-negate) + args+=( + -threshold "$THRESHOLD" + -type Bilevel + -transparent black + "PNG32:$out" + ) + "${IM_CONVERT[@]}" "${args[@]}" + echo "✓ $out [$tag]" + + # optional C-array emission + if [[ "$EMIT_C" == true ]]; then + cfile="${out%.png}.c" + cname="${stem}_${eff_size}px" + "${IM_CONVERT[@]}" "$out" \ + -background black -alpha remove -alpha off \ + -negate \ + pbm:- | \ + python3 "$PY_SCRIPT" \ + --name "$cname" \ + --src "$(basename "$out")" \ + --fg FFFFFF \ + > "$cfile" + echo " → $cfile" + fi +done + +echo "done — $OUT_DIR/" diff --git a/scripts/pbm_to_gslc_c.py b/scripts/pbm_to_gslc_c.py new file mode 100755 index 0000000..b9f2040 --- /dev/null +++ b/scripts/pbm_to_gslc_c.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +pbm_to_gslc_c.py — wrap a P4 PBM stream in GUIslice 1bpp C array boilerplate. + +Reads binary PBM (P4) from stdin (typically piped from ImageMagick), emits +a .c file matching Image2C's output format byte-for-byte. + +Usage (inside icon_prep.sh): + convert IN.png ... pbm:- | pbm_to_gslc_c.py --name foo --src foo.png > foo.c +""" +import argparse +import os +import sys + + +WIKI_URL = "https://github.com/ImpulseAdventure/GUIslice/wiki/Display-Images-from-FLASH" +COMMENT_COL = 100 # column to align trailing `// 0x____ ...` comments +BYTES_PER_LINE = 16 + + +def parse_args(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--name", required=True, + help="C array name (e.g. back_40x40px)") + p.add_argument("--src", required=True, + help="Source filename for header comment (e.g. back_40x40px.png)") + p.add_argument("--fg", default="FFFFFF", + help="Foreground colour as RRGGBB hex (default: FFFFFF)") + return p.parse_args() + + +def read_pbm(stream): + """Read P4 binary PBM from stream. Returns (width, height, data_bytes).""" + magic = stream.readline().strip() + if magic != b"P4": + sys.exit(f"error: expected P4 PBM, got {magic!r}") + # Skip comment lines, find dimensions + while True: + line = stream.readline().strip() + if line and not line.startswith(b"#"): + break + width, height = map(int, line.split()) + data = stream.read() + expected = ((width + 7) // 8) * height + if len(data) != expected: + sys.exit(f"error: expected {expected} data bytes, got {len(data)}") + return width, height, data + + +def format_data_lines(data): + """Format byte data as C array lines with Image2C-style trailing offset comments.""" + lines = [] + total = len(data) + for i in range(0, total, BYTES_PER_LINE): + chunk = data[i:i + BYTES_PER_LINE] + end_offset = i + len(chunk) + bytes_str = ", ".join(f"0x{b:02X}" for b in chunk) + # Trailing comma for all but the final chunk + if end_offset < total: + bytes_str += "," + padding = max(1, COMMENT_COL - len(bytes_str)) + # Image2C labels these "pixels" though they're bytes — preserve quirk + line = f"{bytes_str}{' ' * padding}// 0x{end_offset:04X} ({end_offset}) pixels" + lines.append(line) + return lines + + +def emit(name, src, width, height, data, fg_rgb): + r, g, b = fg_rgb + mem_size = len(data) + out = [] + out.append("//" + "-" * 78) + out.append("// File Generated by GUIslice_Image2C") + out.append("//" + "-" * 78) + out.append(f"// Generated from : {src}") + out.append(f"// Dimensions : {width}x{height} pixels") + out.append("// Bits Per Pixel : 1 Bits") + out.append(f"// Memory Size : {mem_size} Bytes") + out.append("// Little Endian : true") + out.append("//" + "-" * 78) + out.append("") + out.append("// For details on how to generate this file please refer to") + out.append(f"// {WIKI_URL}") + out.append("") + out.append('#include "GUIslice.h"') + out.append('#include "GUIslice_config.h"') + out.append("") + out.append("#if (GSLC_USE_PROGMEM)") + out.append(" #if defined(__AVR__)") + out.append(" #include ") + out.append(" #else") + out.append(" #include ") + out.append(" #endif") + out.append("#endif") + out.append("") + out.append(f"const unsigned char {name}[{mem_size}+7] GSLC_PMEM = {{") + # 16-bit big-endian height + width, each split into two byte literals + out.append(f"0x{(height >> 8) & 0xFF:02X}, // Height of image") + out.append(f"0x{height & 0xFF:02X},") + out.append(f"0x{(width >> 8) & 0xFF:02X}, // Width of image") + out.append(f"0x{width & 0xFF:02X},") + # RGB foreground colour as right-aligned decimal (matches Image2C style) + out.append(f"{r:3d}, // red color") + out.append(f"{g:3d}, // green color") + out.append(f"{b:3d}, // blue color") + out.extend(format_data_lines(data)) + out.append("};") + return "\n".join(out) + "\n" + + +def main(): + args = parse_args() + # Parse foreground hex + fg_hex = args.fg.lstrip("#") + if len(fg_hex) != 6: + sys.exit("error: --fg must be 6-hex-digit RRGGBB") + fg_rgb = tuple(int(fg_hex[i:i+2], 16) for i in (0, 2, 4)) + + width, height, data = read_pbm(sys.stdin.buffer) + c_source = emit(args.name, args.src, width, height, data, fg_rgb) + sys.stdout.write(c_source) + + +if __name__ == "__main__": + main()