commit 46926935fbe25eea03e4bc38599d08a9138dc236 Author: MassiveBox Date: Tue Sep 10 22:45:30 2024 +0200 First commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7eaad74 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Matteo Bendotti + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..45175cd --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +![A printer which has just printed a Gopher](./demo.jpg) + +go-catprinter is a driver and CLI application that allows you to use some BLE printers, known as "cat printers", on Linux and MacOS, without needing to use the official app. + +# Documentation + +## CLI + +Grab a binary from the [Releases](https://git.massivebox.net/massivebox/go-catprinter/releases) page. Use `./catprinter --help` for help. + +- Basic example with provided MAC: `./catprinter --mac 41:c2:6f:0f:90:c7 --image ./gopher.png` +- Basic example with auto discovery by name: `./catprinter --name X6h --image ./gopher.png` + +## Driver + +For extensive documentation, please consult the [Go Reference](https://pkg.go.dev/git.massivebox.net/massivebox/go-catprinter). Check the `examples/` directory for examples: + +- `examples/00-knownMac.go`: Shows how to connect to a printer by its MAC address and print a file +- `examples/01-unknownMac.go`: Shows how to connect to a printer by its name address and print a file +- `examples/02-options.go`: Shows how `PrinterOptions` can be used to create a rich printing experience with previews and user interaction + +# Information + +## Printer compatibility + +This software should be compatible with printers whose official apps are [iPrint](https://play.google.com/store/apps/details?id=com.frogtosea.iprint&hl=en_US&gl=US), [Tiny Print](https://play.google.com/store/apps/details?id=com.frogtosea.tinyPrint&hl=en_US&gl=US) and similar. +Probably more printers work, but it's hard to tell with how fragmented the ecosystem is. Some printers with these apps might not work. The project takes no responsibility as per the LICENSE. + +The project's main developer uses a X6h (the one in the top of the README). It can be found in AliExpress bundles for around ~€8. + +## Thanks to... + +- [rbaron/catprinter](https://github.com/rbaron/catprinter) and [NaitLee/Cat-Printer](https://github.com/NaitLee/Cat-Printer), for providing most of the printer commands and inspiration for the project +- Shenzhen Frog To Sea Technology Co.,LTD +- Everyone who contributed, tested or used this software! + +## Alternatives + +- [NaitLee/Cat-Printer](https://github.com/NaitLee/Cat-Printer) - the cat printer central, with a CLI application, a web UI, CUPS/IPP support and an Android app. The code is a bit more cluttered, but it works well. +- [rbaron/catprinter](https://github.com/rbaron/catprinter) - simple CLI application for cat printers with batteries included, written in Python with code that's easy to understand. +- [NaitLee/kitty-printer](NaitLee/kitty-printer) - a web app for cat printers which leverages Web Bluetooth +- [jhbruhn/catprint-rs](jhbruhn/catprint-rs) - a driver for cat printers with a basic CLI utility, written in Rust \ No newline at end of file diff --git a/ble.go b/ble.go new file mode 100644 index 0000000..657dc50 --- /dev/null +++ b/ble.go @@ -0,0 +1,135 @@ +package catprinter + +import ( + "context" + "github.com/go-ble/ble" + "github.com/pkg/errors" + "strconv" + "strings" + "time" +) + +// For some reason, bleak reports the 0xaf30 service on my macOS, while it reports +// 0xae30 (which I believe is correct) on my Raspberry Pi. This hacky workaround +// should cover both cases. + +var possibleServiceUuids = []string{ + "ae30", + "af30", +} + +const txCharacteristicUuid = "ae01" + +const scanTimeout = 10 * time.Second + +const waitAfterEachChunkS = 20 * time.Millisecond + +const waitAfterDataSentS = 30 * time.Second + +func chunkify(data []byte, chunkSize int) [][]byte { + var chunks [][]byte + for i := 0; i < len(data); i += chunkSize { + end := i + chunkSize + if end > len(data) { + end = len(data) + } + chunks = append(chunks, data[i:end]) + } + return chunks +} + +func (c *Client) writeData(data []byte) error { + + chunks := chunkify(data, c.chunkSize) + c.log("Sending %d chunks of size %d...", len(chunks), c.chunkSize) + for i, chunk := range chunks { + err := c.printer.WriteCharacteristic(c.characteristic, chunk, true) + if err != nil { + return errors.Wrap(err, "writing to characteristic, chunk "+strconv.Itoa(i)) + } + time.Sleep(waitAfterEachChunkS) + } + c.log("All sent.") + + return nil +} + +// ScanDevices scans for a device with the given name, or auto discovers it based on characteristics (not implemented yet). +// Returns a map with MACs as key, and device names as values. +func (c *Client) ScanDevices(name string) (map[string]string, error) { + + var devices = make(map[string]string) + var found []string + + c.log("Looking for a BLE device named %s", name) + + ctx := ble.WithSigHandler(context.WithTimeout(context.Background(), c.Timeout)) + + err := ble.Scan(ctx, true, func(a ble.Advertisement) { + if strings.Contains(strings.Join(found, " "), a.Addr().String()) { + return + } + found = append(found, a.Addr().String()) + if strings.Contains(strings.ToLower(a.LocalName()), strings.ToLower(name)) { + devices[a.Addr().String()] = a.LocalName() + c.log("Matches %s %s", a.Addr().String(), a.LocalName()) + return + } + c.log("No match %s %s", a.Addr().String(), a.LocalName()) + }, nil) + + switch errors.Cause(err) { + case nil: + case context.DeadlineExceeded: + c.log("Timeout: scan completed") + case context.Canceled: + c.log("Scan was canceled") + default: + return nil, errors.Wrap(err, "failed scan") + } + + if len(devices) < 0 { + return nil, ErrPrinterNotFound + } + return devices, nil + +} + +// Connect establishes a BLE connection to a printer by MAC address. +func (c *Client) Connect(mac string) error { + + ctx := ble.WithSigHandler(context.WithTimeout(context.Background(), c.Timeout)) + connect, err := ble.Dial(ctx, ble.NewAddr(mac)) + if err != nil { + return err + } + + profile, err := connect.DiscoverProfile(true) + if err != nil { + return errors.Wrap(err, "discovering profile") + } + + var char *ble.Characteristic + for _, service := range profile.Services { + c.log("service %s", service.UUID.String()) + for _, characteristic := range service.Characteristics { + c.log(" %s", characteristic.UUID.String()) + if characteristic.UUID.Equal(ble.MustParse(txCharacteristicUuid)) && + strings.Contains(strings.Join(possibleServiceUuids, " "), service.UUID.String()) { + c.log(" found characteristic!") + char = characteristic + break + } + } + } + + if char == nil { + return ErrMissingCharacteristic + } + + c.characteristic = char + c.printer = connect + c.chunkSize = c.printer.Conn().RxMTU() - 3 + return nil + +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..46db6da --- /dev/null +++ b/cli/main.go @@ -0,0 +1,164 @@ +package main + +import ( + "fmt" + "git.massivebox.net/massivebox/go-catprinter" + "github.com/pkg/errors" + "log" + "os" + "time" + + "github.com/urfave/cli/v2" +) + +var flags = []cli.Flag{ + &cli.StringFlag{ + Name: "mac", + Usage: "MAC address of the printer. Provide this or name.", + }, + &cli.StringFlag{ + Name: "name", + Usage: "common name of the printer. Provide this or MAC.", + }, + &cli.StringFlag{ + Name: "image", + Usage: "path to the image file to be printed", + Required: true, + TakesFile: true, + }, + &cli.BoolFlag{ + Name: "lowerQuality", + Usage: "print with lower quality, but slightly faster speed", + }, + &cli.BoolFlag{ + Name: "autoRotate", + Usage: "rotate image to fit printer", + }, + &cli.BoolFlag{ + Name: "dontDither", + Usage: "don't dither the image", + }, + &cli.Float64Flag{ + Name: "blackPoint", + Value: 0.5, + Usage: "regulate at which point a gray pixel is printed as black", + }, + &cli.BoolFlag{ + Name: "debugLog", + Usage: "print debugging messages", + }, + &cli.BoolFlag{ + Name: "dumpImage", + Usage: "save dithered image to ./image.png", + }, + &cli.BoolFlag{ + Name: "dumpRequest", + Usage: "save raw data sent to printer to ./request.bin", + }, + &cli.BoolFlag{ + Name: "dontPrint", + Usage: "don't actually print the image", + }, +} + +func findMac(name string, c *catprinter.Client) (string, error) { + fmt.Printf("Finding MAC by name (will take %d seconds)...", c.Timeout/time.Second) + devices, err := c.ScanDevices(name) + if err != nil { + return "", err + } + switch len(devices) { + case 0: + return "", errors.New("no devices found with name " + name) + case 1: + for k, _ := range devices { + return k, nil + } + break + default: + fmt.Println("Found multiple devices:") + for m, n := range devices { + fmt.Printf("%s\t%s", m, n) + } + return "", errors.New("multiple devices found with name " + name + ", please specify MAC directly") + } + return "", nil +} + +func action(cCtx *cli.Context) error { + + var ( + mac = cCtx.String("mac") + name = cCtx.String("name") + imagePath = cCtx.String("image") + lowerQuality = cCtx.Bool("lowerQuality") + autoRotate = cCtx.Bool("autoRotate") + dontDither = cCtx.Bool("dontDither") + blackPoint = cCtx.Float64("blackPoint") + debugLog = cCtx.Bool("debugLog") + dumpImage = cCtx.Bool("dumpImage") + dumpRequest = cCtx.Bool("dumpRequest") + dontPrint = cCtx.Bool("dontPrint") + ) + + fmt.Println("Initializing...") + c, err := catprinter.NewClient() + if err != nil { + return err + } + defer c.Stop() + + c.Debug.Log = debugLog + c.Debug.DumpImage = dumpImage + c.Debug.DumpRequest = dumpRequest + c.Debug.DontPrint = dontPrint + + opts := catprinter.NewOptions(). + SetBestQuality(!lowerQuality). + SetDither(!dontDither). + SetAutoRotate(autoRotate). + SetBlackPoint(float32(blackPoint)) + + if (mac != "") == (name != "") { + return errors.New("either mac or name must be provided") + } + + if name != "" { + mac, err = findMac(name, c) + if err != nil { + return err + } + } + + fmt.Println("Connecting...") + err = c.Connect(mac) + if err != nil { + return err + } + fmt.Println("Connected!") + + fmt.Println("Printing...") + err = c.PrintFile(imagePath, opts) + if err != nil { + return err + } + + fmt.Println("All done, exiting now.") + + return nil +} + +func main() { + + app := &cli.App{ + Name: "catprinter", + Usage: "print images to some BLE thermal printers", + Flags: flags, + Action: action, + } + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } + +} diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..4ec0432 --- /dev/null +++ b/commands.go @@ -0,0 +1,212 @@ +package catprinter + +import "fmt" + +var ( + cmdGetDevState = []byte{81, 120, 163, 0, 1, 0, 0, 0, 255} + cmdSetQuality200Dpi = []byte{81, 120, 164, 0, 1, 0, 50, 158, 255} + cmdGetDevInfo = []byte{81, 120, 168, 0, 1, 0, 0, 0, 255} + cmdLatticeStart = []byte{81, 120, 166, 0, 11, 0, 170, 85, 23, 56, 68, 95, 95, 95, 68, 56, 44, 161, 255} + cmdLatticeEnd = []byte{81, 120, 166, 0, 11, 0, 170, 85, 23, 0, 0, 0, 0, 0, 0, 0, 23, 17, 255} + cmdSetPaper = []byte{81, 120, 161, 0, 2, 0, 48, 0, 249, 255} + cmdPrintImg = []byte{81, 120, 190, 0, 1, 0, 0, 0, 255} + cmdPrintText = []byte{81, 120, 190, 0, 1, 0, 1, 7, 255} + + cmdStartPrinting = []byte{0x51, 0x78, 0xa3, 0x00, 0x01, 0x00, 0x00, 0x00, 0xff} + cmdApplyEnergy = []byte{81, 120, 190, 0, 1, 0, 1, 7, 255} + cmdUpdateDevice = []byte{81, 120, 169, 0, 1, 0, 0, 0, 255} + cmdSlow = []byte{81, 120, 189, 0, 1, 0, 36, 252, 255} + cmdPower = []byte{81, 120, 175, 0, 2, 0, 255, 223, 196, 255} + + cmdFinalSpeed = []byte{81, 120, 189, 0, 1, 0, 8, 56, 255} + + checksumTable = []byte{ + 0, 7, 14, 9, 28, 27, 18, 21, 56, 63, 54, 49, 36, 35, 42, 45, 112, 119, 126, 121, 108, 107, 98, + 101, 72, 79, 70, 65, 84, 83, 90, 93, 224, 231, 238, 233, 252, 251, 242, 245, 216, 223, 214, 209, 196, 195, 202, + 205, 144, 151, 158, 153, 140, 139, 130, 133, 168, 175, 166, 161, 180, 179, 186, 189, 199, 192, 201, 206, 219, 220, + 213, 210, 255, 248, 241, 246, 227, 228, 237, 234, 183, 176, 185, 190, 171, 172, 165, 162, 143, 136, 129, 134, 147, + 148, 157, 154, 39, 32, 41, 46, 59, 60, 53, 50, 31, 24, 17, 22, 3, 4, 13, 10, 87, 80, 89, 94, 75, 76, 69, 66, 111, + 104, 97, 102, 115, 116, 125, 122, 137, 142, 135, 128, 149, 146, 155, 156, 177, 182, 191, 184, 173, 170, 163, 164, + 249, 254, 247, 240, 229, 226, 235, 236, 193, 198, 207, 200, 221, 218, 211, 212, 105, 110, 103, 96, 117, 114, 123, + 124, 81, 86, 95, 88, 77, 74, 67, 68, 25, 30, 23, 16, 5, 2, 11, 12, 33, 38, 47, 40, 61, 58, 51, 52, 78, 73, 64, 71, + 82, 85, 92, 91, 118, 113, 120, 127, 106, 109, 100, 99, 62, 57, 48, 55, 34, 37, 44, 43, 6, 1, 8, 15, 26, 29, 20, 19, + 174, 169, 160, 167, 178, 181, 188, 187, 150, 145, 152, 159, 138, 141, 132, 131, 222, 217, 208, 215, 194, 197, 204, + 203, 230, 225, 232, 239, 250, 253, 244, 243, + } +) + +// checksum calculates the checksum for a given byte array. +func checksum(bArr []byte, i, i2 int) byte { + var b2 byte + for i3 := i; i3 < i+i2; i3++ { + b2 = checksumTable[(b2^bArr[i3])&0xff] + } + return b2 +} + +// commandFeedPaper creates a command to feed paper by a specified amount. +func commandFeedPaper(howMuch int) []byte { + + bArr := []byte{ + 81, + 120, + 189, + 0, + 1, + 0, + byte(howMuch & 0xff), + 0, + 255, + } + + bArr[7] = checksum(bArr, 6, 1) + return bArr +} + +// cmdSetEnergy sets the energy level. Max to `0xffff` By default, it seems around `0x3000` (1 / 5) +func commandSetEnergy(val int) []byte { + + bArr := []byte{ + 81, + 120, + 175, + 0, + 2, + 0, + byte((val >> 8) & 0xff), + byte(val & 0xff), + 0, + 255, + } + + bArr[7] = checksum(bArr, 6, 2) + fmt.Println(bArr) + return bArr + +} + +// encodeRunLengthRepetition encodes repetitions in a run-length format. +func encodeRunLengthRepetition(n int, val byte) []byte { + var res []byte + for n > 0x7f { + res = append(res, 0x7f|byte(val<<7)) + n -= 0x7f + } + if n > 0 { + res = append(res, val<<7|byte(n)) + } + return res +} + +// runLengthEncode performs run-length encoding on an image row. +func runLengthEncode(imgRow []byte) []byte { + var res []byte + count := 0 + var lastVal byte = 255 + for _, val := range imgRow { + if val == lastVal { + count++ + } else { + res = append(res, encodeRunLengthRepetition(count, lastVal)...) + count = 1 + } + lastVal = val + } + if count > 0 { + res = append(res, encodeRunLengthRepetition(count, lastVal)...) + } + return res +} + +// byteEncode encodes an image row into a byte array. +func byteEncode(imgRow []byte) []byte { + var res []byte + for chunkStart := 0; chunkStart < len(imgRow); chunkStart += 8 { + var byteVal byte = 0 + for bitIndex := 0; bitIndex < 8; bitIndex++ { + if chunkStart+bitIndex < len(imgRow) && imgRow[chunkStart+bitIndex] != 0 { + byteVal |= 1 << uint8(bitIndex) + } + } + res = append(res, byteVal) + } + return res +} + +// commandPrintRow builds a print row command based on the image data. +func commandPrintRow(imgRow []byte) []byte { + + // Try to use run-length compression on the image data. + encodedImg := runLengthEncode(imgRow) + + // If the resulting compression takes more than PRINT_WIDTH // 8, it means + // it's not worth it. So we fall back to a simpler, fixed-length encoding. + if len(encodedImg) > printWidth/8 { + encodedImg = byteEncode(imgRow) + bArr := append([]byte{ + 81, + 120, + 162, + 0, + byte(len(encodedImg) & 0xff), + 0, + }, encodedImg...) + bArr = append(bArr, 0, 0xff) + bArr[len(bArr)-2] = checksum(bArr, 6, len(encodedImg)) + return bArr + } + + // Build the run-length encoded image command. + bArr := append([]byte{81, 120, 191, 0, byte(len(encodedImg)), 0}, encodedImg...) + bArr = append(bArr, 0, 0xff) + bArr[len(bArr)-2] = checksum(bArr, 6, len(encodedImg)) + return bArr +} + +// commandsPrintImg builds the commands to print an image. +func commandsPrintImg(imgS []byte) []byte { + + img := chunkify(imgS, printWidth) + var data []byte + + data = append(data, cmdGetDevState...) + data = append(data, cmdStartPrinting...) + data = append(data, cmdSetQuality200Dpi...) + data = append(data, cmdSlow...) + data = append(data, cmdPower...) + data = append(data, cmdApplyEnergy...) + data = append(data, cmdUpdateDevice...) + + data = append(data, cmdLatticeStart...) + for _, row := range img { + data = append(data, commandPrintRow(row)...) + } + data = append(data, cmdLatticeEnd...) + data = append(data, cmdFinalSpeed...) + data = append(data, commandFeedPaper(5)...) + data = append(data, cmdSetPaper...) + data = append(data, cmdSetPaper...) + data = append(data, cmdSetPaper...) + data = append(data, cmdGetDevState...) + return data + +} + +func weakCommandsPrintImg(imgS []byte) []byte { + + img := chunkify(imgS, printWidth) + + data := append(cmdGetDevState, cmdSetQuality200Dpi...) + data = append(data, cmdLatticeStart...) + for _, row := range img { + data = append(data, commandPrintRow(row)...) + } + data = append(data, commandFeedPaper(5)...) + data = append(data, cmdSetPaper...) + data = append(data, cmdSetPaper...) + data = append(data, cmdSetPaper...) + data = append(data, cmdLatticeEnd...) + data = append(data, cmdGetDevState...) + return data + +} diff --git a/demo.jpg b/demo.jpg new file mode 100644 index 0000000..76ee2cf Binary files /dev/null and b/demo.jpg differ diff --git a/examples/00-knownMac.go b/examples/00-knownMac.go new file mode 100644 index 0000000..9c1bfe2 --- /dev/null +++ b/examples/00-knownMac.go @@ -0,0 +1,28 @@ +package main + +import "git.massivebox.net/massivebox/go-catprinter" + +func main() { + + const mac = "41:c2:6f:0f:90:c7" + + c, err := catprinter.NewClient() + if err != nil { + panic(err) + } + + c.Debug.Log = true + + opts := catprinter.NewOptions() + defer c.Stop() + + if err = c.Connect(mac); err != nil { + panic(err) + } + + err = c.PrintFile("../demo.jpg", opts) + if err != nil { + panic(err) + } + +} diff --git a/examples/01-unknownMac.go b/examples/01-unknownMac.go new file mode 100644 index 0000000..a2970e9 --- /dev/null +++ b/examples/01-unknownMac.go @@ -0,0 +1,44 @@ +package main + +import ( + "git.massivebox.net/massivebox/go-catprinter" + "log" +) + +func main() { + + const name = "x6h" + + c, err := catprinter.NewClient() + if err != nil { + panic(err) + } + + c.Debug.Log = true + + opts := catprinter.NewOptions() + defer c.Stop() + + // let's find the MAC from the device name + var mac string + devices, err := c.ScanDevices(name) + if err != nil { + panic(err) + } + for deviceMac, deviceName := range devices { + // you should ask the user to choose the device here, we will pretend they selected the first + log.Println("Connecting to", deviceName, "with MAC", deviceMac) + mac = deviceMac + break + } + + if err = c.Connect(mac); err != nil { + panic(err) + } + + err = c.PrintFile("../demo.jpg", opts) + if err != nil { + panic(err) + } + +} diff --git a/examples/02-options.go b/examples/02-options.go new file mode 100644 index 0000000..89d5ec2 --- /dev/null +++ b/examples/02-options.go @@ -0,0 +1,46 @@ +package main + +import ( + "git.massivebox.net/massivebox/go-catprinter" + "image/jpeg" + "os" +) + +func main() { + + const mac = "41:c2:6f:0f:90:c7" + + c, err := catprinter.NewClient() + if err != nil { + panic(err) + } + + c.Debug.Log = true + defer c.Stop() + + if err = c.Connect(mac); err != nil { + panic(err) + } + + // note that for this we need to open the image as image.Image manually! + file, _ := os.Open("../demo.jpg") + defer file.Close() + img, _ := jpeg.Decode(file) + + // for now, we will use default options + opts := catprinter.NewOptions() + fmtImg := c.FormatImage(img, opts) + + // now you should display your image to the user and ask for what they want to change + // in this example, we will pretend they want to disable dithering + opts = opts.SetDither(false) + fmtImg = c.FormatImage(img, opts) + + // you can show the image again and make all the adjustments you see fit with opts.SetXYZ + // when the user decides to print, we can use + err = c.Print(fmtImg, opts, true) // NOTE THE TRUE HERE! It means the image is already formatted + if err != nil { + panic(err) + } + +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d459698 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module git.massivebox.net/massivebox/go-catprinter + +go 1.22 + +require ( + github.com/disintegration/imaging v1.6.2 + github.com/go-ble/ble v0.0.0-20240122180141-8c5522f54333 + github.com/makeworld-the-better-one/dither/v2 v2.4.0 + github.com/urfave/cli/v2 v2.27.4 +) + +require ( + github.com/JuulLabs-OSS/cbgo v0.0.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect + github.com/raff/goble v0.0.0-20190909174656-72afc67d6a99 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sirupsen/logrus v1.5.0 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect +) + +require ( + github.com/mattn/go-colorable v0.1.6 // indirect + github.com/mattn/go-isatty v0.0.12 // indirect + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + github.com/mgutz/logxi v0.0.0-20161027140823-aebf8a7d67ab // indirect + github.com/pkg/errors v0.8.1 + golang.org/x/sys v0.0.0-20211204120058-94396e421777 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b927acb --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/JuulLabs-OSS/cbgo v0.0.1 h1:A5JdglvFot1J9qYR0POZ4qInttpsVPN9lqatjaPp2ro= +github.com/JuulLabs-OSS/cbgo v0.0.1/go.mod h1:L4YtGP+gnyD84w7+jN66ncspFRfOYB5aj9QSXaFHmBA= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/go-ble/ble v0.0.0-20240122180141-8c5522f54333 h1:bQK6D51cNzMSTyAf0HtM30V2IbljHTDam7jru9JNlJA= +github.com/go-ble/ble v0.0.0-20240122180141-8c5522f54333/go.mod h1:fFJl/jD/uyILGBeD5iQ8tYHrPlJafyqCJzAyTHNJ1Uk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE= +github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= +github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/logxi v0.0.0-20161027140823-aebf8a7d67ab h1:n8cgpHzJ5+EDyDri2s/GC7a9+qK3/YEGnBsd0uS/8PY= +github.com/mgutz/logxi v0.0.0-20161027140823-aebf8a7d67ab/go.mod h1:y1pL58r5z2VvAjeG1VLGc8zOQgSOzbKN7kMHPvFXJ+8= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/raff/goble v0.0.0-20190909174656-72afc67d6a99 h1:JtoVdxWJ3tgyqtnPq3r4hJ9aULcIDDnPXBWxZsdmqWU= +github.com/raff/goble v0.0.0-20190909174656-72afc67d6a99/go.mod h1:CxaUhijgLFX0AROtH5mluSY71VqpjQBw9JXE2UKZmc4= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q= +github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= +github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20211204120058-94396e421777 h1:QAkhGVjOxMa+n4mlsAWeAU+BMZmimQAaNiMu+iUi94E= +golang.org/x/sys v0.0.0-20211204120058-94396e421777/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/image.go b/image.go new file mode 100644 index 0000000..d9ce6c1 --- /dev/null +++ b/image.go @@ -0,0 +1,104 @@ +package catprinter + +import ( + "github.com/disintegration/imaging" + "github.com/makeworld-the-better-one/dither/v2" + "image" + "image/color" + "image/draw" + "log" +) + +const printWidth = 384 + +func convertImageToBytes(img image.Image) ([]byte, error) { + + if img.Bounds().Dx() != printWidth { + return nil, ErrInvalidImageSize + } + + var byteArray []byte + for y := 0; y < img.Bounds().Dy(); y++ { + for x := 0; x < img.Bounds().Dx(); x++ { + r, g, b, _ := img.At(x, y).RGBA() + if r != g || g != b || r != b || (r != 0 && r != 65535) { + return nil, ErrNotBlackWhite + } + if r == 0 { + byteArray = append(byteArray, 1) // black + } else { + byteArray = append(byteArray, 0) // white + } + } + } + + return byteArray, nil + +} + +func grayscaleToBlackWhite(img image.Image, blackPoint float32) *image.NRGBA { + + bounds := img.Bounds() + nrgbaImg := image.NewNRGBA(bounds) + draw.Draw(nrgbaImg, bounds, img, bounds.Min, draw.Src) + + for y := 0; y < img.Bounds().Dy(); y++ { + for x := 0; x < img.Bounds().Dx(); x++ { + r, g, b, _ := img.At(x, y).RGBA() + if r != g || g != b || r != b { + log.Panicln("logic error, image should have been grayscale") + } + if float32(r)/65535 < blackPoint { + nrgbaImg.Set(x, y, color.Black) + } else { + nrgbaImg.Set(x, y, color.White) + } + } + } + + return nrgbaImg + +} + +func ditherImage(img image.Image, algo dither.ErrorDiffusionMatrix) image.Image { + + palette := []color.Color{ + color.Black, + color.White, + } + + d := dither.NewDitherer(palette) + d.Matrix = algo + + return d.Dither(img) + +} + +// FormatImage formats the image for printing by resizing it and dithering or grayscaling it +func (c *Client) FormatImage(img image.Image, opts *PrinterOptions) image.Image { + + if img.Bounds().Dx() > img.Bounds().Dy() && opts.autoRotate { + img = imaging.Rotate90(img) + } + + var newImg image.Image = imaging.New(img.Bounds().Dx(), img.Bounds().Dy(), color.White) + newImg = imaging.OverlayCenter(newImg, img, 1) + newImg = imaging.Resize(newImg, printWidth, 0, imaging.NearestNeighbor) + + if opts.dither { + newImg = ditherImage(newImg, opts.ditherAlgo) + } else { + newImg = imaging.Grayscale(newImg) + newImg = grayscaleToBlackWhite(newImg, opts.blackPoint) + } + + if c.Debug.DumpImage { + err := imaging.Save(newImg, "./image.png") + if err != nil { + log.Println("failed to save debugging image dump", err.Error()) + } + } + + return newImg + +} diff --git a/lib.go b/lib.go new file mode 100644 index 0000000..2b60b86 --- /dev/null +++ b/lib.go @@ -0,0 +1,115 @@ +package catprinter + +import ( + "github.com/disintegration/imaging" + "github.com/go-ble/ble" + "github.com/go-ble/ble/examples/lib/dev" + "github.com/pkg/errors" + "image" + "log" + "os" + "time" +) + +var ( + ErrPrinterNotFound = errors.New("unable to find printer, make sure it is turned on and in range") + ErrMissingCharacteristic = errors.New("missing characteristic, make sure the MAC is correct and the printer is supported") + ErrNotBlackWhite = errors.New("image must be black and white (NOT only grayscale)") + ErrInvalidImageSize = errors.New("image must be 384px wide") +) + +// Client contains information for the connection to the printer and debugging options. +type Client struct { + device ble.Device + printer ble.Client + characteristic *ble.Characteristic + chunkSize int + Timeout time.Duration + Debug struct { + Log bool // print logs to terminal + DumpRequest bool // saves last data sent to printer to ./request.bin + DumpImage bool // saves formatted image to ./image.png + DontPrint bool // if true, the image is not actually printed. saves paper during testing. + } +} + +// NewClient initiates a new client with sane defaults +func NewClient() (*Client, error) { + d, err := dev.DefaultDevice() + if err != nil { + return nil, errors.Wrap(err, "can't create device, superuser permissions might be needed") + } + return NewClientFromDevice(d) +} + +// NewClientFromDevice initiates a new client with a custom ble.Device and sane defaults +func NewClientFromDevice(d ble.Device) (*Client, error) { + var c = &Client{} + ble.SetDefaultDevice(d) + c.device = d + c.Timeout = scanTimeout + return c, nil +} + +// Stop closes any active connection to a printer and detaches the GATT server +func (c *Client) Stop() error { + if err := c.Disconnect(); err != nil { + return errors.Wrap(err, "can't disconnect printer") + } + return c.device.Stop() +} + +// Disconnect closes any active connection to a printer +func (c *Client) Disconnect() error { + if c.printer != nil { + if err := c.printer.CancelConnection(); err != nil { + return err + } + c.printer = nil + } + return nil +} + +// Print prints an image to the connected printer. It also formats it and dithers if specified by opts and isAlreadyFormatted. +// Only set isAlreadyFormatted to true if the image is in black and white (NOT ONLY grayscale) and 384px wide. +func (c *Client) Print(img image.Image, opts *PrinterOptions, isAlreadyFormatted bool) error { + if !isAlreadyFormatted { + img = c.FormatImage(img, opts) + } + fmtImg, err := convertImageToBytes(img) + if err != nil { + return err + } + if opts.bestQuality { + fmtImg = commandsPrintImg(fmtImg) + } else { + fmtImg = weakCommandsPrintImg(fmtImg) + } + if c.Debug.DumpRequest { + err = os.WriteFile("./request.bin", fmtImg, 0644) + if err != nil { + log.Println("failed to save debugging request dump", err.Error()) + } + } + if c.Debug.DontPrint { + log.Println("image will not be printed as Client.Debug.DontPrint is true") + return nil + } + return c.writeData(fmtImg) +} + +// PrintFile dithers, formats and prints an image by path to the connected printer +func (c *Client) PrintFile(path string, opts *PrinterOptions) error { + img, err := imaging.Open(path) + if err != nil { + return err + } + return c.Print(img, opts, false) +} + +func (c *Client) log(format string, a ...any) { + if !c.Debug.Log { + return + } + log.Printf(format, a...) +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..5379e9a --- /dev/null +++ b/options.go @@ -0,0 +1,88 @@ +package catprinter + +import ( + "github.com/makeworld-the-better-one/dither/v2" + "log" +) + +type PrinterOptions struct { + bestQuality bool + autoRotate bool + dither bool + blackPoint float32 + ditherAlgo dither.ErrorDiffusionMatrix +} + +// NewOptions creates a new PrinterOptions object with sane defaults. +func NewOptions() *PrinterOptions { + return &PrinterOptions{ + bestQuality: true, + autoRotate: false, + dither: true, + ditherAlgo: dither.FloydSteinberg, + blackPoint: 0.5, + } +} + +// SetBestQuality sets the quality option. Default is true. +// If true, prints slower with higher thermal strength, resulting in a darker image. +// Recommended for self-adhesive paper. +func (o *PrinterOptions) SetBestQuality(best bool) *PrinterOptions { + o.bestQuality = best + return o +} + +// BestQuality returns the quality option. +func (o *PrinterOptions) BestQuality() bool { + return o.bestQuality +} + +// SetAutoRotate sets the auto rotate option. Default is false. +// If true and the image is landscape, it gets rotated to be printed in higher resolution. +func (o *PrinterOptions) SetAutoRotate(rotate bool) *PrinterOptions { + o.autoRotate = rotate + return o +} + +// AutoRotate returns the auto rotate option. +func (o *PrinterOptions) AutoRotate() bool { + return o.autoRotate +} + +// SetDither sets the dither option. Default is true. +// If false, dithering is disabled, and the image is converted to black/white and each pixel is printed if less white than BlackPoint. +func (o *PrinterOptions) SetDither(dither bool) *PrinterOptions { + o.dither = dither + return o +} + +// Dither returns the dither option. +func (o *PrinterOptions) Dither() bool { + return o.dither +} + +// SetDitherAlgo sets the dither algorithm. Default is FloydSteinberg. +func (o *PrinterOptions) SetDitherAlgo(algo dither.ErrorDiffusionMatrix) *PrinterOptions { + o.ditherAlgo = algo + return o +} + +// DitherAlgo returns the dither algorithm. +func (o *PrinterOptions) DitherAlgo() dither.ErrorDiffusionMatrix { + return o.ditherAlgo +} + +// SetBlackPoint sets the black point. Default is 0.5. +// If 0.5, a gray pixel will be printed as black if it's less than 50% white. Only effective if Dither is disabled. +func (o *PrinterOptions) SetBlackPoint(bp float32) *PrinterOptions { + if bp < 0 || bp > 1 { + log.Panic("Invalid black point value. Must be between 0 and 1.") + } + o.blackPoint = bp + return o +} + +// BlackPoint returns the black point. +func (o *PrinterOptions) BlackPoint() float32 { + return o.blackPoint +}