425 lines
9.3 KiB
Go
425 lines
9.3 KiB
Go
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)
|
|
}
|