New daemon architecture

This commit is contained in:
2026-02-06 13:47:06 -03:00
commit cbe18da598
18 changed files with 2435 additions and 0 deletions

346
cmd/bleh/main.go Normal file
View File

@@ -0,0 +1,346 @@
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"image"
"io"
"log"
"net"
"os"
"time"
"bleh/internal/imageproc"
"bleh/internal/ipc"
"bleh/internal/mxw01"
"github.com/disintegration/imaging"
)
const defaultSock = "/run/bleh/blehd.sock"
var (
intensity int
mode string
ditherType string
getStatus bool
getBattery bool
getVersion bool
getPrintType bool
getQueryCount bool
ejectPaper uint
retractPaper uint
outputPath string
address string
socketPath string
jsonOutput bool
version = "dev"
)
func init() {
flag.IntVar(&intensity, "intensity", 80, "Print intensity (0-100)")
flag.IntVar(&intensity, "i", 80, "Print intensity (0-100)")
flag.StringVar(&mode, "mode", "1bpp", "Print mode: 1bpp or 4bpp")
flag.StringVar(&mode, "m", "1bpp", "Print mode: 1bpp or 4bpp")
flag.StringVar(&ditherType, "dither", "none", "Dither method: none, floyd, bayer2x2, bayer4x4, bayer8x8, bayer16x16, atkinson, jjn")
flag.StringVar(&ditherType, "d", "none", "Dither method: none, floyd, bayer2x2, bayer4x4, bayer8x8, bayer16x16, atkinson, jjn")
flag.BoolVar(&getStatus, "status", false, "Query printer status")
flag.BoolVar(&getStatus, "s", false, "Query printer status")
flag.BoolVar(&getBattery, "battery", false, "Query battery level")
flag.BoolVar(&getBattery, "b", false, "Query battery level")
flag.BoolVar(&getVersion, "version", false, "Query printer version")
flag.BoolVar(&getVersion, "v", false, "Query printer version")
flag.BoolVar(&getPrintType, "printtype", false, "Query print type")
flag.BoolVar(&getPrintType, "p", false, "Query print type")
flag.BoolVar(&getQueryCount, "querycount", false, "Query internal counter")
flag.BoolVar(&getQueryCount, "q", false, "Query internal counter")
flag.UintVar(&ejectPaper, "eject", 0, "Eject paper by N lines")
flag.UintVar(&ejectPaper, "E", 0, "Eject paper by N lines")
flag.UintVar(&retractPaper, "retract", 0, "Retract paper by N lines")
flag.UintVar(&retractPaper, "R", 0, "Retract paper by N lines")
flag.StringVar(&outputPath, "o", "", "Output PNG preview instead of printing (specify output path)")
flag.StringVar(&outputPath, "output", "", "Output PNG preview instead of printing (specify output path)")
flag.StringVar(&address, "a", "", "Connect to printer by MAC address")
flag.StringVar(&address, "address", "", "Connect to printer by MAC address")
flag.StringVar(&socketPath, "socket", "", "blehd Unix socket path (default: auto-detect)")
flag.BoolVar(&jsonOutput, "json", false, "Output machine-readable JSON")
}
func main() {
flag.Parse()
if outputPath != "-" {
log.Println("Bleh! Cat Printer Utility for MXW01, version", version)
}
needNotifications := getStatus || getBattery || getVersion || getPrintType || getQueryCount || ejectPaper > 0 || retractPaper > 0
needPrinter := needNotifications || (flag.NArg() > 0 && outputPath == "")
if !needPrinter && outputPath == "" {
log.Println("Nothing to do. Use -h for help.")
log.Println("Done!")
return
}
// Print mode
var printMode mxw01.PrintMode
switch mode {
case "1bpp":
printMode = mxw01.Mode1bpp
case "4bpp":
printMode = mxw01.Mode4bpp
default:
log.Fatalf("Invalid mode. Use '1bpp' or '4bpp'.")
}
imagePath := flag.Arg(0)
var pixels []byte
var height int
var err error
if imagePath != "" {
img, err := imageproc.DecodeImage(imagePath)
if err != nil {
log.Fatalf("Failed to load image: %v", err)
}
img = imageproc.PadImageToMinLines(img, mxw01.MinLines)
switch printMode {
case mxw01.Mode1bpp:
pixels, height, err = imageproc.LoadImageMonoFromImage(img, ditherType)
case mxw01.Mode4bpp:
pixels, height, err = imageproc.LoadImage4BitFromImage(img, ditherType)
}
if err != nil {
log.Fatalf("Image conversion error: %v", err)
}
}
if outputPath != "" {
writePreview(printMode, pixels, height)
return
}
c, usedSock, err := dialAuto(socketPath)
if err != nil {
log.Fatalf("Could not connect to blehd: %v\nStart it with: ./blehd --socket %s", err, defaultSock)
}
_ = usedSock
defer c.Close()
id := 1
call := func(method string, params map[string]any) *ipc.Response {
req := ipc.Request{ID: fmt.Sprintf("%d", id), Method: method, Params: params}
id++
if err := ipc.EncodeLine(c, req); err != nil {
log.Fatalf("send failed: %v", err)
}
resp, err := readResp(c)
if err != nil {
log.Fatalf("read failed: %v", err)
}
if !resp.OK {
log.Fatalf("%s failed: %s", method, resp.Error.Message)
}
return resp
}
params := map[string]any{}
if address != "" {
params["address"] = address
}
if needNotifications {
if getStatus {
resp := call("printer.status.get", params)
printResult("printer.status.get", resp.Result)
}
if getBattery {
resp := call("printer.battery.get", params)
printResult("printer.battery.get", resp.Result)
}
if getVersion {
resp := call("printer.version.get", params)
printResult("printer.version.get", resp.Result)
}
if getPrintType {
resp := call("printer.printtype.get", params)
printResult("printer.printtype.get", resp.Result)
}
if getQueryCount {
resp := call("printer.querycount.get", params)
printResult("printer.querycount.get", resp.Result)
}
if ejectPaper > 0 {
p := cloneMap(params)
p["lines"] = int(ejectPaper)
resp := call("paper.eject", p)
printResult("paper.eject", resp.Result)
}
if retractPaper > 0 {
p := cloneMap(params)
p["lines"] = int(retractPaper)
resp := call("paper.retract", p)
printResult("paper.retract", resp.Result)
}
}
if flag.NArg() > 0 {
i := intensity
if i < 0 {
i = 0
}
if i > 100 {
i = 100
}
p := cloneMap(params)
p["mode"] = mode
p["intensity"] = i
p["height"] = height
p["pixels_b64"] = base64.StdEncoding.EncodeToString(pixels)
resp := call("print.start", p)
printResult("print.start", resp.Result)
}
log.Println("Done!")
}
func writePreview(printMode mxw01.PrintMode, pixels []byte, height int) {
var previewImg image.Image
switch printMode {
case mxw01.Mode1bpp:
previewImg = imageproc.RenderPreviewFrom1bpp(pixels, mxw01.LinePixels, height)
case mxw01.Mode4bpp:
previewImg = imageproc.RenderPreviewFrom4bpp(pixels, mxw01.LinePixels, height)
}
var out io.Writer
if outputPath == "-" {
out = os.Stdout
} else {
f, err := os.Create(outputPath)
if err != nil {
log.Fatalf("Failed to create output file: %v", err)
}
defer f.Close()
out = bufio.NewWriter(f)
defer func() { _ = out.(*bufio.Writer).Flush() }()
}
if err := imaging.Encode(out, previewImg, imaging.PNG); err != nil {
log.Fatalf("Failed to write PNG preview: %v", err)
}
if outputPath != "-" {
log.Printf("Preview PNG written to %s\n", outputPath)
}
}
func dial(sock string) (net.Conn, error) {
return net.DialTimeout("unix", sock, 2*time.Second)
}
func dialAuto(sock string) (net.Conn, string, error) {
if sock != "" {
c, err := dial(sock)
return c, sock, err
}
candidates := []string{}
if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
candidates = append(candidates, xdg+"/bleh/blehd.sock")
}
candidates = append(candidates, defaultSock)
var lastErr error
for _, p := range candidates {
c, err := dial(p)
if err == nil {
return c, p, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = fmt.Errorf("no socket candidates")
}
return nil, "", lastErr
}
func readResp(c net.Conn) (*ipc.Response, error) {
r := bufio.NewReader(c)
line, err := ipc.ReadLine(r)
if err != nil {
return nil, err
}
var resp ipc.Response
if err := jsonUnmarshal(line, &resp); err != nil {
return nil, err
}
return &resp, nil
}
func cloneMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m)+1)
for k, v := range m {
out[k] = v
}
return out
}
func jsonUnmarshal(b []byte, v any) error {
// Small helper to avoid importing encoding/json at top-level with name conflicts.
return json.Unmarshal(b, v)
}
func jsonPretty(v any) ([]byte, error) {
return json.MarshalIndent(v, "", " ")
}
func printResult(method string, result any) {
if result == nil {
return
}
if jsonOutput {
b, _ := jsonPretty(result)
fmt.Println(string(b))
return
}
m, _ := result.(map[string]any)
switch method {
case "printer.status.get":
fmt.Printf("Status: %v (%s), Battery: %v, Temp: %v\n",
m["ok"], m["state"], m["battery"], m["temp"])
case "printer.battery.get":
fmt.Printf("Battery level: %v\n", m["battery"])
case "printer.version.get":
fmt.Printf("Version: %v, Print type: %v\n", m["version"], m["printType"])
case "printer.printtype.get":
fmt.Printf("Print type: %v\n", m["printType"])
case "printer.querycount.get":
fmt.Printf("Query count: %v\n", m["queryCount"])
case "paper.eject":
fmt.Printf("Ejecting paper (%v lines)...\n", m["eject"])
case "paper.retract":
fmt.Printf("Retracting paper (%v lines)...\n", m["retract"])
case "print.start":
fmt.Println("Printed.")
default:
b, _ := jsonPretty(result)
fmt.Println(string(b))
}
}

