feat(04-03): PrinterDriver interface, MockDriver, PrtQutieDriver stub

- PrinterDriver interface: Connect/Print(bitmap,w,h)/Disconnect
- ImageToRawBitmap(): 1-bit packed row-major converter from image.Image
- MockDriver: saves PNG to SaveDir (/tmp default) for visual inspection
- PrtQutieDriver: stub returns ErrNoDevice — safe before hardware arrives
- ErrNoDevice, ErrNotConnected, ErrEmptyBitmap sentinel errors
This commit is contained in:
Mikkel Georgsen 2026-04-10 06:50:12 +00:00
parent b223b54a87
commit 1156eff896
3 changed files with 185 additions and 0 deletions

View file

@ -0,0 +1,49 @@
package printer
import (
"errors"
"image"
"math"
)
// PrinterDriver is the interface all label printer implementations must satisfy.
// The PRT Qutie driver, mock driver, and any future printer use this interface.
type PrinterDriver interface {
// Connect opens the connection to the printer. Must be called before Print.
Connect() error
// Print sends a rendered label bitmap to the printer.
// bitmap is raw 1-bit packed row-major data from ImageToRawBitmap.
Print(bitmap []byte, width, height int) error
// Disconnect closes the connection.
Disconnect() error
}
var (
// ErrNoDevice is returned when no printer matching the VID/PID is found.
ErrNoDevice = errors.New("printer: no device found matching VID/PID")
// ErrNotConnected is returned when Print is called before Connect.
ErrNotConnected = errors.New("printer: not connected")
// ErrEmptyBitmap is returned when an empty or nil bitmap is passed to Print.
ErrEmptyBitmap = errors.New("printer: bitmap is empty")
)
// ImageToRawBitmap converts an image.Image to 1-bit packed row-major bitmap.
// White pixels → 0, dark pixels → 1. Returns bytes, width, and height.
func ImageToRawBitmap(img image.Image) ([]byte, int, int) {
b := img.Bounds()
w, h := b.Max.X-b.Min.X, b.Max.Y-b.Min.Y
rowBytes := int(math.Ceil(float64(w) / 8.0))
out := make([]byte, rowBytes*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, bv, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
// Luminance: pixel is dark if average < 50% of max (0xffff).
lum := (r + g + bv) / 3
if lum < 0x7fff {
byteIdx := y*rowBytes + x/8
out[byteIdx] |= 1 << (7 - uint(x%8))
}
}
}
return out, w, h
}

View file

@ -0,0 +1,72 @@
package printer
import (
"fmt"
"image"
"image/color"
"image/png"
"os"
"time"
)
// MockDriver implements PrinterDriver for testing and development.
// Print() saves a PNG to SaveDir (default /tmp) and logs to stdout.
// No hardware required — use this as the default driver until PRT Qutie arrives.
type MockDriver struct {
connected bool
// SaveDir is the directory where label PNGs are written.
// Defaults to /tmp. Override in tests to use t.TempDir().
SaveDir string
}
// NewMockDriver creates a MockDriver that saves PNGs to /tmp.
func NewMockDriver() *MockDriver {
return &MockDriver{SaveDir: "/tmp"}
}
// Connect marks the driver as connected.
func (m *MockDriver) Connect() error {
m.connected = true
return nil
}
// Disconnect marks the driver as disconnected.
func (m *MockDriver) Disconnect() error {
m.connected = false
return nil
}
// Print reconstructs the label image from the 1-bit bitmap and saves it as a PNG.
// Returns ErrEmptyBitmap if bitmap is nil or empty.
func (m *MockDriver) Print(bitmap []byte, width, height int) error {
if len(bitmap) == 0 {
return ErrEmptyBitmap
}
// Reconstruct image from 1-bit packed bitmap for visual inspection.
img := image.NewGray(image.Rect(0, 0, width, height))
rowBytes := (width + 7) / 8
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
byteIdx := y*rowBytes + x/8
bit := (bitmap[byteIdx] >> (7 - uint(x%8))) & 1
if bit == 1 {
img.SetGray(x, y, color.Gray{Y: 0})
} else {
img.SetGray(x, y, color.Gray{Y: 255})
}
}
}
path := fmt.Sprintf("%s/hwlab-label-%d.png", m.SaveDir, time.Now().UnixMilli())
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("printer: mock create file: %w", err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
return fmt.Errorf("printer: mock encode PNG: %w", err)
}
fmt.Printf("[MockDriver] label saved → %s (%dx%d)\n", path, width, height)
return nil
}

View file

@ -0,0 +1,64 @@
package printer
import (
"go.bug.st/serial"
)
// PrtQutieDriver implements PrinterDriver for the PRT Qutie thermal printer.
//
// Protocol: UNKNOWN — reverse engineering required after hardware arrival 2026-04-13.
//
// TODO(hardware): Replace the stub Print() with real ESC/POS or PRT Qutie commands
// after capturing USB traffic with Wireshark on hardware arrival day.
// Reference: https://atctwo.net/posts/2024/07/16/thermal-printer.html
type PrtQutieDriver struct {
portPath string // resolved at Connect() time
port serial.Port
baudRate int
}
// NewPrtQutieDriver creates a stub driver. portPath is resolved at Connect() via
// USB Manager VID/PID enumeration. baudRate 0 defaults to 9600.
func NewPrtQutieDriver(baudRate int) *PrtQutieDriver {
if baudRate == 0 {
baudRate = 9600
}
return &PrtQutieDriver{baudRate: baudRate}
}
// Connect attempts to find and open the PRT Qutie serial port.
// Currently returns ErrNoDevice — real port resolution requires hardware.
//
// TODO(hardware): Resolve portPath via serial.GetPortsList() + system_profiler VID/PID
// cross-reference, then open the port with serial.Open().
func (d *PrtQutieDriver) Connect() error {
// Stub: return ErrNoDevice so callers can detect missing hardware gracefully.
// Real implementation: enumerate ports, match VID:PID 0525:a4a7, open port.
return ErrNoDevice
}
// Print sends label bitmap to the printer.
// Returns ErrNotConnected if Connect() has not been successfully called.
//
// TODO(hardware): Implement PRT Qutie print protocol — likely a binary
// command sequence (ESC/POS or proprietary). Capture with Wireshark.
func (d *PrtQutieDriver) Print(bitmap []byte, width, height int) error {
if d.port == nil {
return ErrNotConnected
}
// TODO(hardware): implement PRT Qutie print protocol
_ = bitmap
_ = width
_ = height
return nil
}
// Disconnect closes the serial port if open.
func (d *PrtQutieDriver) Disconnect() error {
if d.port != nil {
err := d.port.Close()
d.port = nil
return err
}
return nil
}