package main import ( "context" "errors" "fmt" _ "image/jpeg" _ "image/png" "log" "log/slog" "os" "os/signal" "slices" "strconv" "strings" "time" "unicode" "github.com/spf13/pflag" _ "golang.org/x/image/webp" "golang.org/x/sys/unix" "git.sr.ht/~guacamolie/faxmachine/config" "git.sr.ht/~guacamolie/faxmachine/escpos" "git.sr.ht/~guacamolie/faxmachine/escpos/protocol" "git.sr.ht/~guacamolie/faxmachine/keyboard" "git.sr.ht/~guacamolie/faxmachine/matrix" "git.sr.ht/~guacamolie/faxmachine/ntfy" "git.sr.ht/~guacamolie/faxmachine/printjob" ) func main() { configPath := pflag.String("config", "", "Use alternative config file location") pflag.Parse() matrixSlogHandler := matrix.NewSlogHandler(nil) logger := slog.New(slogMultiHandler{ slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelDebug, }), matrixSlogHandler, }) conf := config.Load(*configPath) logger.Info("program init", slog.Group("conf", slog.Group("devices", slog.String("printer", conf.Devices.Printer), slog.String("keyboard", conf.Devices.Keyboard), ), slog.Group("ntfy", slog.String("host", conf.Ntfy.Host), ), slog.Group("matrix", slog.String("user", conf.Matrix.UserID), slog.String("admin_room", conf.Matrix.AdminRoom), ), ), ) var kb *keyboard.Keyboard if conf.Devices.Keyboard != "" { var err error kb, err = keyboard.Open(conf.Devices.Keyboard) if err != nil { log.Fatalf("failed to open keyboard: %v", err) } } ntfyInstance := ntfy.NewInstance(conf.Ntfy, logger.WithGroup("ntfy")) matrixInstance, err := matrix.NewBot(context.Background(), conf.Matrix, logger.WithGroup("matrix")) if err != nil { log.Fatalf("failed to connect to Matrix: %v", err) } matrixSlogHandler.Attach(matrixInstance) daemon := &printDaemon{ lp: conf.Devices.Printer, ntfy: ntfyInstance, matrix: matrixInstance, keyboard: kb, } daemon.run(context.Background()) } type printDaemon struct { lp string ntfy *ntfy.Instance matrix *matrix.Bot keyboard *keyboard.Keyboard history []printjob.Job } func (d *printDaemon) run(ctx context.Context) { queue := make(chan printjob.Job, 10) inputCh := make(chan keyboard.KeyPress) if err := d.ntfy.Subscribe(ctx, queue); err != nil { log.Fatalf("failed to subscribe to ntfy: %v\n", err) } d.matrix.Subscribe(ctx, queue) if d.keyboard != nil { d.keyboard.Listen(ctx, inputCh) } sighubCh := make(chan os.Signal, 1) signal.Notify(sighubCh, unix.SIGHUP) for { if err := d.handlePrintJobs(ctx, queue, inputCh); err != nil { log.Println(err) } log.Println("waiting for SIGHUB to retry...") <-sighubCh } } func (d *printDaemon) handlePrintJobs(ctx context.Context, queue <-chan printjob.Job, inputCh <-chan keyboard.KeyPress) error { defer d.matrix.BecomeUnavailable(ctx) p, err := escpos.StartUSBPrinter(d.lp, protocol.TMT88IV, escpos.FlagNone) if err != nil { return fmt.Errorf("failed to connect to printer: %w", err) } defer p.Close() d.matrix.BecomeOnline(ctx) if err := p.EnableASB(protocol.ASBReportAll); err != nil { return fmt.Errorf("failed to enable ASB: %w", err) } disconnected := make(chan bool, 1) go func() { for status := range p.ASBStatus() { log.Printf("received ASB status: %#v\n", status) } disconnected <- true }() for { select { case job := <-queue: if err := p.SetPrintSpeed(5); err != nil { return fmt.Errorf("failed to set print speed: %w", err) } if err := handleJob(ctx, p, job); err != nil { var ioerr *escpos.IOError if errors.As(err, &ioerr) { return fmt.Errorf("fatal print failure: %v", err) } log.Printf("print failure: %v", err) } d.history = append(d.history, job) if len(d.history) > 100 { d.history = d.history[1:] } case inputEvent := <-inputCh: if name, ok := inputEvent.Sym.Name(); !ok || name != "r" { continue } if err := p.SetPrintSpeed(0); err != nil { return fmt.Errorf("failed to set print speed: %w", err) } handleInteractiveReply(ctx, p, d.history, inputCh) case <-disconnected: return fmt.Errorf("printer disconnected") } } } func handleInteractiveReply(ctx context.Context, p *escpos.Printer, history []printjob.Job, inputCh <-chan keyboard.KeyPress) { fmt.Fprintf(p, "CHOOSE MESSAGE FOR REPLY:\n") replyOptions := []printjob.JobReplyer{} for i := len(history) - 1; i >= 0; i-- { if job, ok := history[i].(printjob.JobReplyer); ok { replyOptions = append(replyOptions, job) } if len(replyOptions) >= 9 { break } } for i := len(replyOptions) - 1; i >= 0; i-- { option := replyOptions[i] description := option.Description() description = strings.ReplaceAll(description, "\r", "") description = strings.ReplaceAll(description, "\n", " ") fmt.Fprintf(p, "\n") fmt.Fprintf(p, " %d. \"%.36s\"\n", i+1, description) fmt.Fprintf(p, " sent %s ago by %s\n", time.Since(option.Time()).Round(time.Second), option.Sender()) } scrollUp(p) var chosenJob printjob.JobReplyer for inputEvent := range inputCh { if name, ok := inputEvent.Sym.Name(); ok && name == "Escape" { fmt.Fprintf(p, "REPLY ABORTED\n\n\n") p.CutPaper() return } num, err := strconv.Atoi(inputEvent.Char) if err != nil { continue } if num-1 >= len(replyOptions) { continue } fmt.Fprintf(p, "REPLYING TO MESSAGE %d\n\n", num) chosenJob = replyOptions[num-1] break } fmt.Fprintf(p, "ENTER REPLY:\n") scrollUp(p) var buffer textBuffer for inputEvent := range inputCh { mods := inputEvent.ShortcutMods key, _ := inputEvent.Sym.Name() if mods == 0 && key == "Escape" { fmt.Fprintf(p, "REPLY ABORTED\n\n\n") p.CutPaper() return } if mods == 0 && key == "Return" { break } switch { case mods == 0 && key == "Tab": fmt.Fprintf(p, "%s\n", buffer.preview()) scrollUp(p) case mods == 0 && key == "Delete": buffer.reset() case mods == 0 && key == "BackSpace": buffer.backspace() case mods == 0 && key == "Left": buffer.moveCursor(-1) case mods == 0 && key == "Right": buffer.moveCursor(+1) case mods == keyboard.Control && key == "w": buffer.removeWord() case mods == 0 && inputEvent.Char != "": buffer.typeChar(inputEvent.Char) } } if err := chosenJob.Reply(ctx, buffer.String()); err != nil { fmt.Fprintf(p, "failed to reply: %v\n\n\n", err) p.CutPaper() return } fmt.Fprintf(p, "REPLY SENT\n\n\n") p.CutPaper() } type textBuffer struct { // We store each entered key seperately here. This allows us to the same // amount of text that was entered when pressing backspace. It's a bit of a // hack, but it means I don't have to figure out the (complex) rules as to // what exactly should happen when you press backspace. buffer []string cursor int } func (tb *textBuffer) reset() { *tb = textBuffer{} } func (tb *textBuffer) typeChar(c string) { tb.buffer = slices.Insert(tb.buffer, tb.cursor, c) tb.cursor++ } func (tb *textBuffer) backspace() { if tb.cursor > 0 { tb.buffer = slices.Delete(tb.buffer, tb.cursor-1, tb.cursor) tb.cursor-- } } // removeWord implements deletion of a word using the conventional // "unix-word-rubout" method. It first deletes spaces until it can find a // non-space character, and then deletes non-space characters until it reaches // a space again. func (tb *textBuffer) removeWord() { hasSpace := func(s string) bool { return strings.IndexFunc(s, unicode.IsSpace) != -1 } for tb.cursor > 0 { if hasSpace(tb.buffer[tb.cursor-1]) { tb.backspace() } else { break } } for tb.cursor > 0 { if !hasSpace(tb.buffer[tb.cursor-1]) { tb.backspace() } else { break } } } func (tb *textBuffer) moveCursor(movement int) { tb.cursor = max(min(tb.cursor+movement, len(tb.buffer)), 0) } func (tb *textBuffer) preview() string { var builder strings.Builder for _, s := range tb.buffer[:tb.cursor] { builder.WriteString(s) } builder.WriteString("▒") for _, s := range tb.buffer[tb.cursor:] { builder.WriteString(s) } return builder.String() } func (tb *textBuffer) String() string { var builder strings.Builder for _, s := range tb.buffer { builder.WriteString(s) } return builder.String() } func scrollUp(p *escpos.Printer) { fmt.Fprintf(p, "\n\n\n\n\n\n\n\n") } func handleJob(ctx context.Context, p *escpos.Printer, job printjob.Job) (err error) { defer func() { if err == nil { job.OnPrinted(ctx) } else { job.OnShredded(ctx, err) } }() if err := job.Print(ctx, p); err != nil { return err } fmt.Fprint(p, "\n\n\n") if err := p.CutPaper(); err != nil { return fmt.Errorf("failed to cut paper: %w", err) } if err := p.Wait(); err != nil { return fmt.Errorf("failed to print: %w", err) } return nil }