First commit

This commit is contained in:
MassiveBox 2024-09-10 22:45:30 +02:00
commit 46926935fb
Signed by: massivebox
GPG key ID: 9B74D3A59181947D
14 changed files with 1089 additions and 0 deletions

21
LICENSE Normal file
View file

@ -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.

42
README.md Normal file
View file

@ -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

135
ble.go Normal file
View file

@ -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
}

164
cli/main.go Normal file
View file

@ -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)
}
}

212
commands.go Normal file
View file

@ -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
}

BIN
demo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

28
examples/00-knownMac.go Normal file
View file

@ -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)
}
}

44
examples/01-unknownMac.go Normal file
View file

@ -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)
}
}

46
examples/02-options.go Normal file
View file

@ -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)
}
}

30
go.mod Normal file
View file

@ -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
)

60
go.sum Normal file
View file

@ -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=

104
image.go Normal file
View file

@ -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
}

115
lib.go Normal file
View file

@ -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...)
}

88
options.go Normal file
View file

@ -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
}