4a0a055234
git-subtree-dir: faxmachine git-subtree-split: d23200bcfdedb9f8cc57e6a3c65b5ef93fcbfd19
401 lines
8.6 KiB
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
|
|
}
|