label_printer/epson/faxmachine/main.go

401 lines
8.6 KiB
Go

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
}