diff --git a/internal/printer/driver.go b/internal/printer/driver.go new file mode 100644 index 0000000..9dd5362 --- /dev/null +++ b/internal/printer/driver.go @@ -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 +} diff --git a/internal/printer/mock_driver.go b/internal/printer/mock_driver.go new file mode 100644 index 0000000..1dde63e --- /dev/null +++ b/internal/printer/mock_driver.go @@ -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 +} diff --git a/internal/printer/prt_qutie.go b/internal/printer/prt_qutie.go new file mode 100644 index 0000000..573c7b2 --- /dev/null +++ b/internal/printer/prt_qutie.go @@ -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 +}