// Bleh! // A Go reimplementation of the CatPrinterBLE utility // using Go's BLE stack and Image processing libraries package main import ( "context" "flag" "fmt" "image" "image/color" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "log" "os" "os/signal" "time" "github.com/disintegration/imaging" ble "github.com/go-ble/ble" "github.com/go-ble/ble/linux" dither "github.com/makeworld-the-better-one/dither" ) const minLines = 86 // firmware refuses to print anything shorter var ( mainServiceUUID = ble.MustParse("ae30") printCharacteristic = ble.MustParse("ae01") notifyCharacteristic = ble.MustParse("ae02") dataCharacteristic = ble.MustParse("ae03") targetPrinterName = "MXW01" scanTimeout = 10 * time.Second printCommandHeader = []byte{0x22, 0x21} printCommandFooter = byte(0xFF) intensity = flag.Int("intensity", 80, "Print intensity (0-100)") mode = flag.String("mode", "1bpp", "Print mode: 1bpp or 4bpp") ditherType = flag.String("dither", "none", "Dither method: none, floyd, bayer2x2, bayer4x4, bayer8x8, bayer16x16, atkinson, jjn") ) func dumpCharacteristics(client ble.Client, svc *ble.Service) { chars, err := client.DiscoverCharacteristics(nil, svc) if err != nil { log.Fatalf("Error discovering characteristics: %v", err) } log.Printf("Discovered %d characteristics:", len(chars)) for _, c := range chars { props := "" if c.Property&ble.CharRead != 0 { props += "Read " } if c.Property&ble.CharWrite != 0 { props += "Write " } if c.Property&ble.CharWriteNR != 0 { props += "WriteWithoutResponse " } if c.Property&ble.CharNotify != 0 { props += "Notify " } if c.Property&ble.CharIndicate != 0 { props += "Indicate " } log.Printf(" UUID: %s, Properties: %s", c.UUID, props) } } func parseNotification(data []byte) { if len(data) < 10 || data[0] != 0x22 || data[1] != 0x21 { fmt.Println("Invalid notification header") return } cmd := data[2] switch cmd { case 0xA1: // GetStatus battery := data[9] temp := data[10] statusOk := data[12] == 0 statusCode := data[6] errCode := data[13] statusMsg := "Unknown" if statusOk { switch statusCode { case 0x0: statusMsg = "Standby" case 0x1: statusMsg = "Printing" case 0x2: statusMsg = "Feeding paper" case 0x3: statusMsg = "Ejecting paper" } } else { switch errCode { case 0x1, 0x9: statusMsg = "No paper" case 0x4: statusMsg = "Overheated" case 0x8: statusMsg = "Low battery" } } fmt.Printf("Status: %v (%s), Battery: %d, Temp: %d\n", statusOk, statusMsg, battery, temp) default: fmt.Printf("Unhandled command: 0x%02X\n", cmd) } } const ( linePixels = 384 bytesPerLine = linePixels / 8 ) // decodeImage loads an image from a given path or stdin ("-") func decodeImage(path string) (image.Image, error) { if path == "-" { return decodeImageFromReader(os.Stdin) } img, err := imaging.Open(path, imaging.AutoOrientation(true)) if err != nil { return nil, fmt.Errorf("failed to open image %q: %v", path, err) } return img, nil } // decodeImageFromReader reads and decodes an image from any io.Reader func decodeImageFromReader(r io.Reader) (image.Image, error) { img, _, err := image.Decode(r) if err != nil { return nil, fmt.Errorf("decode error: %v", err) } return img, nil } // loadImageMonoFromImage processes an image.Image to 1bpp packed byte format func loadImageMonoFromImage(img image.Image, ditherType string) ([]byte, int, error) { ratio := float64(img.Bounds().Dx()) / float64(img.Bounds().Dy()) height := int(float64(linePixels) / ratio) img = imaging.Resize(img, linePixels, height, imaging.Lanczos) img = imaging.Grayscale(img) if ditherType != "none" { palette := []color.Color{color.Black, color.White} d := dither.NewDitherer(palette) switch ditherType { case "floyd": d.Matrix = dither.FloydSteinberg case "bayer2x2": d.Mapper = dither.Bayer(2, 2, 1.0) case "bayer4x4": d.Mapper = dither.Bayer(4, 4, 1.0) case "bayer8x8": d.Mapper = dither.Bayer(8, 8, 1.0) case "bayer16x16": d.Mapper = dither.Bayer(16, 16, 1.0) case "atkinson": d.Matrix = dither.Atkinson case "jjn": d.Matrix = dither.JarvisJudiceNinke default: return nil, 0, fmt.Errorf("unknown dither type: %s", ditherType) } img = d.DitherCopy(img) } else { img = imaging.AdjustContrast(img, 10) } pixels := make([]byte, (linePixels*height)/8) for y := 0; y < height; y++ { for x := 0; x < linePixels; x++ { gray := color.GrayModel.Convert(img.At(x, y)).(color.Gray) if gray.Y < 128 { idx := (y*linePixels + x) / 8 pixels[idx] |= 1 << (x % 8) } } } return pixels, height, nil } // loadImage4BitFromImage processes an image.Image to 4bpp packed byte format func loadImage4BitFromImage(img image.Image) ([]byte, int, error) { ratio := float64(img.Bounds().Dx()) / float64(img.Bounds().Dy()) height := int(float64(linePixels) / ratio) img = imaging.Resize(img, linePixels, height, imaging.Lanczos) img = imaging.Grayscale(img) pixels := make([]byte, (linePixels*height)/2) for y := 0; y < height; y++ { for x := 0; x < linePixels; x++ { gray := color.GrayModel.Convert(img.At(x, y)).(color.Gray) level := (255 - gray.Y) >> 4 idx := (y*linePixels + x) >> 1 shift := uint(((x & 1) ^ 1) << 2) // 4 if even, 0 if odd pixels[idx] |= level << shift } } return pixels, height, nil } // Extend sendImageToPrinter to handle 4-bit mode type PrintMode byte const ( Mode1bpp PrintMode = 0x00 Mode4bpp PrintMode = 0x02 ) func sendImageBufferToPrinter(client ble.Client, dataChr, printChr *ble.Characteristic, pixels []byte, height int, mode PrintMode, intensity byte) error { fmt.Printf("Sending image: %dx%d lines\n", linePixels, height) cmd := buildCommand(0xA2, []byte{intensity}) if err := client.WriteCharacteristic(printChr, cmd, true); err != nil { return fmt.Errorf("intensity set failed: %v", err) } param := []byte{ byte(height & 0xFF), byte(height >> 8), 0x30, byte(mode), } cmd = buildCommand(0xA9, param) if err := client.WriteCharacteristic(printChr, cmd, true); err != nil { return fmt.Errorf("print command failed: %v", err) } bytesPerLine := linePixels / 8 if mode == Mode4bpp { bytesPerLine = linePixels / 2 } mtu := 20 for y := 0; y < height; y++ { slice := pixels[y*bytesPerLine : (y+1)*bytesPerLine] for offset := 0; offset < len(slice); offset += mtu { end := offset + mtu if end > len(slice) { end = len(slice) } chunk := slice[offset:end] if err := client.WriteCharacteristic(dataChr, chunk, true); err != nil { return fmt.Errorf("line %d chunk write failed: %v", y, err) } time.Sleep(6 * time.Millisecond) } } cmd = buildCommand(0xAD, []byte{0x00}) if err := client.WriteCharacteristic(printChr, cmd, true); err != nil { return fmt.Errorf("flush failed: %v", err) } return nil } func padImageToMinLines(img image.Image, minLines int) image.Image { bounds := img.Bounds() if bounds.Dy() >= minLines { return img } // Create a new white image dst := imaging.New(bounds.Dx(), minLines, color.White) // Paste the original image at the top dst = imaging.Paste(dst, img, image.Pt(0, 0)) return dst } func main() { flag.Parse() if flag.NArg() < 1 { fmt.Println("Usage: catprinter-ble [flags] ") flag.PrintDefaults() return } imagePath := flag.Arg(0) i := *intensity if i < 0 { i = 0 } if i > 100 { i = 100 } intensityByte := byte(i) var printMode PrintMode switch *mode { case "1bpp": printMode = Mode1bpp case "4bpp": printMode = Mode4bpp default: fmt.Println("Invalid mode. Use '1bpp' or '4bpp'.") return } img, err := decodeImage(imagePath) if err != nil { log.Fatalf("Image load error: %v", err) } img = padImageToMinLines(img, minLines) var pixels []byte var height int switch printMode { case Mode1bpp: pixels, height, err = loadImageMonoFromImage(img, *ditherType) case Mode4bpp: pixels, height, err = loadImage4BitFromImage(img) } if err != nil { log.Fatalf("Image conversion error: %v", err) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() d, err := linux.NewDevice() if err != nil { log.Fatalf("Failed to open BLE device: %v", err) } ble.SetDefaultDevice(d) log.Println("Scanning for printer...") var adv ble.Advertisement ctxScan, cancel := context.WithTimeout(ctx, 10*time.Second) err = ble.Scan(ctxScan, false, func(a ble.Advertisement) { if a.LocalName() == targetPrinterName { adv = a cancel() } }, nil) if err != nil && err != context.Canceled { log.Fatalf("Scan error: %v", err) } if adv == nil { log.Println("Printer not found.") return } log.Println("Connecting...") client, err := ble.Dial(ctx, adv.Addr()) if err != nil { log.Fatalf("Connect failed: %v", err) } defer client.CancelConnection() mtu, err := client.ExchangeMTU(100) if err != nil { log.Printf("MTU negotiation failed: %v", err) } else { log.Printf("Negotiated ATT MTU: %d", mtu) } services, err := client.DiscoverServices([]ble.UUID{mainServiceUUID}) if err != nil || len(services) == 0 { log.Fatalf("Service discovery failed: %v", err) } svc := services[0] chars, err := client.DiscoverCharacteristics(nil, svc) if err != nil { log.Fatalf("Characteristic discovery failed: %v", err) } var printChr, dataChr *ble.Characteristic for _, c := range chars { switch c.UUID.String() { case printCharacteristic.String(): printChr = c case dataCharacteristic.String(): dataChr = c } } if printChr == nil || dataChr == nil { log.Fatalf("Missing required BLE characteristics") } err = sendImageBufferToPrinter(client, dataChr, printChr, pixels, height, printMode, intensityByte) if err != nil { log.Fatalf("Failed to print image: %v", err) } log.Println("Done!") } func buildCommand(cmdId byte, payload []byte) []byte { cmd := append([]byte{}, printCommandHeader...) cmd = append(cmd, cmdId) cmd = append(cmd, 0x00) // reserved cmd = append(cmd, byte(len(payload)&0xFF), byte(len(payload)>>8)) cmd = append(cmd, payload...) cmd = append(cmd, calculateCRC8(payload)) cmd = append(cmd, printCommandFooter) return cmd } func calculateCRC8(data []byte) byte { table := [256]byte{ 0x00, 0x07, 0x0e, 0x09, 0x1c, 0x1b, 0x12, 0x15, 0x38, 0x3f, 0x36, 0x31, 0x24, 0x23, 0x2a, 0x2d, 0x70, 0x77, 0x7e, 0x79, 0x6c, 0x6b, 0x62, 0x65, 0x48, 0x4f, 0x46, 0x41, 0x54, 0x53, 0x5a, 0x5d, 0xe0, 0xe7, 0xee, 0xe9, 0xfc, 0xfb, 0xf2, 0xf5, 0xd8, 0xdf, 0xd6, 0xd1, 0xc4, 0xc3, 0xca, 0xcd, 0x90, 0x97, 0x9e, 0x99, 0x8c, 0x8b, 0x82, 0x85, 0xa8, 0xaf, 0xa6, 0xa1, 0xb4, 0xb3, 0xba, 0xbd, 0xc7, 0xc0, 0xc9, 0xce, 0xdb, 0xdc, 0xd5, 0xd2, 0xff, 0xf8, 0xf1, 0xf6, 0xe3, 0xe4, 0xed, 0xea, 0xb7, 0xb0, 0xb9, 0xbe, 0xab, 0xac, 0xa5, 0xa2, 0x8f, 0x88, 0x81, 0x86, 0x93, 0x94, 0x9d, 0x9a, 0x27, 0x20, 0x29, 0x2e, 0x3b, 0x3c, 0x35, 0x32, 0x1f, 0x18, 0x11, 0x16, 0x03, 0x04, 0x0d, 0x0a, 0x57, 0x50, 0x59, 0x5e, 0x4b, 0x4c, 0x45, 0x42, 0x6f, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7d, 0x7a, 0x89, 0x8e, 0x87, 0x80, 0x95, 0x92, 0x9b, 0x9c, 0xb1, 0xb6, 0xbf, 0xb8, 0xad, 0xaa, 0xa3, 0xa4, 0xf9, 0xfe, 0xf7, 0xf0, 0xe5, 0xe2, 0xeb, 0xec, 0xc1, 0xc6, 0xcf, 0xc8, 0xdd, 0xda, 0xd3, 0xd4, 0x69, 0x6e, 0x67, 0x60, 0x75, 0x72, 0x7b, 0x7c, 0x51, 0x56, 0x5f, 0x58, 0x4d, 0x4a, 0x43, 0x44, 0x19, 0x1e, 0x17, 0x10, 0x05, 0x02, 0x0b, 0x0c, 0x21, 0x26, 0x2f, 0x28, 0x3d, 0x3a, 0x33, 0x34, 0x4e, 0x49, 0x40, 0x47, 0x52, 0x55, 0x5c, 0x5b, 0x76, 0x71, 0x78, 0x7f, 0x6a, 0x6d, 0x64, 0x63, 0x3e, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2c, 0x2b, 0x06, 0x01, 0x08, 0x0f, 0x1a, 0x1d, 0x14, 0x13, 0xae, 0xa9, 0xa0, 0xa7, 0xb2, 0xb5, 0xbc, 0xbb, 0x96, 0x91, 0x98, 0x9f, 0x8a, 0x8d, 0x84, 0x83, 0xde, 0xd9, 0xd0, 0xd7, 0xc2, 0xc5, 0xcc, 0xcb, 0xe6, 0xe1, 0xe8, 0xef, 0xfa, 0xfd, 0xf4, 0xf3} crc := byte(0) for _, b := range data { crc = table[crc^b] } return crc }