424
cmd/blehd/main.go Normal file
View File

@@ -0,0 +1,424 @@
package main
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"strconv"
"sync"
"syscall"
"time"
"bleh/internal/ipc"
"bleh/internal/mxw01"
ble "github.com/go-ble/ble"
"github.com/go-ble/ble/linux"
"os/user"
)
const (
defaultSock = "/run/bleh/blehd.sock"
)
func autoSocketPath(explicit string) (string, error) {
if explicit != "" {
return explicit, nil
}
if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
return filepath.Join(xdg, "bleh", "blehd.sock"), nil
}
return defaultSock, nil
}
var printerMu sync.Mutex
func main() {
var socketPath string
var groupName string
flag.StringVar(&socketPath, "socket", "", "Unix socket path (default: auto-detect)")
flag.StringVar(&groupName, "group", "bleh", "Group name to own the socket")
flag.Parse()
resolvedSock, err := autoSocketPath(socketPath)
if err != nil {
log.Fatalf("Failed to resolve socket path: %v", err)
}
socketPath = resolvedSock
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Init BLE device once.
d, err := linux.NewDevice()
if err != nil {
log.Printf("Failed to open BLE device: %v", err)
log.Printf("Hint: blehd needs CAP_NET_RAW and CAP_NET_ADMIN. Example:")
log.Printf(" sudo setcap cap_net_raw,cap_net_admin=eip $(which blehd)")
log.Fatalf("Exiting.")
}
ble.SetDefaultDevice(d)
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
log.Fatalf("Failed to create socket dir: %v", err)
}
// Remove stale socket.
_ = os.Remove(socketPath)
ln, err := net.Listen("unix", socketPath)
if err != nil {
log.Fatalf("Listen failed: %v", err)
}
defer ln.Close()
// Set group ownership + perms.
if err := setSocketPerms(socketPath, groupName); err != nil {
log.Printf("Warning: failed to set socket permissions: %v", err)
log.Printf("Hint: if running unprivileged, prefer an XDG runtime socket (set $XDG_RUNTIME_DIR)")
}
log.Printf("blehd listening on %s", socketPath)
go func() {
<-ctx.Done()
_ = ln.Close()
_ = os.Remove(socketPath)
}()
for {
c, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
return
}
log.Printf("accept error: %v", err)
continue
}
go handleConn(ctx, c)
}
}
func setSocketPerms(path, group string) error {
grp, err := user.LookupGroup(group)
if err != nil {
return err
}
gid, err := strconv.Atoi(grp.Gid)
if err != nil {
return err
}
// Keep uid unchanged, set gid.
if err := os.Chown(path, -1, gid); err != nil {
return err
}
return os.Chmod(path, 0o660)
}
func handleConn(ctx context.Context, c net.Conn) {
defer c.Close()
r := bufio.NewReader(c)
w := bufio.NewWriter(c)
defer w.Flush()
for {
line, err := ipc.ReadLine(r)
if err != nil {
return
}
req, err := ipc.DecodeRequestLine(line)
if err != nil {
_ = ipc.EncodeLine(w, ipc.Response{OK: false, Error: &ipc.Error{Code: "EBADJSON", Message: err.Error()}})
_ = w.Flush()
continue
}
resp := dispatch(ctx, req)
_ = ipc.EncodeLine(w, resp)
_ = w.Flush()
}
}
func dispatch(ctx context.Context, req *ipc.Request) ipc.Response {
if req.ID == "" {
req.ID = "-"
}
method := req.Method
// Serialize access to the printer to avoid concurrent BLE sessions stomping each other.
// (This can be relaxed later if the printer/stack supports it reliably.)
printerMu.Lock()
defer printerMu.Unlock()
switch method {
case "printer.status.get":
st, err := doQuery(ctx, req.Params, 0xA1)
if err != nil {
return fail(req.ID, err)
}
s, err := mxw01.DecodeStatusNotification(st)
if err != nil {
return fail(req.ID, err)
}
return ok(req.ID, s)
case "printer.battery.get":
b, err := doQuery(ctx, req.Params, 0xAB)
if err != nil {
return fail(req.ID, err)
}
pct, err := mxw01.DecodeBatteryNotification(b)
if err != nil {
return fail(req.ID, err)
}
return ok(req.ID, map[string]any{"battery": pct})
case "printer.version.get":
b, err := doQuery(ctx, req.Params, 0xB1)
if err != nil {
return fail(req.ID, err)
}
v, err := mxw01.DecodeVersionNotification(b)
if err != nil {
return fail(req.ID, err)
}
return ok(req.ID, v)
case "printer.printtype.get":
b, err := doQuery(ctx, req.Params, 0xB0)
if err != nil {
return fail(req.ID, err)
}
t, err := mxw01.DecodePrintTypeNotification(b)
if err != nil {
return fail(req.ID, err)
}
return ok(req.ID, map[string]any{"printType": t})
case "printer.querycount.get":
b, err := doQuery(ctx, req.Params, 0xA7)
if err != nil {
return fail(req.ID, err)
}
qc, err := mxw01.DecodeQueryCountNotification(b)
if err != nil {
return fail(req.ID, err)
}
return ok(req.ID, map[string]any{"queryCount": fmt.Sprintf("% X", qc)})
case "paper.eject":
lines, _ := getInt(req.Params, "lines")
err := doLineCommand(ctx, req.Params, 0xA3, uint(lines))
if err != nil {
return fail(req.ID, err)
}
return ok(req.ID, map[string]any{"eject": lines})
case "paper.retract":
lines, _ := getInt(req.Params, "lines")
err := doLineCommand(ctx, req.Params, 0xA4, uint(lines))
if err != nil {
return fail(req.ID, err)
}
return ok(req.ID, map[string]any{"retract": lines})
case "print.start":
err := doPrint(ctx, req.Params)
if err != nil {
return fail(req.ID, err)
}
return ok(req.ID, map[string]any{"printed": true})
default:
return ipc.Response{ID: req.ID, OK: false, Error: &ipc.Error{Code: "ENOIMPL", Message: "unknown method"}}
}
}
func ok(id string, result any) ipc.Response {
return ipc.Response{ID: id, OK: true, Result: result}
}
func fail(id string, err error) ipc.Response {
return ipc.Response{ID: id, OK: false, Error: &ipc.Error{Code: "EFAIL", Message: err.Error()}}
}
func getString(m map[string]any, key string) (string, bool) {
v, ok := m[key]
if !ok {
return "", false
}
s, ok := v.(string)
return s, ok
}
func getInt(m map[string]any, key string) (int, bool) {
v, ok := m[key]
if !ok {
return 0, false
}
switch t := v.(type) {
case float64:
return int(t), true
case int:
return t, true
case json.Number:
i, err := t.Int64()
return int(i), err == nil
default:
return 0, false
}
}
func doQuery(ctx context.Context, params map[string]any, cmd byte) ([]byte, error) {
addr, _ := getString(params, "address")
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
adv, err := mxw01.FindPrinter(cctx, addr)
if err != nil {
return nil, err
}
client, err := ble.Dial(cctx, adv.Addr())
if err != nil {
return nil, err
}
defer client.CancelConnection()
mtu, err := client.ExchangeMTU(100)
if err == nil {
_ = mtu
}
printChr, notifyChr, _, err := mxw01.DiscoverChars(client)
if err != nil {
return nil, err
}
if notifyChr == nil {
return nil, fmt.Errorf("missing notification characteristic")
}
notifs := make(chan []byte, 8)
_, _ = client.DiscoverDescriptors(nil, notifyChr)
err = client.Subscribe(notifyChr, false, func(b []byte) {
select {
case notifs <- append([]byte{}, b...):
default:
}
})
if err != nil {
return nil, err
}
if err := mxw01.SendSimpleCommand(client, printChr, cmd); err != nil {
return nil, err
}
deadline := time.NewTimer(2 * time.Second)
defer deadline.Stop()
for {
select {
case b := <-notifs:
c, _, perr := mxw01.ParseNotification(b)
if perr == nil && c == cmd {
return b, nil
}
case <-deadline.C:
return nil, fmt.Errorf("timeout waiting for notification 0x%02X", cmd)
case <-cctx.Done():
return nil, cctx.Err()
}
}
}
func doLineCommand(ctx context.Context, params map[string]any, cmd byte, lines uint) error {
addr, _ := getString(params, "address")
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
adv, err := mxw01.FindPrinter(cctx, addr)
if err != nil {
return err
}
client, err := ble.Dial(cctx, adv.Addr())
if err != nil {
return err
}
defer client.CancelConnection()
printChr, _, _, err := mxw01.DiscoverChars(client)
if err != nil {
return err
}
return mxw01.SendLineCommand(client, printChr, cmd, lines)
}
func doPrint(ctx context.Context, params map[string]any) error {
addr, _ := getString(params, "address")
modeStr, _ := getString(params, "mode")
intensity, _ := getInt(params, "intensity")
height, _ := getInt(params, "height")
pixelsB64, _ := getString(params, "pixels_b64")
if height <= 0 {
return fmt.Errorf("invalid height")
}
pixels, err := base64.StdEncoding.DecodeString(pixelsB64)
if err != nil {
return fmt.Errorf("decode pixels: %v", err)
}
pm := mxw01.Mode1bpp
switch modeStr {
case "1bpp":
pm = mxw01.Mode1bpp
case "4bpp":
pm = mxw01.Mode4bpp
default:
return fmt.Errorf("invalid mode")
}
if intensity < 0 {
intensity = 0
}
if intensity > 100 {
intensity = 100
}
cctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
adv, err := mxw01.FindPrinter(cctx, addr)
if err != nil {
return err
}
client, err := ble.Dial(cctx, adv.Addr())
if err != nil {
return err
}
defer client.CancelConnection()
mtu, err := client.ExchangeMTU(100)
if err != nil {
mtu = 23
}
printChr, _, dataChr, err := mxw01.DiscoverChars(client)
if err != nil {
return err
}
if printChr == nil || dataChr == nil {
return fmt.Errorf("missing required characteristics")
}
return mxw01.SendImageBufferToPrinter(client, dataChr, printChr, pixels, height, pm, byte(intensity), mtu)
}