diff --git a/faxmachine/LICENSE b/faxmachine/LICENSE new file mode 100644 index 0000000..8a98fcf --- /dev/null +++ b/faxmachine/LICENSE @@ -0,0 +1,12 @@ +Copyright (C) 2023 by Guacamolie + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/faxmachine/cmd/printimg/main.go b/faxmachine/cmd/printimg/main.go new file mode 100644 index 0000000..60808c9 --- /dev/null +++ b/faxmachine/cmd/printimg/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "log" + "os" + + _ "github.com/jbuchbinder/gopnm" + _ "golang.org/x/image/webp" + + "github.com/lestrrat-go/dither" + "github.com/nfnt/resize" + + "git.sr.ht/~guacamolie/faxmachine/escpos" + "git.sr.ht/~guacamolie/faxmachine/escpos/protocol" +) + +func main() { + p, err := escpos.StartUSBPrinter(os.Args[1], protocol.TMT88IV, escpos.FlagNone) + if err != nil { + log.Fatalf("failed to start printer: %v\n", err) + } + defer p.Close() + + if err := p.EnableASB(protocol.ASBReportAll); err != nil { + log.Fatalf("failed to enable ASB: %v\n", err) + } + go func() { + for status := range p.ASBStatus() { + log.Printf("received ASB status: %#v\n", status) + } + }() + + img, _, err := image.Decode(os.Stdin) + if err != nil { + log.Fatalf("failed to get decode image: %v\n", err) + } + img = resize.Resize(512, 0, img, resize.Lanczos3) + img = dither.Monochrome(dither.FloydSteinberg, img, 1.18) + + if err := p.SetEncoding(escpos.CharPagePC427); err != nil { + log.Fatalf("failed to set encoding: %v\n", err) + } + if err := p.SetPrintSpeed(5); err != nil { + log.Fatalf("failed to set print speed: %v\n", err) + } + if err := p.PrintImage(img); err != nil { + log.Fatalf("failed to print image: %v\n", err) + } + + if len(os.Args) > 2 && os.Args[2] != "" { + fmt.Fprintf(p, "\n%s\n", os.Args[2]) + } + + fmt.Fprint(p, "\n\n\n") + + if err := p.CutPaper(); err != nil { + log.Fatalf("failed to cut paper: %v", err) + } + + if err := p.Wait(); err != nil { + log.Fatalf("failed to print: %v\n", err) + } +} diff --git a/faxmachine/cmd/printtext/main.go b/faxmachine/cmd/printtext/main.go new file mode 100644 index 0000000..aaa71ab --- /dev/null +++ b/faxmachine/cmd/printtext/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "log" + "os" + + "github.com/hennedo/escpos" + "github.com/qiniu/iconv" +) + +func encode(input string) []byte { + cd, err := iconv.Open("cp437", "utf-8") + if err != nil { + log.Fatalf("failed to load cp437 encoding: %v\n", err) + } + outbuf := make([]byte, len(input)+32) + encoded, _, err := cd.Conv([]byte(input), outbuf) + if err != nil { + log.Fatalf("failed to convert to cp437: %v\n", err) + } + return encoded +} + +func main() { + p := escpos.New(os.Stdout) + p.WriteRaw(encode(os.Args[1])) + p.LineFeed() + p.LineFeed() + p.LineFeed() + p.PrintAndCut() +} diff --git a/faxmachine/config/config.go b/faxmachine/config/config.go new file mode 100644 index 0000000..a8bf540 --- /dev/null +++ b/faxmachine/config/config.go @@ -0,0 +1,80 @@ +package config + +import ( + "log" + "os" + + "github.com/BurntSushi/toml" + "github.com/adrg/xdg" +) + +type Toplevel struct { + Ntfy Ntfy `toml:"ntfy"` + Matrix Matrix `toml:"matrix"` + Devices Devices `toml:"devices"` +} + +type Ntfy struct { + Host string `toml:"host"` + AccessToken string `toml:"access_token"` + Topics map[string]Topic `toml:"topics"` +} + +type Topic struct { + Name string `toml:"name"` +} + +type Matrix struct { + UserID string `toml:"user_id"` + DeviceID string `toml:"device_id"` + AccessToken string `toml:"access_token"` + AdminRoom string `toml:"admin_room"` + Users map[string]MatrixUser `toml:"users"` + FeelgoodKey string `toml:"feelgood_key"` + Database string `toml:"database"` + MautrixLog string `toml:"mautrix_log"` +} + +type MatrixUser struct{} + +type Devices struct { + Printer string `toml:"printer"` + Keyboard string `toml:"keyboard"` +} + +func open(altpath string) *os.File { + if altpath != "" { + file, err := os.Open(altpath) + if err != nil { + log.Fatalf("unable to open %s: %v\n", altpath, file) + } + + return file + } + + path, err := xdg.SearchConfigFile("faxmachine/config.toml") + if err != nil { + log.Fatalf("unable to locate config: %v\n", err) + } + + file, err := os.Open(path) + if err != nil { + log.Fatalf("unable to open %s: %v\n", path, file) + } + + return file +} + +func Load(altpath string) *Toplevel { + file := open(altpath) + + decoder := toml.NewDecoder(file) + + var toplevel Toplevel + _, err := decoder.Decode(&toplevel) + if err != nil { + log.Fatalf("failed to parse config: %v\n", err) + } + + return &toplevel +} diff --git a/faxmachine/escpos/component.go b/faxmachine/escpos/component.go new file mode 100644 index 0000000..95ce120 --- /dev/null +++ b/faxmachine/escpos/component.go @@ -0,0 +1,157 @@ +package escpos + +import ( + "errors" + "fmt" + "log/slog" + "strings" + "syscall" + + "git.sr.ht/~guacamolie/faxmachine/escpos/protocol" + "github.com/djimenez/iconv-go" +) + +type Component interface { + render(proto protocol.Protocol) ([][]byte, error) +} + +type dataComponent struct { + data []byte +} + +func (dc dataComponent) render(proto protocol.Protocol) ([][]byte, error) { + return [][]byte{dc.data}, nil +} + +type groupComponent struct { + children []Component +} + +func (gc groupComponent) render(proto protocol.Protocol) ([][]byte, error) { + var ( + instructions [][]byte + errs []error + ) + + for _, child := range gc.children { + inst, err := child.render(proto) + instructions = append(instructions, inst...) + errs = append(errs, err) + } + + return instructions, errors.Join(errs...) +} + +// Text returns a Component that will print utf8-encoded text in an +// automatically chosen encoding. It will automatically switch between +// encodings mid-sentence if needed. The Component will return an error if the +// printer any part of the input string could not be converted. +// +// Text uses libc iconv to convert the text. Both musl libc and glibc are +// supported. +func Text(text string) Component { + return textComponent{text} +} + +type textComponent struct { + text string +} + +func (tc textComponent) render(proto protocol.Protocol) ([][]byte, error) { + if len(tc.text) == 0 { + return nil, nil + } + + // musl libc uses '*' to represent characters it couldn't convert in the + // output string. This is more standard compliant than glibc, but does make + // it more difficult for us to detect if some characters couldn't be + // converted in a given encoding. + // + // So for musl libc we will be checking for '*' characters in the output to + // determine if the conversion failed. This means however that we can't + // have any '*' characters in our input, since this would mess up the + // detection. + // + // Luckily the first 127 characters are always the same on this printer for + // every possible encoding, meaning we can just directly print '*' + // characters without having to bother with encoding. The beauty of our + // US-centric world. + if before, after, found := strings.Cut(tc.text, "*"); found { + return groupComponent{ + children: []Component{ + textComponent{before}, + dataComponent{[]byte("*")}, + textComponent{after}, + }, + }.render(proto) + } + + for _, encoding := range allEncodings { + if encoding.iconvEncoding == "" { + continue + } + + converter, err := iconv.NewConverter("utf-8", encoding.iconvEncoding) + if err == syscall.EINVAL { + continue + } else if err != nil { + return nil, fmt.Errorf("failed to initialize converter from utf-8 to %s: %w", encoding.iconvEncoding, err) + } + defer converter.Close() + + converted, err := converter.ConvertString(tc.text) + slog.Debug("iconv", + "utf8", tc.text, + slog.String(encoding.iconvEncoding, converted), + "error", err) + + // glibc returns EILSEQ, musl libc uses '*' to represent invalid chars. + // Check for both. + if err == syscall.EILSEQ || + (err == nil && strings.Index(converted, "*") != -1) { + // This encoding can't properly convert this string, try the next + // encoding. + continue + } else if err != nil { + return nil, fmt.Errorf("failed to convert text encoding: %w", err) + } + + // We found a good encoding! + inst, err := proto.SelectCharacterCodeTable(encoding.page) + if err != nil { + // ...or not + continue + } + + return [][]byte{ + inst, + []byte(converted), + }, nil + } + + // None of the supported encoding convert this string. Try to see if it + // does work when splitting the string up into substrings. + + // Find a midpoint in the string that is between two rune boundaries. + // TODO: Can this be made more correct? Not every rune boundary is a valid + // place to chop up a string. + halfwayIsh := 0 + for i := range tc.text { + if i >= len(tc.text)/2 { + halfwayIsh = i + break + } + } + + if halfwayIsh == 0 { + // We have exhausted all options to convert this, bail out + return nil, fmt.Errorf("device doesn't support %q characters", tc.text) + } + + return groupComponent{ + []Component{ + textComponent{tc.text[:halfwayIsh]}, + textComponent{tc.text[halfwayIsh:]}, + }, + }.render(proto) +} diff --git a/faxmachine/escpos/encoding.go b/faxmachine/escpos/encoding.go new file mode 100644 index 0000000..f7e6e1c --- /dev/null +++ b/faxmachine/escpos/encoding.go @@ -0,0 +1,114 @@ +package escpos + +type Encoding struct { + page uint8 + iconvEncoding string +} + +var allEncodings = []Encoding{ + CharPage0, + CharPage1, + CharPage2, + CharPage3, + CharPage4, + CharPage5, + CharPage6, + CharPage7, + CharPage8, + CharPage16, + CharPage17, + CharPage18, + CharPage19, +} + +var ( + CharPage0 = Encoding{ + page: 0, + iconvEncoding: "cp437", + } + CharPagePC427 = CharPage0 + CharPageUSA = CharPage0 + CharPageStandardEurope = CharPage0 + + CharPage1 = Encoding{ + page: 1, + } + CharPageKatakana = CharPage1 + + CharPage2 = Encoding{ + page: 2, + iconvEncoding: "cp850", + } + CharPagePC850 = CharPage2 + CharPageMultilingual = CharPage2 + + CharPage3 = Encoding{ + page: 3, + iconvEncoding: "cp860", + } + CharPagePC860 = CharPage3 + CharPagePortuguese = CharPage3 + + CharPage4 = Encoding{ + page: 4, + iconvEncoding: "cp863", + } + CharPagePC863 = CharPage4 + CharPageCanadianFrench = CharPage4 + + CharPage5 = Encoding{ + page: 5, + iconvEncoding: "cp865", + } + CharPagePC865 = CharPage5 + CharPageNordic = CharPage5 + + CharPage6 = Encoding{ + page: 6, + } + CharPageHirakana = CharPage6 + CharPageSimplifiedKanji1 = CharPage6 + + CharPage7 = Encoding{ + page: 7, + } + CharPageSimplifiedKanji2 = CharPage7 + + CharPage8 = Encoding{ + page: 8, + } + CharPageSimplifiedKanji3 = CharPage8 + + CharPage16 = Encoding{ + page: 16, + iconvEncoding: "cp1252", + } + CharPageWPC1252 = CharPage16 + + CharPage17 = Encoding{ + page: 17, + iconvEncoding: "cp866", + } + CharPagePC866 = CharPage17 + CharPageCyrillic2 = CharPage17 + + CharPage18 = Encoding{ + page: 18, + iconvEncoding: "cp852", + } + CharPageLatin2 = CharPage18 + + CharPage19 = Encoding{ + page: 19, + iconvEncoding: "cp858", + } + CharPageEuro = CharPage19 + + CharPage254 = Encoding{ + page: 254, + } + + CharPage255 = Encoding{ + page: 255, + } +) diff --git a/faxmachine/escpos/escpos.go b/faxmachine/escpos/escpos.go new file mode 100644 index 0000000..6805166 --- /dev/null +++ b/faxmachine/escpos/escpos.go @@ -0,0 +1,274 @@ +package escpos + +import ( + "context" + "fmt" + "image" + "io" + "math" + "runtime" + + "git.sr.ht/~guacamolie/faxmachine/escpos/printer" + "git.sr.ht/~guacamolie/faxmachine/escpos/protocol" +) + +type Printer struct { + printer printer.Printer + proto protocol.Protocol + ownsPrinter bool + + lineDirty bool + + ctx context.Context + cancelCtx context.CancelCauseFunc + statusResponse chan byte + asbStatus chan protocol.ASBStatus +} + +func StartPrinter(printer printer.Printer, proto protocol.Protocol) (*Printer, error) { + p := &Printer{ + printer: printer, + proto: proto, + ownsPrinter: false, + lineDirty: false, + statusResponse: make(chan byte), + asbStatus: make(chan protocol.ASBStatus), + } + + if err := p.writeInstr(mustInstr(p.proto.InitializePrinter())); err != nil { + return nil, err + } + + p.ctx, p.cancelCtx = context.WithCancelCause(context.Background()) + + go func() { + for p.ctx.Err() == nil { + var buf [4]byte + n, err := p.printer.Read(buf[:]) + if err == io.EOF { + runtime.Gosched() + continue + } + if err != nil { + p.cancelCtx(fmt.Errorf("failed to read from printer: %w", err)) + break + } + switch n { + case 1: + p.statusResponse <- buf[1] + case 4: + p.asbStatus <- p.proto.ParseASBStatus(buf) + } + } + + close(p.statusResponse) + close(p.asbStatus) + }() + + return p, nil +} + +const ( + FlagNone = 0 + FlagDebug = 1 << 0 +) + +func StartUSBPrinter(path string, proto protocol.Protocol, flags int) (*Printer, error) { + usbPrinter, err := printer.OpenUSBPrinter(path, flags) + if err != nil { + return nil, err + } + + p, err := StartPrinter(usbPrinter, proto) + if err != nil { + usbPrinter.Close() + return nil, err + } + p.ownsPrinter = true + + return p, nil +} + +func (p *Printer) Close() error { + p.cancelCtx(nil) + if p.ownsPrinter { + return p.printer.Close() + } + return nil +} + +func mustInstr(instr []byte, err error) []byte { + if err != nil { + panic(fmt.Sprintf("library bug: %v", err)) + } + return instr +} + +func (p *Printer) writeInstr(instr []byte) error { + _, err := p.printer.Write(instr) + if err != nil { + return &IOError{err} + } + return nil +} + +func (p *Printer) Write(data []byte) (int, error) { + return p.WriteString(string(data)) +} + +func (p *Printer) WriteString(s string) (n int, err error) { + err = p.Print(Text(s)) + if err != nil { + return 0, err + } + + // We can't just return the amount of written characters here, since that + // may not equal the amount of input characters after conversion. Code + // calling Write and WriteString often expect n to equal len(s) on success, + // to just return len(s) to satisfy this. + return len(s), nil +} + +func (p *Printer) Print(c Component) error { + instructions, err := c.render(p.proto) + if err != nil { + return err + } + + for _, inst := range instructions { + if _, err := p.printer.Write(inst); err != nil { + return &IOError{err} + } + + if len(inst) > 0 && inst[len(inst)-1] == '\n' { + p.lineDirty = false + } else { + p.lineDirty = true + } + } + + return nil +} + +func (p *Printer) SetPrintSpeed(speed int) error { + if speed > math.MaxUint8 { + return fmt.Errorf("invalid print speed %d", speed) + } + instr, err := p.proto.SelectPrintSpeed(uint8(speed)) + if err != nil { + return fmt.Errorf("printer does not support print speed %d: %v", speed, err) + } + return p.writeInstr(instr) +} + +func (p *Printer) PrintImage(img image.Image) error { + if p.lineDirty { + return &LineDirtyError{"print image"} + } + + x, y, data := getPrintImageData(img) + + return p.printImage(x, y, data) +} + +func (p *Printer) printImage(x int, y int, data []byte) error { + maxY := 1662 + + if y > maxY { + err := p.printImage(x, maxY, data[:(x*maxY)>>3]) + if err != nil { + return fmt.Errorf("failed to print first half: %w", err) + } + + // Wait until we finished to avoid overflowing the printer's buffer. + if err := p.Wait(); err != nil { + return fmt.Errorf("error printing first half: %w", err) + } + + err = p.printImage(x, y-maxY, data[(x*maxY)>>3:]) + if err := p.Wait(); err != nil { + return fmt.Errorf("error printing second half: %w", err) + } + + return nil + } + + storeInstr, err := p.proto.StoreGraphicsData(1, 1, protocol.Color1, uint16(x), uint16(y), data...) + if err != nil { + return fmt.Errorf("image does not confirm to limitations of printer: %w", err) + } + printInstr := mustInstr(p.proto.PrintGraphicsData()) + + if err := p.writeInstr(storeInstr); err != nil { + return fmt.Errorf("failed to send image data to printer: %w", err) + } + if err := p.writeInstr(printInstr); err != nil { + return fmt.Errorf("failed to send print instruction to printer: %w", err) + } + return nil +} + +func (p *Printer) CutPaper() error { + if p.lineDirty { + return &LineDirtyError{"cut paper"} + } + instr := mustInstr(p.proto.CutPaper(protocol.FeedAndPartialCut, 0)) + return p.writeInstr(instr) +} + +func (p *Printer) Wait() error { + instr := mustInstr(p.proto.TransmitStatus(protocol.TransmitPaperSensorStatus)) + if err := p.writeInstr(instr); err != nil { + return fmt.Errorf("failed to request status from printer: %w", err) + } + + select { + case <-p.ctx.Done(): + return context.Cause(p.ctx) + case <-p.statusResponse: + return nil + } +} + +func (p *Printer) EnableASB(flags int) error { + instr, err := p.proto.SetAutomaticStatusBack(uint8(flags)) + if err != nil { + return fmt.Errorf("failed to request Automatic Status Back (ASB) from printer: %w", err) + } + return p.writeInstr(instr) +} + +func (p *Printer) DisableASB() error { + return p.EnableASB(protocol.ASBReportNothing) +} + +func (p *Printer) ASBStatus() <-chan protocol.ASBStatus { + return p.asbStatus +} + +// LineDirtyError is returned when the action cannot be performed because the +// printer needs to be in the "begining of the line" state for action to be +// performed, but it has been determined that the printer won't be in this +// state when it will process the command. +// +// This usually happens when character data is written to the printer without a +// linefeed ('\n') at the end. +type LineDirtyError struct { + attemptedAction string +} + +func (err *LineDirtyError) Error() string { + return fmt.Sprintf("can only %s when at the beginning of the line", err.attemptedAction) +} + +type IOError struct { + wrapped error +} + +func (err *IOError) Error() string { + return fmt.Sprintf("io error when printing: %v", err.wrapped) +} + +func (err *IOError) Unwrap() error { + return err.wrapped +} diff --git a/faxmachine/escpos/printer/printer.go b/faxmachine/escpos/printer/printer.go new file mode 100644 index 0000000..b371400 --- /dev/null +++ b/faxmachine/escpos/printer/printer.go @@ -0,0 +1,115 @@ +package printer + +import ( + "encoding/hex" + "fmt" + "io" + "log" + "os" + "strings" + + "golang.org/x/sys/unix" +) + +type Printer interface { + io.Reader + io.Writer + io.Closer +} + +type usbPrinter struct { + *os.File +} + +const ( + FlagNone = 0 + FlagDebug = 1 << 0 +) + +func OpenUSBPrinter(path string, flags int) (Printer, error) { + file, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return nil, err + } + + var printer Printer = usbPrinter{file} + if flags&FlagDebug != 0 { + printer = withDebugger(printer) + } + return printer, nil +} + +func (usb usbPrinter) Write(p []byte) (int, error) { + // The printer often doens't like it if we use multiple write syscalls to + // transmit the data. usb.File.Write() doesn't seem to guarantee that it + // will only use a single syscall, so we manually call write instead. + return unix.Write(int(usb.Fd()), p) +} + +type debugger struct { + Printer + lastError error +} + +func withDebugger(printer Printer) Printer { + return &debugger{ + Printer: printer, + lastError: nil, + } +} + +func (d *debugger) Read(p []byte) (n int, err error) { + n, err = d.Printer.Read(p) + + if err == io.EOF { + return + } + + // don't spam the terminal with the same error again and again + if err != nil && d.lastError != nil && err.Error() == d.lastError.Error() { + return + } + d.lastError = err + + var builder strings.Builder + fmt.Fprint(&builder, " \033[31m") + for i, b := range p[:n] { + if i > 0 { + fmt.Fprint(&builder, " ") + } + fmt.Fprintf(&builder, "%08b", b) + } + if err != nil { + if n > 0 { + fmt.Fprint(&builder, " ") + } + fmt.Fprintf(&builder, "read error: %v", err) + } + fmt.Fprintf(&builder, "\033[0m\n") + + fmt.Fprint(os.Stderr, builder.String()) + + return +} + +func (d *debugger) Write(p []byte) (n int, err error) { + n, err = d.Printer.Write(p) + + printSize := len(p) + max := 20 * 8 * 2 + if printSize > max { + printSize = max + } + fmt.Fprintf(os.Stderr, "\033[36m%s\033[0m", hex.Dump(p[:printSize])) + if len(p) > printSize { + fmt.Fprintf(os.Stderr, " \033[36m[%d bytes omitted]\033[0m\n", len(p)-printSize) + } + + if err == nil && n == len(p) { + log.Printf("wrote %d bytes to printer\n", n) + } else { + log.Printf("only wrote %d/%d bytes to printer: %v\n", n, len(p), err) + } + + return +} diff --git a/faxmachine/escpos/protocol/build.go b/faxmachine/escpos/protocol/build.go new file mode 100644 index 0000000..93f53d3 --- /dev/null +++ b/faxmachine/escpos/protocol/build.go @@ -0,0 +1,166 @@ +package protocol + +import ( + "fmt" +) + +const ( + esc = '\x1b' + gs = '\x1d' +) + +// InitializePrinter executes ESC @ ("Initialize printer") +func (p Protocol) InitializePrinter() ([]byte, error) { + return []byte{esc, '@'}, nil +} + +// SelectCharacterCodeTable executes ESC t n ("Select character code table") +func (p Protocol) SelectCharacterCodeTable(n uint8) ([]byte, error) { + if (n > 5 && n < 16) || (n > 19 && n < 255) { + return nil, fmt.Errorf("range error: 0 ≤ n ≤ 5, 16 ≤ n ≤ 19, n = 255") + } + + return []byte{esc, 't', n}, nil +} + +const ( + PrintModeDefault = 0 + PrintModeFont2 = 1 << 0 + _ = 1 << 1 + _ = 1 << 2 + PrintModeEmphasized = 1 << 3 + PrintModeDoubleHeight = 1 << 4 + PrintModeDoubleWidth = 1 << 5 + _ = 1 << 6 + PrintModeUnderline = 1 << 7 +) + +// SelectPrintModes executes ESC ! n ("Select print mode(s)") +func (p Protocol) SelectPrintModes(n uint8) ([]byte, error) { + return []byte{esc, '!', n}, nil +} + +// SetPrintSpeed executes GS ( K ("Select the print speed") +func (p Protocol) SelectPrintSpeed(m uint8) ([]byte, error) { + if (m > 9 && m < 48) || m > 57 { + return nil, fmt.Errorf("range error: m = 0 ≤ m ≤ 9, 48 ≤ m ≤ 57") + } + return []byte{gs, '(', 'K', 2, 0, 50, m}, nil +} + +const ( + ASBReportNothing = 0 + ASBReportKickoutConnector = 1 << 0 + ASBReportOnlineStatus = 1 << 1 + ASBReportErrors = 1 << 2 + ASBReportRollPaperSensorStatus = 1 << 3 + _ = 1 << 4 + _ = 1 << 5 + ASBReportPanelSwitchStatus = 1 << 6 + _ = 1 << 7 + + ASBReportAll = ASBReportKickoutConnector | + ASBReportOnlineStatus | + ASBReportErrors | + ASBReportRollPaperSensorStatus | + ASBReportPanelSwitchStatus +) + +func (p Protocol) SetAutomaticStatusBack(n uint8) ([]byte, error) { + return []byte{gs, 'a', n}, nil +} + +const ( + TransmitPaperSensorStatus = 1 + TransmitKickoutConnectorStatus = 2 +) + +func (p Protocol) TransmitStatus(n uint8) ([]byte, error) { + if n != 1 && n != 2 && n != 49 && n != 50 { + return nil, fmt.Errorf("range error: n = 1, 2, 49, 50") + } + + return []byte{gs, 'r', n}, nil +} + +func (p Protocol) gs8l(m uint8, fn uint8, parameters ...uint8) ([]byte, error) { + p_ := 1 /* m */ + 1 /* fn */ + len(parameters) + p1 := uint8(p_ >> 0) + p2 := uint8(p_ >> 8) + p3 := uint8(p_ >> 16) + p4 := uint8(p_ >> 24) + command := []uint8{gs, '8', 'L', p1, p2, p3, p4, m, fn} + command = append(command, parameters...) + + return command, nil +} + +const ( + Color1 = 49 + Color2 = 50 +) + +// StoreGraphicsData executes GS 8 L p1 p2 p3 p4 m fn a bx by c xL xH yL yH +// d1...dk ("Store the graphics data in the print buffer (raster +// format).") +func (p Protocol) StoreGraphicsData(bx uint8, by uint8, c uint8, x uint16, y uint16, d ...uint8) ([]byte, error) { + var m uint8 = 0x30 + var fn uint8 = 0x70 + var a uint8 = 0x30 + + if bx != 1 && bx != 2 { + return nil, fmt.Errorf("range error: bx = 1, 2") + } + if by != 1 && by != 2 { + return nil, fmt.Errorf("range error: by = 1, 2") + } + if c != Color1 && c != Color2 { + return nil, fmt.Errorf("range error: c = 49, 50") + } + if x > 2047 { + return nil, fmt.Errorf("range error: 1 ≤ x ≤ 2047 (x = %d)", x) + } + if by == 1 && y > 1662 { + return nil, fmt.Errorf("range error: (by = 1): 1 ≤ y ≤ 1662 (y = %d)", y) + } + if by == 2 && y > 831 { + return nil, fmt.Errorf("range error: (by = 2): 1 ≤ y ≤ 831 (y = %d)", y) + } + if (int(x)+7)/8*int(y) != len(d) { + return nil, fmt.Errorf("range error: k = (int(x + 7)/8) × y (k = %d, x = %d, y = %d)", len(d), x, y) + } + + xl := uint8(x >> 0) + xh := uint8(x >> 8) + yl := uint8(y >> 0) + yh := uint8(y >> 8) + + paramters := []byte{a, bx, by, c, xl, xh, yl, yh} + paramters = append(paramters, d...) + + return p.gs8l(m, fn, paramters...) +} + +// PrintGraphicsData executes GS 8 L p1 p2 p3 p4 m fn ("Print the +// graphics data in the print buffer.") +func (p Protocol) PrintGraphicsData() ([]byte, error) { + var m uint8 = 48 + var fn uint8 = 50 + return p.gs8l(m, fn) +} + +const ( + FullCut = 0 + PartialCut = 1 + FeedAndFullCut = 65 + FeedAndPartialCut = 66 +) + +// CutPaper executes GS V ("Select cut mode and cut paper") +func (p Protocol) CutPaper(m uint8, n uint8) ([]byte, error) { + if m == FullCut || m == PartialCut { + return []byte{gs, 'V', m}, nil + } else { + return []byte{gs, 'V', m, n}, nil + } +} diff --git a/faxmachine/escpos/protocol/parse.go b/faxmachine/escpos/protocol/parse.go new file mode 100644 index 0000000..07997a6 --- /dev/null +++ b/faxmachine/escpos/protocol/parse.go @@ -0,0 +1,71 @@ +package protocol + +type DrawerKickoutConnectorStatus struct { + IsPin3High bool +} + +type OnlineStatus struct { + IsOffline bool + IsCoverOpen bool + IsPaperFedByFeedButton bool + IsWaitingForOnlineRecovery bool +} + +type PaperSensorStatus struct { + IsNearEnd bool + IsNotPresent bool +} + +type ErrorStatus struct { + RecoverableErrorOccurred bool + AutocutterErrorOccurred bool + UnrecoverableErrorOccurred bool + AutomaticallyRecoverableErrorOccurred bool +} + +type PanelSwitchStatus struct { + IsFeedButtonPressed bool +} + +type ASBStatus struct { + DrawerKickoutConnector DrawerKickoutConnectorStatus + Online OnlineStatus + Paper PaperSensorStatus + Error ErrorStatus + PanelSwitch PanelSwitchStatus +} + +func (p Protocol) ParsePaperSensorStatus(status byte) PaperSensorStatus { + return PaperSensorStatus{ + IsNearEnd: status&0b00000011 != 0, + IsNotPresent: status&0b00001100 != 0, + } +} + +func (p Protocol) ParseASBStatus(status [4]byte) ASBStatus { + checkbit := func(nbyte int, nbit int) bool { + return status[nbyte]&(1<>3) + + for y := 0; y < printHeight; y++ { + for x := 0; x < printWidth; x = x + 8 { + i := y*(printWidth>>3) + (x >> 3) + bytes[i] = + byte((getPixelValue(x+0, y, pixels) << 7) | + (getPixelValue(x+1, y, pixels) << 6) | + (getPixelValue(x+2, y, pixels) << 5) | + (getPixelValue(x+3, y, pixels) << 4) | + (getPixelValue(x+4, y, pixels) << 3) | + (getPixelValue(x+5, y, pixels) << 2) | + (getPixelValue(x+6, y, pixels) << 1) | + getPixelValue(x+7, y, pixels)) + } + } + + return bytes, nil +} + +func getPixelValue(x int, y int, pixels *[][]pixel) int { + row := (*pixels)[y] + pixel := row[x] + + if pixel.R > 0 { + return 0 + } + + return 1 +} + +func rgbaToPixel(r uint32, g uint32, b uint32, a uint32) pixel { + return pixel{int(r >> 8), int(g >> 8), int(b >> 8), int(a >> 8)} +} + +type pixel struct { + R int + G int + B int + A int +} + +func getPixels(img image.Image) (int, int, [][]pixel) { + + bounds := img.Bounds() + width, height := bounds.Max.X, bounds.Max.Y + + var pixels [][]pixel + for y := 0; y < height; y++ { + var row []pixel + for x := 0; x < width; x++ { + row = append(row, rgbaToPixel(img.At(x, y).RGBA())) + } + pixels = append(pixels, row) + } + + return width, height, pixels +} diff --git a/faxmachine/go.mod b/faxmachine/go.mod new file mode 100644 index 0000000..5a7226c --- /dev/null +++ b/faxmachine/go.mod @@ -0,0 +1,36 @@ +module git.sr.ht/~guacamolie/faxmachine + +go 1.21.1 + +require ( + github.com/BurntSushi/toml v1.4.0 + github.com/adrg/xdg v0.4.0 + github.com/djimenez/iconv-go v0.0.0-20160305225143-8960e66bd3da + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/hennedo/escpos v0.0.2-0.20221114190247-475ba147a030 + github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 + github.com/jbuchbinder/gopnm v0.0.0-20220507095634-e31f54490ce0 + github.com/lestrrat-go/dither v0.0.0-20180426220553-2a6e1152a49e + github.com/mattn/go-sqlite3 v1.14.22 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 + github.com/qiniu/iconv v1.2.0 + github.com/rs/zerolog v1.33.0 + github.com/spf13/pflag v1.0.5 + golang.org/x/image v0.17.0 + golang.org/x/sys v0.21.0 + maunium.net/go/mautrix v0.18.1 + nhooyr.io/websocket v1.8.11 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/tidwall/gjson v1.17.1 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.mau.fi/util v0.4.2 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect + golang.org/x/net v0.26.0 // indirect +) diff --git a/faxmachine/go.sum b/faxmachine/go.sum new file mode 100644 index 0000000..fc7bf73 --- /dev/null +++ b/faxmachine/go.sum @@ -0,0 +1,81 @@ +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= +github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/djimenez/iconv-go v0.0.0-20160305225143-8960e66bd3da h1:0qwwqQCLOOXPl58ljnq3sTJR7yRuMolM02vjxDh4ZVE= +github.com/djimenez/iconv-go v0.0.0-20160305225143-8960e66bd3da/go.mod h1:ns+zIWBBchgfRdxNgIJWn2x6U95LQchxeqiN5Cgdgts= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/hennedo/escpos v0.0.2-0.20221114190247-475ba147a030 h1:8tYnnUdNC5Vzr1NkanSHOx/Ai9mA1huL3hEuD6ORzaY= +github.com/hennedo/escpos v0.0.2-0.20221114190247-475ba147a030/go.mod h1:W6HIhrS6mSFGSsxpfqIKXnZsujl0x/2lM5xDIAKGkow= +github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 h1:92OsBIf5KB1Tatx+uUGOhah73jyNUrt7DmfDRXXJ5Xo= +github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk= +github.com/jbuchbinder/gopnm v0.0.0-20220507095634-e31f54490ce0 h1:9GwwkVzUn1vRWAQ8GRu7UOaoM+FZGnvw88DsjyiqfXc= +github.com/jbuchbinder/gopnm v0.0.0-20220507095634-e31f54490ce0/go.mod h1:6U0E76+sB1jTuSSXJjePtLd44vExeoYThOWgOoXo3x8= +github.com/lestrrat-go/dither v0.0.0-20180426220553-2a6e1152a49e h1:VBmMIwDyGTS/2sz+kgEeBwW0deYg9Qqjnsx6VjnHtiE= +github.com/lestrrat-go/dither v0.0.0-20180426220553-2a6e1152a49e/go.mod h1:lu7KKF+0cGq2q9YisNN3giHRRpbMvhe6Q5x26nQ4+2c= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/qiniu/iconv v1.2.0 h1:2LJKwoF+4LJ3lNM+7cE3P1kNQzAI/HMZuWhkmFoY2U8= +github.com/qiniu/iconv v1.2.0/go.mod h1:5bxb2h9lptZt2eHLgY+Jw4X06TMtKb6tvvok0DwSwGA= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.mau.fi/util v0.4.2 h1:RR3TOcRHmCF9Bx/3YG4S65MYfa+nV6/rn8qBWW4Mi30= +go.mau.fi/util v0.4.2/go.mod h1:PlAVfUUcPyHPrwnvjkJM9UFcPE7qGPDJqk+Oufa1Gtw= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/image v0.17.0 h1:nTRVVdajgB8zCMZVsViyzhnMKPwYeroEERRC64JuLco= +golang.org/x/image v0.17.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mautrix v0.18.1 h1:a6mUsJixegBNTXUoqC5RQ9gsumIPzKvCubKwF+zmCt4= +maunium.net/go/mautrix v0.18.1/go.mod h1:2oHaq792cSXFGvxLvYw3Gf1L4WVVP4KZcYys5HVk/h8= +nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= +nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/faxmachine/keyboard/keyboard.go b/faxmachine/keyboard/keyboard.go new file mode 100644 index 0000000..7d9a4e7 --- /dev/null +++ b/faxmachine/keyboard/keyboard.go @@ -0,0 +1,149 @@ +package keyboard + +import ( + "context" + "fmt" + "log" + + "git.sr.ht/~guacamolie/faxmachine/xkbcommon" + "github.com/holoplot/go-evdev" +) + +type Keyboard struct { + dev *evdev.InputDevice + ctx *xkbcommon.Context + keymap *xkbcommon.Keymap + state *xkbcommon.State +} + +func Open(devnode string) (*Keyboard, error) { + var err error + k := &Keyboard{} + + k.dev, err = evdev.Open(devnode) + if err != nil { + return nil, err + } + + err = k.dev.Grab() + if err != nil { + k.dev.Close() + return nil, fmt.Errorf("failed to grab input: %w", err) + } + + k.ctx, err = xkbcommon.NewContext(xkbcommon.ContextNoFlags) + if err != nil { + k.dev.Close() + return nil, err + } + + k.keymap, err = xkbcommon.NewKeymapFromNames(k.ctx, nil, xkbcommon.KeymapCompileFlagsNoFlags) + if err != nil { + k.ctx.Unref() + k.dev.Close() + return nil, err + } + + k.state, err = xkbcommon.NewState(k.keymap) + if err != nil { + k.keymap.Unref() + k.ctx.Unref() + k.dev.Close() + return nil, err + } + + return k, nil +} + +func (k *Keyboard) Close() { + k.state.Unref() + k.keymap.Unref() + k.ctx.Unref() + k.dev.Close() +} + +func (k *Keyboard) Listen(ctx context.Context, ch chan<- KeyPress) { + go func() { + for { + inputEvent, err := k.dev.ReadOne() + if err != nil { + log.Fatalf("keyboard read failed: %v\n", err) + } + + if inputEvent.Type != evdev.EV_KEY { + continue + } + + if inputEvent.Value == 1 { + keyPress := KeyPress{ + Sym: k.state.GetOneSym(keycode(inputEvent)), + ShortcutMods: k.getShortcutMods(keycode(inputEvent)), + Char: k.state.KeyGetUtf8(keycode(inputEvent)), + } + + log.Printf("keypress: %#v\n", keyPress) + + ch <- keyPress + } + + // This should be called _after_ procesing the key, so that the key + // doesn't influence itself. + if inputEvent.Value == 1 { + k.state.UpdateKey(keycode(inputEvent), xkbcommon.KeyDown) + } else if inputEvent.Value == 0 { + k.state.UpdateKey(keycode(inputEvent), xkbcommon.KeyUp) + } + } + }() +} + +func (k *Keyboard) getShortcutMods(key xkbcommon.Keycode) ModMask { + // This implements the algorithm as described here: + // https://xkbcommon.org/doc/current/group__state.html#consumed-modifiers + + stateMods := k.state.SerializeMods(xkbcommon.StateModsEffective) + consumedMods := k.state.KeyGetConsumedMods(key) + significantMods := xkbcommon.ModMask( + 1< 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 +} diff --git a/faxmachine/matrix/bot.go b/faxmachine/matrix/bot.go new file mode 100644 index 0000000..176958a --- /dev/null +++ b/faxmachine/matrix/bot.go @@ -0,0 +1,535 @@ +package matrix + +import ( + "context" + "fmt" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "log" + "log/slog" + "strings" + "time" + + "github.com/google/shlex" + "github.com/lestrrat-go/dither" + "github.com/nfnt/resize" + _ "golang.org/x/image/webp" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "git.sr.ht/~guacamolie/faxmachine/config" + "git.sr.ht/~guacamolie/faxmachine/escpos" + "git.sr.ht/~guacamolie/faxmachine/printjob" +) + +var faxmachineStatusEventType = event.NewEventType("nl.guacamolie.faxmachine.status") + +type faxmachineStatusContent struct { + Status event.Presence `json:"status"` +} + +var faxmachineConfigEventType = event.NewEventType("nl.guacamolie.faxmachine.config") + +type roomConfig struct { + SendStatusMessages bool `json:"send_status_messages"` + SkipDithering bool `json:"skip_dithering"` + Concise bool `json:"concise"` +} + +func (rc *roomConfig) load(ctx context.Context, r Room) error { + return r.GetAccountData(ctx, faxmachineConfigEventType.Type, rc) +} + +func (rc *roomConfig) save(ctx context.Context, r Room) error { + return r.SetAccountData(ctx, faxmachineConfigEventType.Type, rc) +} + +type Bot struct { + matrix *Matrix + users map[id.UserID]config.MatrixUser + adminRoom Room + logger *slog.Logger +} + +func NewBot(ctx context.Context, conf config.Matrix, logger *slog.Logger) (*Bot, error) { + m, err := NewMatrix(ctx, conf) + if err != nil { + return nil, err + } + + users := map[id.UserID]config.MatrixUser{} + for k, mu := range conf.Users { + users[id.UserID(k)] = mu + } + + var adminRoom Room + if strings.HasPrefix(conf.AdminRoom, "#") { + room, err := m.ResolveAlias(ctx, id.RoomAlias(conf.AdminRoom)) + if err != nil { + return nil, fmt.Errorf("couldn't resolve room alias of admin room: %w", err) + } + adminRoom = room + } else if strings.HasPrefix(conf.AdminRoom, "!") { + adminRoom = m.Room(id.RoomID(conf.AdminRoom)) + } + + b := &Bot{ + matrix: m, + users: users, + adminRoom: adminRoom, + logger: logger, + } + + b.matrix.OnInvite(func(ctx context.Context, i Invite) { + if i.RoomID == b.adminRoom.ID { + if err := i.Accept(ctx); err != nil { + log.Printf("failed to join admin room: %v\n", err) + } + return + } + + if b.isAuthorized(i.Sender) { + if err := i.Accept(ctx); err != nil { + log.Printf("failed to join user room: %v\n", err) + } + return + } + + i.Decline(ctx) + }) + + b.matrix.OnMessage(func(ctx context.Context, msg Message) { + if msg.Room() != b.adminRoom { + return + } + + if msg, ok := msg.(*TextMessage); ok { + if !strings.HasPrefix(msg.Body(), "!") { + return + } + + b.execAdminCommand(ctx, msg) + } + }) + + return b, nil +} + +func (b *Bot) Subscribe(ctx context.Context, queue chan<- printjob.Job) { + b.matrix.OnMessage(func(ctx context.Context, msg Message) { + if msg.Room() == b.adminRoom { + return + } + + msg.SendReceipt(ctx) + + if msg, ok := msg.(*TextMessage); ok { + if strings.HasPrefix(msg.Body(), "📠") { + b.handleCommand(ctx, msg) + return + } + } + + b.logger.Info("received print job", + slog.Group("event", + "id", msg.ID(), + "sender", msg.Sender(), + "room", msg.Room().ID, + ), + ) + + var config roomConfig + config.load(ctx, msg.Room()) + + queue <- &job{msg: msg, config: config} + }) + + go func() { + err := b.matrix.Client.SyncWithContext(ctx) + slog.Error("Matix sync stopped", "error", err) + }() +} + +func (b *Bot) execAdminCommand(ctx context.Context, msg *TextMessage) { + cmd, err := shlex.Split(msg.Body()) + if err != nil { + msg.Room().Send(ctx, fmt.Sprintf("Couldn't parse command: %v", err)) + return + } + + switch cmd[0] { + case "!displayname": + if len(cmd) <= 1 { + msg.Room().Send(ctx, "SYNTAX: !displayname ") + return + } + + if err := b.matrix.Client.SetDisplayName(ctx, cmd[1]); err != nil { + msg.Room().Send(ctx, fmt.Sprintf("Couldn't update display name: %v", err)) + return + } + + msg.Room().Send(ctx, "I have updated my display name") + + case "!avatar": + if len(cmd) <= 1 { + msg.Room().Send(ctx, "SYNTAX: !avatar ") + return + } + + uri, err := id.ParseContentURI(cmd[1]) + if err != nil { + msg.Room().Send(ctx, fmt.Sprintf("Invalid content URI: %v", err)) + return + } + + if err := b.matrix.Client.SetAvatarURL(ctx, uri); err != nil { + msg.Room().Send(ctx, fmt.Sprintf("Failed to set avatar: %v", err)) + return + } + + msg.Room().Send(ctx, "Avatar updated successfully") + + case "!encrypt": + rooms, err := b.matrix.JoinedRooms(ctx) + if err != nil { + msg.Room().Send(ctx, + fmt.Sprintf("Failed to query joined rooms: %v", err)) + } + + for i, room := range rooms { + // Don't trigger rate limits + time.Sleep(100 * time.Millisecond) + + prefix := fmt.Sprintf("[%02d/%02d] %s", i+1, len(rooms), room.ID) + + encrypted, err := room.IsEncrypted(ctx) + if err != nil { + msg.Room().Send(ctx, + fmt.Sprintf("%s: failed to get encryption status: %v", prefix, err)) + continue + } + + if encrypted { + msg.Room().Send(ctx, + fmt.Sprintf("%s: encryption already enabled", prefix)) + continue + } + + if err := room.EnableEncryption(ctx); err != nil { + msg.Room().Send(ctx, + fmt.Sprintf("%s: failed to enable encryption: %v", prefix, err)) + continue + } + } + + default: + msg.Room().Send(ctx, "Unrecognized command") + } +} + +func (b *Bot) handleCommand(ctx context.Context, msg *TextMessage) { + command := strings.TrimPrefix(msg.Body(), "📠") + args := strings.Fields(command) + + if len(args) == 0 { + msg.Reply(ctx, "Hello?") + return + } + + var config roomConfig + config.load(ctx, msg.Room()) + + switch args[0] { + case "ping": + msg.Reply(ctx, "pong!") + + case "send-status": + if len(args) != 2 { + msg.Reply(ctx, "Usage: 📠 send-status ") + return + } + + switch args[1] { + case "on": + config.SendStatusMessages = true + if err := config.save(ctx, msg.Room()); err != nil { + msg.Reply(ctx, fmt.Sprintf("error saving config: %v", err)) + return + } + + msg.Reply(ctx, "I will tell you whenever I lose or gain connection with the printer.") + + case "off": + config.SendStatusMessages = false + if err := config.save(ctx, msg.Room()); err != nil { + msg.Reply(ctx, fmt.Sprintf("error saving config: %v", err)) + return + } + + msg.Reply(ctx, "I won't tell you anymore if I lose or gain connection with the printer.") + + default: + msg.Reply(ctx, "Usage: 📠 send-status ") + } + + case "dithering": + if len(args) != 2 { + msg.Reply(ctx, "Usage: 📠 dithering ") + return + } + + switch args[1] { + case "on": + config.SkipDithering = false + if err := config.save(ctx, msg.Room()); err != nil { + msg.Reply(ctx, fmt.Sprintf("error saving config: %v", err)) + return + } + + msg.Reply(ctx, "I have enabled dithering") + + case "off": + config.SkipDithering = true + if err := config.save(ctx, msg.Room()); err != nil { + msg.Reply(ctx, fmt.Sprintf("error saving config: %v", err)) + return + } + + msg.Reply(ctx, "I have disabled dithering") + + default: + msg.Reply(ctx, "Usage: 📠 dithering ") + } + + case "concise": + if len(args) != 2 { + msg.Reply(ctx, "Usage: 📠 concise ") + return + } + + switch args[1] { + case "on": + config.Concise = true + if err := config.save(ctx, msg.Room()); err != nil { + msg.Reply(ctx, fmt.Sprintf("error saving config: %v", err)) + return + } + + msg.React(ctx, "✅") + + case "off": + config.Concise = false + if err := config.save(ctx, msg.Room()); err != nil { + msg.Reply(ctx, fmt.Sprintf("error saving config: %v", err)) + return + } + + msg.Reply(ctx, "Your command has been received loud and clear!"+ + "I, Faxmachine the great, hereby announce that I shall no longer be concise when formulating my responses.") + + default: + msg.Reply(ctx, "Usage: 📠 concise ") + } + + default: + msg.Reply(ctx, "Unrecognized command") + } +} + +func (b *Bot) isAuthorized(user id.UserID) bool { + _, ok := b.users[user] + return ok +} + +type job struct { + msg Message + config roomConfig + + stopTyping func() +} + +func (j *job) keepTypingUntilDone(ctx context.Context) { + for { + err := j.msg.Room().StartTyping(ctx, 4*time.Second) + if err != nil { + slog.Error("error sending typing start", "error", err) + } + + select { + case <-time.After(2 * time.Second): + continue + case <-ctx.Done(): + ctx := context.WithoutCancel(ctx) + err := j.msg.Room().StopTyping(ctx) + if err != nil { + slog.Error("error sending typing stop", "error", err) + } + return + } + } +} + +func (j *job) Sender() string { + return j.msg.Sender().String() +} + +func (j *job) Time() time.Time { + return j.msg.Time() +} + +func (j *job) Description() string { + switch msg := j.msg.(type) { + case *TextMessage: + return msg.Body() + case *ImageMessage: + if c, ok := msg.Caption(); ok { + return c + } + + return msg.FileName() + default: + return "" + } +} + +func (j *job) Reply(ctx context.Context, msg string) error { + return j.msg.Reply(ctx, msg) +} + +func (j *job) Print(ctx context.Context, p *escpos.Printer) error { + var typingCtx context.Context + typingCtx, j.stopTyping = context.WithCancel(ctx) + + go j.keepTypingUntilDone(typingCtx) + + switch msg := j.msg.(type) { + case *TextMessage: + _, err := fmt.Fprintf(p, "%s\n", msg.Body()) + return err + + case *ImageMessage: + img, err := msg.DownloadImage(ctx) + if err != nil { + return fmt.Errorf("couldn't get image: %w", err) + } + + if !j.config.SkipDithering { + img = resize.Resize(512, 0, img, resize.Lanczos3) + img = dither.Monochrome(dither.FloydSteinberg, img, 1.18) + } + + if err := p.PrintImage(img); err != nil { + return fmt.Errorf("couldn't print image: %w", err) + } + + if caption, ok := msg.Caption(); ok { + _, err := fmt.Fprintf(p, "\n%s\n", caption) + if err != nil { + return fmt.Errorf("couldn't print image caption: %w", err) + } + } + + return nil + + default: + return fmt.Errorf("unsupported message type") + } +} + +func (j *job) OnPrinted(ctx context.Context) { + defer j.stopTyping() + + var err error + + if j.config.Concise { + err = j.msg.React(ctx, "✅") + } else { + switch msg := j.msg.(type) { + case *TextMessage: + err = msg.Reply(ctx, "I have printed these words") + + case *ImageMessage: + if j.config.SkipDithering { + err = msg.Reply(ctx, "I have printed this image without dithering") + } else { + err = msg.Reply(ctx, "I have printed this image") + } + } + } + + if err != nil { + slog.Error("failed to reply", "err", err) + } +} + +func (j *job) OnShredded(ctx context.Context, err error) { + defer j.stopTyping() + + reply := fmt.Sprintf("Print failed: %v", err) + if err := j.msg.Reply(ctx, reply); err != nil { + slog.Error("failed to reply", "err", err) + } +} + +func (b *Bot) BecomeOnline(ctx context.Context) { + b.matrix.SetPresence(ctx, event.PresenceOnline) + + rooms, err := b.matrix.JoinedRooms(ctx) + if err != nil { + log.Printf("failed to query joined rooms: %v", err) + } + for _, room := range rooms { + b.sendStatus(ctx, room, event.PresenceOnline) + } +} + +func (b *Bot) BecomeUnavailable(ctx context.Context) { + rooms, err := b.matrix.JoinedRooms(ctx) + if err != nil { + log.Printf("failed to query joined rooms: %v", err) + } + for _, room := range rooms { + b.sendStatus(ctx, room, event.PresenceUnavailable) + } + + b.matrix.SetPresence(ctx, event.PresenceUnavailable) +} + +func (b *Bot) sendStatus(ctx context.Context, + room Room, status event.Presence) error { + + var config roomConfig + config.load(ctx, room) + + if !config.SendStatusMessages { + return nil + } + + var roomStatus faxmachineStatusContent + if err := room.GetAccountData(ctx, faxmachineStatusEventType.Type, &roomStatus); err != nil { + roomStatus.Status = event.PresenceOffline + } + + if roomStatus.Status == status { + return nil + } + + b.logger.Debug("sending status message", "status", status, "room", room) + + roomStatus.Status = status + if err := room.SetAccountData(ctx, faxmachineStatusEventType.Type, &roomStatus); err != nil { + return fmt.Errorf("failed to set room status: %w", err) + } + + if err := room.SendEmote(ctx, fmt.Sprintf("is now %s", status)); err != nil { + return fmt.Errorf("failed to send emote: %w", err) + } + + return nil +} + +func (b *Bot) sendInAdminRoom(ctx context.Context, msg string) error { + return b.adminRoom.Send(ctx, msg) +} diff --git a/faxmachine/matrix/matrix.go b/faxmachine/matrix/matrix.go new file mode 100644 index 0000000..ae75d74 --- /dev/null +++ b/faxmachine/matrix/matrix.go @@ -0,0 +1,422 @@ +package matrix + +import ( + "context" + "encoding/json" + "fmt" + "image" + "io" + "log" + "os" + "time" + + "git.sr.ht/~guacamolie/faxmachine/config" + _ "github.com/mattn/go-sqlite3" + "github.com/rs/zerolog" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/cryptohelper" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var storeEventType = event.NewEventType("nl.guacamolie.faxmachine.store") + +type Matrix struct { + Client *mautrix.Client + inviteHandlers []InviteHandler + messageHandlers []MessageHandler +} + +func NewMatrix(ctx context.Context, conf config.Matrix) (*Matrix, error) { + me := id.UserID(conf.UserID) + client, err := mautrix.NewClient(me.Homeserver(), me, conf.AccessToken) + if err != nil { + return nil, fmt.Errorf("failed to initalize client") + } + + if conf.MautrixLog != "" { + out, err := os.OpenFile(conf.MautrixLog, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666) + if err != nil { + return nil, fmt.Errorf("failed to mautrix log file %q: %w", conf.MautrixLog, err) + } + + log := zerolog.New(zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { + w.Out = out + w.TimeFormat = time.RFC3339 + })).With().Timestamp().Logger() + + client.Log = log + } + + client.DeviceID = id.DeviceID(conf.DeviceID) + + m := &Matrix{ + Client: client, + } + + syncer := mautrix.NewDefaultSyncer() + + // Prevent infinite loop of store events + syncer.FilterJSON = &mautrix.Filter{ + AccountData: mautrix.FilterPart{ + Limit: 20, + NotTypes: []event.Type{storeEventType}, + }, + } + + syncer.OnEventType(event.StateMember, m.handleStateMember) + syncer.OnEventType(event.EventMessage, m.handleMessage) + + syncer.OnEvent(func(ctx context.Context, evt *event.Event) { + if evt.Mautrix.EventSource == event.SourcePresence { + // Not interesting enough to log + return + } + + pretty, err := json.MarshalIndent(evt, "", "\t") + if err != nil { + log.Printf("failed to marshal: %v\n", err) + } + log.Printf("OnEvent %s %s\n", evt.Mautrix.EventSource, pretty) + }) + + client.Syncer = syncer + + store := mautrix.NewAccountDataStore(storeEventType.Type, client) + client.Store = store + + if len(conf.FeelgoodKey) > 0 && conf.Database != "" { + cryptoHelper, err := cryptohelper.NewCryptoHelper(m.Client, []byte(conf.FeelgoodKey), conf.Database) + if err != nil { + return nil, fmt.Errorf("failed to create crypto helper: %w", err) + } + + if err := cryptoHelper.Init(ctx); err != nil { + return nil, fmt.Errorf("failed to initalize crypto helper: %w", err) + } + + m.Client.Crypto = cryptoHelper + } + + return m, nil +} + +func (m *Matrix) JoinedRooms(ctx context.Context) ([]Room, error) { + resp, err := m.Client.JoinedRooms(ctx) + if err != nil { + return nil, err + } + + rooms := []Room{} + for _, roomID := range resp.JoinedRooms { + rooms = append(rooms, m.Room(roomID)) + } + + return rooms, nil +} + +func (m *Matrix) SetPresence(ctx context.Context, status event.Presence) { + m.Client.SyncPresence = event.PresenceOnline + m.Client.SetPresence(ctx, event.PresenceOnline) +} + +func (m *Matrix) Room(id id.RoomID) Room { + return Room{ + parent: m, + ID: id, + } +} + +func (m *Matrix) ResolveAlias(ctx context.Context, alias id.RoomAlias) (Room, error) { + resp, err := m.Client.ResolveAlias(ctx, alias) + if err != nil { + return Room{}, err + } + return m.Room(resp.RoomID), nil +} + +type Invite struct { + parent *Matrix + Sender id.UserID + RoomID id.RoomID +} + +func (i *Invite) Accept(ctx context.Context) error { + _, err := i.parent.Client.JoinRoomByID(ctx, i.RoomID) + return err +} + +func (i *Invite) Decline(ctx context.Context) error { + _, err := i.parent.Client.LeaveRoom(ctx, i.RoomID) + return err +} + +type InviteHandler func(ctx context.Context, i Invite) + +func (m *Matrix) OnInvite(handler InviteHandler) { + m.inviteHandlers = append(m.inviteHandlers, handler) +} + +func (m *Matrix) handleStateMember(ctx context.Context, evt *event.Event) { + if evt.Mautrix.EventSource&(event.SourceInvite|event.SourceState) == 0 { + return + } + if evt.GetStateKey() != m.Client.UserID.String() { + return + } + content := evt.Content.AsMember() + if content.Membership != event.MembershipInvite { + return + } + + invite := Invite{ + parent: m, + Sender: evt.Sender, + RoomID: evt.RoomID, + } + + for _, handler := range m.inviteHandlers { + handler(ctx, invite) + } +} + +type MessageHandler func(ctx context.Context, m Message) + +func (m *Matrix) OnMessage(handler MessageHandler) { + m.messageHandlers = append(m.messageHandlers, handler) +} + +func (m *Matrix) handleMessage(ctx context.Context, evt *event.Event) { + if evt.Sender == m.Client.UserID { + return + } + + baseMessage := message{ + parent: m, + evt: evt, + } + + var message Message = &baseMessage + + switch evt.Content.AsMessage().MsgType { + case event.MsgText: + message = &TextMessage{baseMessage} + case event.MsgImage: + message = &ImageMessage{baseMessage} + } + + for _, handler := range m.messageHandlers { + handler(ctx, message) + } +} + +type Message interface { + ID() id.EventID + Time() time.Time + Sender() id.UserID + Room() Room + SendReceipt(ctx context.Context) error + Reply(ctx context.Context, reply string) error + React(ctx context.Context, reaction string) error +} + +type message struct { + parent *Matrix + evt *event.Event +} + +func (m *message) ID() id.EventID { + return m.evt.ID +} + +func (m *message) Time() time.Time { + return time.UnixMilli(m.evt.Timestamp) +} + +func (m *message) Sender() id.UserID { + return m.evt.Sender +} + +func (m *message) Room() Room { + return m.parent.Room(m.evt.RoomID) +} + +func (m *message) SendReceipt(ctx context.Context) error { + return m.parent.Client. + SendReceipt(ctx, m.evt.RoomID, m.evt.ID, event.ReceiptTypeRead, nil) +} + +func (m *message) React(ctx context.Context, reaction string) error { + _, err := m.parent.Client.SendReaction(ctx, m.evt.RoomID, m.evt.ID, reaction) + return err +} + +func (m *message) Reply(ctx context.Context, reply string) error { + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: reply, + } + + // mautrix-go currently does not generate the correct fallback for m.file + // and related events: + // + // https://spec.matrix.org/v1.8/client-server-api/#fallback-for-mimage-mvideo-maudio-and-mfile + // + // TODO: consider sending a patch to support this. + content.SetReply(m.evt) + + _, err := m.parent.Client.SendMessageEvent(ctx, m.evt.RoomID, event.EventMessage, content) + return err +} + +type TextMessage struct { + message +} + +func (tm *TextMessage) Body() string { + msg := tm.evt.Content.AsMessage() + if msg.MsgType != event.MsgText { + panic(fmt.Sprintf("expected msgtype of m.text, got %s", msg.MsgType)) + } + + return msg.Body +} + +type ImageMessage struct { + message +} + +func (im *ImageMessage) DownloadImage(ctx context.Context) (image.Image, error) { + msg := im.evt.Content.AsMessage() + if msg.MsgType != event.MsgImage { + panic(fmt.Sprintf("expected msgtype of m.image, got %s", msg.MsgType)) + } + + var imageReader io.ReadCloser + + if msg.File != nil { + if err := msg.File.PrepareForDecryption(); err != nil { + return nil, fmt.Errorf("unable to decrypt: %w", err) + } + + imageURI, err := msg.File.URL.Parse() + if err != nil { + return nil, fmt.Errorf("invalid URI: %v", err) + } + + reader, err := im.parent.Client.Download(ctx, imageURI) + if err != nil { + return nil, fmt.Errorf("download failed: %w", err) + } + + imageReader = msg.File.DecryptStream(reader) + } else { + imageURI, err := msg.URL.Parse() + if err != nil { + return nil, fmt.Errorf("invalid URI: %v", err) + } + + reader, err := im.parent.Client.Download(ctx, imageURI) + if err != nil { + return nil, fmt.Errorf("download failed: %w", err) + } + + imageReader = reader + } + + img, _, err := image.Decode(imageReader) + if err != nil { + return nil, fmt.Errorf("decode failed: %w", err) + } + + return img, nil +} + +func (im *ImageMessage) Caption() (string, bool) { + msg := im.evt.Content.AsMessage() + if msg.MsgType != event.MsgImage { + panic(fmt.Sprintf("expected msgtype of m.image, got %s", msg.MsgType)) + } + + // "If the filename is present, and its value is different than body, then + // body is considered to be a caption, otherwise body is a filename. format + // and formatted_body are only used for captions." + // + // https://spec.matrix.org/v1.10/client-server-api/#media-captions + if msg.FileName != "" && msg.FileName != msg.Body { + return msg.Body, true + } else { + return "", false + } +} + +func (im *ImageMessage) FileName() string { + msg := im.evt.Content.AsMessage() + if msg.MsgType != event.MsgImage { + panic(fmt.Sprintf("expected msgtype of m.image, got %s", msg.MsgType)) + } + + filename := msg.FileName + if filename == "" { + filename = msg.Body + } + + return filename +} + +type Room struct { + parent *Matrix + ID id.RoomID +} + +func (r Room) Send(ctx context.Context, msg string) error { + return r.sendMessageEvent(ctx, &event.MessageEventContent{ + MsgType: event.MsgText, + Body: msg, + }) +} + +func (r Room) SendEmote(ctx context.Context, msg string) error { + return r.sendMessageEvent(ctx, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: msg, + }) +} + +func (r Room) sendMessageEvent(ctx context.Context, evt *event.MessageEventContent) error { + _, err := r.parent.Client.SendMessageEvent(ctx, r.ID, event.EventMessage, evt) + return err +} + +func (r Room) GetAccountData(ctx context.Context, name string, output any) error { + return r.parent.Client.GetRoomAccountData(ctx, r.ID, name, output) +} + +func (r Room) SetAccountData(ctx context.Context, name string, data any) error { + return r.parent.Client.SetRoomAccountData(ctx, r.ID, name, data) +} + +func (r Room) IsEncrypted(ctx context.Context) (bool, error) { + return r.parent.Client.StateStore.IsEncrypted(ctx, r.ID) +} + +func (r Room) EnableEncryption(ctx context.Context) error { + _, err := r.parent.Client.SendStateEvent(ctx, r.ID, + event.StateEncryption, "", event.EncryptionEventContent{ + Algorithm: "m.megolm.v1.aes-sha2", + RotationPeriodMillis: 604800000, + RotationPeriodMessages: 100, + }) + + return err +} + +func (r Room) StartTyping(ctx context.Context, timeout time.Duration) error { + _, err := r.parent.Client.UserTyping(ctx, r.ID, true, timeout) + return err +} + +func (r Room) StopTyping(ctx context.Context) error { + _, err := r.parent.Client.UserTyping(ctx, r.ID, false, 0) + return err +} diff --git a/faxmachine/matrix/slog.go b/faxmachine/matrix/slog.go new file mode 100644 index 0000000..9af6df8 --- /dev/null +++ b/faxmachine/matrix/slog.go @@ -0,0 +1,47 @@ +package matrix + +import ( + "context" + "fmt" + "log/slog" + "os" + "sync/atomic" +) + +type SlogHandler struct { + slog.Handler + + writer slogWriter +} + +func NewSlogHandler(opts *slog.HandlerOptions) *SlogHandler { + handler := SlogHandler{} + handler.Handler = slog.NewTextHandler(&handler.writer, opts) + return &handler +} + +func (sh *SlogHandler) Attach(bot *Bot) { + sh.writer.matrix.Store(bot) +} + +type slogWriter struct { + matrix atomic.Pointer[Bot] +} + +func (sw *slogWriter) Write(p []byte) (n int, err error) { + matrixInstance := sw.matrix.Load() + if matrixInstance == nil { + // TODO: consider added a queue that we can drain when we get attached + // to a Matrix instance. + return + } + // Spawn a goroutine as not to make the code that initiated the log wait on us going over the network. + go func() { + if err := matrixInstance.sendInAdminRoom(context.TODO(), string(p)); err != nil { + // Make sure not to use any logging method here that might feed back + // into this function! + fmt.Fprintf(os.Stderr, "failed to log message to admin room: %v\n", err) + } + }() + return len(p), nil +} diff --git a/faxmachine/ntfy/ntfy.go b/faxmachine/ntfy/ntfy.go new file mode 100644 index 0000000..5e2575f --- /dev/null +++ b/faxmachine/ntfy/ntfy.go @@ -0,0 +1,188 @@ +package ntfy + +import ( + "context" + "encoding/json" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "log" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/lestrrat-go/dither" + "github.com/nfnt/resize" + _ "golang.org/x/image/webp" + "nhooyr.io/websocket" + + "git.sr.ht/~guacamolie/faxmachine/config" + "git.sr.ht/~guacamolie/faxmachine/escpos" + "git.sr.ht/~guacamolie/faxmachine/printjob" +) + +type Instance struct { + logger *slog.Logger + host string + accessToken string + topics map[string]config.Topic +} + +func NewInstance(conf config.Ntfy, logger *slog.Logger) *Instance { + return &Instance{ + host: conf.Host, + accessToken: conf.AccessToken, + topics: conf.Topics, + logger: logger, + } +} + +type message struct { + Event string `json:"event"` + Topic string `json:"topic"` + Message string `json:"message"` + Time int64 `json:"time"` + Attachment attachment `json:"attachment"` +} + +type attachment struct { + Name string `json:"name"` + Type string `json:"type"` + Size int `json:"size"` + URL string `json:"url"` +} + +func (i *Instance) Subscribe(ctx context.Context, queue chan<- printjob.Job) error { + topicNames := make([]string, 0, len(i.topics)) + for k := range i.topics { + topicNames = append(topicNames, k) + } + uri := fmt.Sprintf("wss://%s/%s/ws", i.host, strings.Join(topicNames, ",")) + + header := make(http.Header) + if i.accessToken != "" { + header.Set("Authorization", fmt.Sprintf("Bearer %s", i.accessToken)) + } + + conn, _, err := websocket.Dial(ctx, uri, &websocket.DialOptions{ + HTTPHeader: header, + }) + if err != nil { + return fmt.Errorf("couldn't connect to instance: %v", err) + } + + go i.drain(ctx, conn, queue) + + return nil +} + +func (i *Instance) drain(ctx context.Context, conn *websocket.Conn, queue chan<- printjob.Job) { + for { + _, data, err := conn.Read(ctx) + if err != nil { + log.Printf("failed to read: %v\n", err) + return + } + + var msg message + if err := json.Unmarshal(data, &msg); err != nil { + log.Printf("failed to unmarshal: %v\n", err) + return + } + + if msg.Event != "message" { + continue + } + + sender := i.topics[msg.Topic].Name + + i.logger.Info("received print job", + slog.Group("topic", + slog.String("topic", msg.Topic), + slog.String("name", sender), + ), + slog.Group("message", + slog.String("message", msg.Message), + slog.String("attachment", msg.Attachment.URL), + ), + ) + + select { + case queue <- &job{sender, msg}: + default: + } + } +} + +type job struct { + sender string + msg message +} + +func (j *job) Sender() string { + return j.msg.Topic +} + +func (j *job) Time() time.Time { + return time.Unix(j.msg.Time, 0) +} + +func (j *job) Description() string { + switch { + case j.msg.Message != "": + return j.msg.Message + case j.msg.Attachment.Name != "": + return j.msg.Attachment.Name + default: + return "" + } +} + +func (j *job) Print(ctx context.Context, p *escpos.Printer) error { + text := j.msg.Message + + if len(text) > 2000 { + text = text[:2000] + } + + if strings.HasPrefix(text, "You received a file: ") { + text = "" + } + + text, _ = strings.CutPrefix(text, "[guac::prefix]") + + if j.msg.Attachment.URL != "" { + resp, err := http.Get(j.msg.Attachment.URL) + if err != nil { + return fmt.Errorf("failed to get attachment: %w", err) + } + + img, _, err := image.Decode(resp.Body) + if err != nil { + return fmt.Errorf("failed to decode image: %w", err) + } + img = resize.Resize(512, 0, img, resize.Lanczos3) + img = dither.Monochrome(dither.FloydSteinberg, img, 1.18) + + if err := p.PrintImage(img); err != nil { + return fmt.Errorf("failed to print image: %w", err) + } + } + + if text != "" { + fmt.Fprintf(p, "\n%s\n", text) + } + + return nil +} + +func (j *job) OnPrinted(ctx context.Context) { + // Nothing to do since we can't reply to the job submitter. +} + +func (j *job) OnShredded(ctx context.Context, err error) { + // Nothing to do since we can't reply to the job submitter. +} diff --git a/faxmachine/printer/printer.go b/faxmachine/printer/printer.go new file mode 100644 index 0000000..0bc5796 --- /dev/null +++ b/faxmachine/printer/printer.go @@ -0,0 +1,38 @@ +package printer + +import ( + "fmt" + "io" + "os" + "os/exec" +) + +type Printer struct { + devicePath string +} + +func New(devicePath string) *Printer { + return &Printer{ + devicePath: devicePath, + } +} + +func (p *Printer) PrintText(text string) error { + lp, err := os.OpenFile(p.devicePath, os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("failed to open: %v", err) + } + defer lp.Close() + + cmd := exec.Command("./printtext", text) + cmd.Stdout = lp + + return cmd.Run() +} + +func (p *Printer) PrintImage(data io.Reader, caption string) error { + cmd := exec.Command("./printimg", p.devicePath, caption) + cmd.Stdin = data + + return cmd.Run() +} diff --git a/faxmachine/printjob/job.go b/faxmachine/printjob/job.go new file mode 100644 index 0000000..69a5e9c --- /dev/null +++ b/faxmachine/printjob/job.go @@ -0,0 +1,22 @@ +package printjob + +import ( + "context" + "time" + + "git.sr.ht/~guacamolie/faxmachine/escpos" +) + +type Job interface { + Sender() string + Time() time.Time + Description() string + Print(ctx context.Context, p *escpos.Printer) error + OnPrinted(ctx context.Context) + OnShredded(ctx context.Context, err error) +} + +type JobReplyer interface { + Job + Reply(ctx context.Context, msg string) error +} diff --git a/faxmachine/slog_multi.go b/faxmachine/slog_multi.go new file mode 100644 index 0000000..1eb7067 --- /dev/null +++ b/faxmachine/slog_multi.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "slices" +) + +// slogMultiHandler is a simple slog.Handler that passes slog.Records on to +// every inner slog.Handler of the slice. +type slogMultiHandler []slog.Handler + +func (smh slogMultiHandler) Enabled(ctx context.Context, level slog.Level) bool { + for _, h := range smh { + if h.Enabled(ctx, level) { + return true + } + } + return false +} + +func (smh slogMultiHandler) Handle(ctx context.Context, record slog.Record) error { + retValues := make([]error, len(smh)) + + for i, h := range smh { + if !h.Enabled(ctx, record.Level) { + continue + } + retValues[i] = h.Handle(ctx, record) + } + + return errors.Join(retValues...) +} + +func (smh slogMultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + newHandlers := make([]slog.Handler, len(smh)) + for i, h := range smh { + // From slog.Handler.WithAttrs godoc: + // "The Handler owns the slice: it may retain, modify or discard it." + // Clone the slice so that modifications don't impact other slog.Handlers. + newHandlers[i] = h.WithAttrs(slices.Clone(attrs)) + } + return slogMultiHandler(newHandlers) +} + +func (smh slogMultiHandler) WithGroup(name string) slog.Handler { + newHandlers := make([]slog.Handler, len(smh)) + for i, h := range smh { + newHandlers[i] = h.WithGroup(name) + } + return slogMultiHandler(newHandlers) +} diff --git a/faxmachine/xkbcommon/xkbcommon.go b/faxmachine/xkbcommon/xkbcommon.go new file mode 100644 index 0000000..0bf2bf0 --- /dev/null +++ b/faxmachine/xkbcommon/xkbcommon.go @@ -0,0 +1,617 @@ +// xkbcommon contains Go bindings for the libxkbcommon C library. All the doc +// comments here are 1-to-1 copies of the C doc comments, and may refer to +// C-specific behavior that is abstracted away by the bindings. +package xkbcommon + +import ( + "errors" + "unsafe" +) + +/* +#cgo pkg-config: xkbcommon +#include +#include +*/ +import "C" + +// @struct xkb_context +// Opaque top level library context object. +// +// The context contains various general library data and state, like +// logging level and include paths. +// +// Objects are created in a specific context, and multiple contexts may +// coexist simultaneously. Objects from different contexts are completely +// separated and do not share any memory or state. +type Context C.struct_xkb_context + +func (c *Context) value() *C.struct_xkb_context { + return (*C.struct_xkb_context)(c) +} + +// Opaque compiled keymap object. +// +// The keymap object holds all of the static keyboard information obtained +// from compiling XKB files. +// +// A keymap is immutable after it is created (besides reference counts, etc.); +// if you need to change it, you must create a new one. +type Keymap C.struct_xkb_keymap + +func (k *Keymap) value() *C.struct_xkb_keymap { + return (*C.struct_xkb_keymap)(k) +} + +// @struct xkb_state +// Opaque keyboard state object. +// +// State objects contain the active state of a keyboard (or keyboards), such +// as the currently effective layout and the active modifiers. It acts as a +// simple state machine, wherein key presses and releases are the input, and +// key symbols (keysyms) are the output. +type State C.struct_xkb_state + +func (s *State) value() *C.struct_xkb_state { + return (*C.struct_xkb_state)(s) +} + +// A number used to represent a physical key on a keyboard. +// +// A standard PC-compatible keyboard might have 102 keys. An appropriate +// keymap would assign each of them a keycode, by which the user should +// refer to the key throughout the library. +// +// Historically, the X11 protocol, and consequentially the XKB protocol, +// assign only 8 bits for keycodes. This limits the number of different +// keys that can be used simultaneously in a single keymap to 256 +// (disregarding other limitations). This library does not share this limit; +// keycodes beyond 255 ('extended keycodes') are not treated specially. +// Keymaps and applications which are compatible with X11 should not use +// these keycodes. +// +// The values of specific keycodes are determined by the keymap and the +// underlying input system. For example, with an X11-compatible keymap +// and Linux evdev scan codes (see linux/input.h), a fixed offset is used: +// +// The keymap defines a canonical name for each key, plus possible aliases. +// Historically, the XKB protocol restricts these names to at most 4 (ASCII) +// characters, but this library does not share this limit. +// +// @code +// xkb_keycode_t keycode_A = KEY_A + 8; +// @endcode +// +// @sa xkb_keycode_is_legal_ext() xkb_keycode_is_legal_x11() +type Keycode C.xkb_keycode_t + +// A number used to represent the symbols generated from a key on a keyboard. +// +// A key, represented by a keycode, may generate different symbols according +// to keyboard state. For example, on a QWERTY keyboard, pressing the key +// labled \ generates the symbol ‘a’. If the Shift key is held, it +// generates the symbol ‘A’. If a different layout is used, say Greek, +// it generates the symbol ‘α’. And so on. +// +// Each such symbol is represented by a *keysym* (short for “key symbol”). +// Note that keysyms are somewhat more general, in that they can also represent +// some “function”, such as “Left” or “Right” for the arrow keys. For more +// information, see: Appendix A [“KEYSYM Encoding”][encoding] of the X Window +// System Protocol. +// +// Specifically named keysyms can be found in the +// xkbcommon/xkbcommon-keysyms.h header file. Their name does not include +// the `XKB_KEY_` prefix. +// +// Besides those, any Unicode/ISO 10646 character in the range U+0100 to +// U+10FFFF can be represented by a keysym value in the range 0x01000100 to +// 0x0110FFFF. The name of Unicode keysyms is `U`, e.g. `UA1B2`. +// +// The name of other unnamed keysyms is the hexadecimal representation of +// their value, e.g. `0xabcd1234`. +// +// Keysym names are case-sensitive. +// +// @note **Encoding:** Keysyms are 32-bit integers with the 3 most significant +// bits always set to zero. See: Appendix A [“KEYSYM Encoding”][encoding] of +// the X Window System Protocol. +// +// @ingroup keysyms +// @sa XKB_KEYSYM_MAX +// +// [encoding]: https://www.x.org/releases/current/doc/xproto/x11protocol.html#keysym_encoding +type Keysym C.xkb_keysym_t + +// Index of a modifier. +// +// A @e modifier is a state component which changes the way keys are +// interpreted. A keymap defines a set of modifiers, such as Alt, Shift, +// Num Lock or Meta, and specifies which keys may @e activate which +// modifiers (in a many-to-many relationship, i.e. a key can activate +// several modifiers, and a modifier may be activated by several keys. +// Different keymaps do this differently). +// +// When retrieving the keysyms for a key, the active modifier set is +// consulted; this detemines the correct shift level to use within the +// currently active layout (see xkb_level_index_t). +// +// Modifier indices are consecutive. The first modifier has index 0. +// +// Each modifier must have a name, and the names are unique. Therefore, it +// is safe to use the name as a unique identifier for a modifier. The names +// of some common modifiers are provided in the xkbcommon/xkbcommon-names.h +// header file. Modifier names are case-sensitive. +// +// @sa xkb_keymap_num_mods() +type ModIndex C.xkb_mod_index_t + +// A mask of modifier indices. +type ModMask C.xkb_mod_mask_t + +var ModInvalid ModIndex = C.XKB_MOD_INVALID + +// Names to compile a keymap with, also known as RMLVO. +// +// The names are the common configuration values by which a user picks +// a keymap. +// +// If the entire struct is NULL, then each field is taken to be NULL. +// You should prefer passing NULL instead of choosing your own defaults. +type RuleNames struct { + // The rules file to use. The rules file describes how to interpret + // the values of the model, layout, variant and options fields. + // + // If NULL or the empty string "", a default value is used. + // If the XKB_DEFAULT_RULES environment variable is set, it is used + // as the default. Otherwise the system default is used. + Rules string + + // The keyboard model by which to interpret keycodes and LEDs. + // + // If NULL or the empty string "", a default value is used. + // If the XKB_DEFAULT_MODEL environment variable is set, it is used + // as the default. Otherwise the system default is used. + Model string + + // A comma separated list of layouts (languages) to include in the + // keymap. + // + // If NULL or the empty string "", a default value is used. + // If the XKB_DEFAULT_LAYOUT environment variable is set, it is used + // as the default. Otherwise the system default is used. + Layout string + + // A comma separated list of variants, one per layout, which may + // modify or augment the respective layout in various ways. + // + // Generally, should either be empty or have the same number of values + // as the number of layouts. You may use empty values as in "intl,,neo". + // + // If NULL or the empty string "", and a default value is also used + // for the layout, a default value is used. Otherwise no variant is + // used. + // If the XKB_DEFAULT_VARIANT environment variable is set, it is used + // as the default. Otherwise the system default is used. + Variant string + + // A comma separated list of options, through which the user specifies + // non-layout related preferences, like which key combinations are used + // for switching layouts, or which key is the Compose key. + // + // If NULL, a default value is used. If the empty string "", no + // options are used. + // If the XKB_DEFAULT_OPTIONS environment variable is set, it is used + // as the default. Otherwise the system default is used. + Options string +} + +func (rn *RuleNames) value() *C.struct_xkb_rule_names { + if rn == nil { + return nil + } + + var cStruct C.struct_xkb_rule_names + if rn.Rules != "" { + cStruct.rules = C.CString(rn.Rules) + } + if rn.Model != "" { + cStruct.model = C.CString(rn.Model) + } + if rn.Layout != "" { + cStruct.layout = C.CString(rn.Layout) + } + if rn.Variant != "" { + cStruct.variant = C.CString(rn.Variant) + } + if rn.Options != "" { + cStruct.options = C.CString(rn.Options) + } + return &cStruct +} + +func (rn *RuleNames) free(cStruct *C.struct_xkb_rule_names) { + if cStruct != nil { + C.free(unsafe.Pointer(cStruct.rules)) + C.free(unsafe.Pointer(cStruct.model)) + C.free(unsafe.Pointer(cStruct.layout)) + C.free(unsafe.Pointer(cStruct.variant)) + C.free(unsafe.Pointer(cStruct.options)) + } +} + +// Get the name of a keysym. +// +// For a description of how keysyms are named, see @ref xkb_keysym_t. +// +// @param[in] keysym The keysym. +// @param[out] buffer A string buffer to write the name into. +// @param[in] size Size of the buffer. +// +// @warning If the buffer passed is too small, the string is truncated +// (though still NUL-terminated); a size of at least 64 bytes is recommended. +// +// @returns The number of bytes in the name, excluding the NUL byte. If +// the keysym is invalid, returns -1. +// +// You may check if truncation has occurred by comparing the return value +// with the length of buffer, similarly to the snprintf(3) function. +// +// @sa xkb_keysym_t +func (k Keysym) Name() (string, bool) { + size := C.xkb_keysym_get_name(C.xkb_keysym_t(k), nil, 0) + if size < 0 { + return "", false + } + + buffer := (*C.char)(C.malloc(C.size_t(size + 1))) + defer C.free((unsafe.Pointer)(buffer)) + C.xkb_keysym_get_name(C.xkb_keysym_t(k), buffer, C.size_t(size+1)) + return C.GoString(buffer), true +} + +// Flags for context creation. +type ContextFlags C.enum_xkb_context_flags + +var ( + // Do not apply any context flags. + ContextNoFlags ContextFlags = C.XKB_CONTEXT_NO_FLAGS + + // Create this context with an empty include path. + ContextNoDefaultIncludes ContextFlags = C.XKB_CONTEXT_NO_DEFAULT_INCLUDES + + // Don't take RMLVO names from the environment + // + // @since 0.3.0 + ContextNoEnvironmentNames ContextFlags = C.XKB_CONTEXT_NO_ENVIRONMENT_NAMES + + // Disable the use of secure_getenv for this context, so that privileged + // processes can use environment variables. Client uses at their own risk. + // + // @since 1.5.0 + ContextNoSecureGetenv ContextFlags = C.XKB_CONTEXT_NO_SECURE_GETENV +) + +// Create a new context. +// +// @param flags Optional flags for the context, or 0. +// +// @returns A new context, or NULL on failure. +// +// @memberof xkb_context +func NewContext(flags ContextFlags) (*Context, error) { + ctx := C.xkb_context_new(C.enum_xkb_context_flags(flags)) + if ctx == nil { + return nil, errors.New("failed to create context") + } + return (*Context)(ctx), nil +} + +// Release a reference on a context, and possibly free it. +// +// @param context The context. If it is NULL, this function does nothing. +// +// @memberof xkb_context +func (c *Context) Unref() { + C.xkb_context_unref(c.value()) +} + +// Flags for keymap compilation. +type KeymapCompileFlags C.enum_xkb_keymap_compile_flags + +var ( + // Do not apply any flags. + KeymapCompileFlagsNoFlags KeymapCompileFlags = C.XKB_KEYMAP_COMPILE_NO_FLAGS +) + +// Create a keymap from RMLVO names. +// +// The primary keymap entry point: creates a new XKB keymap from a set of +// RMLVO (Rules + Model + Layouts + Variants + Options) names. +// +// @param context The context in which to create the keymap. +// @param names The RMLVO names to use. See xkb_rule_names. +// @param flags Optional flags for the keymap, or 0. +// +// @returns A keymap compiled according to the RMLVO names, or NULL if +// the compilation failed. +// +// @sa xkb_rule_names +// @memberof xkb_keymap +func NewKeymapFromNames(ctx *Context, names *RuleNames, + flags KeymapCompileFlags) (*Keymap, error) { + + cNames := names.value() + defer names.free(cNames) + + keymap := C.xkb_keymap_new_from_names(ctx.value(), cNames, + C.enum_xkb_keymap_compile_flags(flags)) + if keymap == nil { + return nil, errors.New("failed to create keymap") + } + return (*Keymap)(keymap), nil +} + +// Release a reference on a keymap, and possibly free it. +// +// @param keymap The keymap. If it is NULL, this function does nothing. +// +// @memberof xkb_keymap +func (k *Keymap) Unref() { + C.xkb_keymap_unref(k.value()) +} + +// Get the index of a modifier by name. +// +// @returns The index. If no modifier with this name exists, returns +// XKB_MOD_INVALID. +// +// @sa xkb_mod_index_t +// @memberof xkb_keymap +func (k *Keymap) ModGetIndex(name string) ModIndex { + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + return ModIndex(C.xkb_keymap_mod_get_index(k.value(), cName)) +} + +// Create a new keyboard state object. +// +// @param keymap The keymap which the state will use. +// +// @returns A new keyboard state object, or NULL on failure. +// +// @memberof xkb_state +func NewState(keymap *Keymap) (*State, error) { + state := C.xkb_state_new(keymap.value()) + if state == nil { + return nil, errors.New("failed to create state") + } + return (*State)(state), nil +} + +func (s *State) Unref() { + C.xkb_state_unref(s.value()) +} + +// Specifies the direction of the key (press / release). +type KeyDirection C.enum_xkb_key_direction + +var ( + // The key was released. + KeyUp KeyDirection = C.XKB_KEY_UP + + // The key was pressed. + KeyDown KeyDirection = C.XKB_KEY_DOWN +) + +// Modifier and layout types for state objects. This enum is bitmaskable, +// e.g. (XKB_STATE_MODS_DEPRESSED | XKB_STATE_MODS_LATCHED) is valid to +// exclude locked modifiers. +// +// In XKB, the DEPRESSED components are also known as 'base'. +type StateComponent C.enum_xkb_state_component + +var ( + // Depressed modifiers, i.e. a key is physically holding them. + StateModsDepressed StateComponent = C.XKB_STATE_MODS_DEPRESSED + + // Latched modifiers, i.e. will be unset after the next non-modifier + // key press. + StateModsLatched StateComponent = C.XKB_STATE_MODS_LATCHED + + // Locked modifiers, i.e. will be unset after the key provoking the + // lock has been pressed again. + StateModsLocked StateComponent = C.XKB_STATE_MODS_LOCKED + + // Effective modifiers, i.e. currently active and affect key + // processing (derived from the other state components). + // Use this unless you explicitly care how the state came about. + StateModsEffective StateComponent = C.XKB_STATE_MODS_EFFECTIVE + + // Depressed layout, i.e. a key is physically holding it. + StateLayoutDepressed StateComponent = C.XKB_STATE_LAYOUT_DEPRESSED + + // Latched layout, i.e. will be unset after the next non-modifier + // key press. + StateLayoutLatched StateComponent = C.XKB_STATE_LAYOUT_LATCHED + + // Locked layout, i.e. will be unset after the key provoking the lock + // has been pressed again. + StateLayoutLocked StateComponent = C.XKB_STATE_LAYOUT_LOCKED + + // Effective layout, i.e. currently active and affects key processing + // (derived from the other state components). + // Use this unless you explicitly care how the state came about. + StateLayoutEffective StateComponent = C.XKB_STATE_LAYOUT_EFFECTIVE + + // LEDs (derived from the other state components). + STATE_LEDS StateComponent = C.XKB_STATE_LEDS +) + +// Update the keyboard state to reflect a given key being pressed or +// released. +// +// This entry point is intended for *server* applications and should not be used +// by *client* applications; see @ref server-client-state for details. +// +// A series of calls to this function should be consistent; that is, a call +// with XKB_KEY_DOWN for a key should be matched by an XKB_KEY_UP; if a key +// is pressed twice, it should be released twice; etc. Otherwise (e.g. due +// to missed input events), situations like "stuck modifiers" may occur. +// +// This function is often used in conjunction with the function +// xkb_state_key_get_syms() (or xkb_state_key_get_one_sym()), for example, +// when handling a key event. In this case, you should prefer to get the +// keysyms *before* updating the key, such that the keysyms reported for +// the key event are not affected by the event itself. This is the +// conventional behavior. +// +// @returns A mask of state components that have changed as a result of +// the update. If nothing in the state has changed, returns 0. +// +// @memberof xkb_state +// +// @sa xkb_state_update_mask() +func (s *State) UpdateKey(key Keycode, direction KeyDirection) StateComponent { + stateComponent := C.xkb_state_update_key(s.value(), C.xkb_keycode_t(key), + C.enum_xkb_key_direction(direction)) + return StateComponent(stateComponent) +} + +// Get the Unicode/UTF-8 string obtained from pressing a particular key +// in a given keyboard state. +// +// @param[in] state The keyboard state object. +// @param[in] key The keycode of the key. +// @param[out] buffer A buffer to write the string into. +// @param[in] size Size of the buffer. +// +// @warning If the buffer passed is too small, the string is truncated +// (though still NUL-terminated). +// +// @returns The number of bytes required for the string, excluding the +// NUL byte. If there is nothing to write, returns 0. +// +// You may check if truncation has occurred by comparing the return value +// with the size of @p buffer, similarly to the snprintf(3) function. +// You may safely pass NULL and 0 to @p buffer and @p size to find the +// required size (without the NUL-byte). +// +// This function performs Capitalization and Control @ref +// keysym-transformations. +// +// @memberof xkb_state +// @since 0.4.1 +func (s *State) KeyGetUtf8(key Keycode) string { + size := C.xkb_state_key_get_utf8(s.value(), C.xkb_keycode_t(key), nil, 0) + if size == 0 { + return "" + } + + buffer := (*C.char)(C.malloc(C.size_t(size + 1))) + defer C.free((unsafe.Pointer)(buffer)) + C.xkb_state_key_get_utf8(s.value(), C.xkb_keycode_t(key), buffer, C.size_t(size+1)) + return C.GoString(buffer) +} + +// Get the single keysym obtained from pressing a particular key in a +// given keyboard state. +// +// This function is similar to xkb_state_key_get_syms(), but intended +// for users which cannot or do not want to handle the case where +// multiple keysyms are returned (in which case this function is +// preferred). +// +// @returns The keysym. If the key does not have exactly one keysym, +// returns XKB_KEY_NoSymbol +// +// This function performs Capitalization @ref keysym-transformations. +// +// @sa xkb_state_key_get_syms() +// @memberof xkb_state +func (s *State) GetOneSym(keycode Keycode) Keysym { + keysym := C.xkb_state_key_get_one_sym(s.value(), C.xkb_keycode_t(keycode)) + return Keysym(keysym) +} + +// The counterpart to xkb_state_update_mask for modifiers, to be used on +// the server side of serialization. +// +// This entry point is intended for *server* applications; see @ref +// server-client-state for details. *Client* applications should use the +// xkb_state_mod_*_is_active API. +// +// @param state The keyboard state. +// @param components A mask of the modifier state components to serialize. +// State components other than XKB_STATE_MODS_* are ignored. +// If XKB_STATE_MODS_EFFECTIVE is included, all other state components are +// ignored. +// +// @returns A xkb_mod_mask_t representing the given components of the +// modifier state. +// +// @memberof xkb_state +func (s *State) SerializeMods(components StateComponent) ModMask { + return ModMask(C.xkb_state_serialize_mods(s.value(), C.enum_xkb_state_component(components))) +} + +// Consumed modifiers mode. +// +// There are several possible methods for deciding which modifiers are +// consumed and which are not, each applicable for different systems or +// situations. The mode selects the method to use. +// +// Keep in mind that in all methods, the keymap may decide to "preserve" +// a modifier, meaning it is not reported as consumed even if it would +// have otherwise. +type ConsumedMode C.enum_xkb_consumed_mode + +var ( + // This is the mode defined in the XKB specification and used by libX11. + // + // A modifier is consumed if and only if it *may affect* key translation. + // + // For example, if `Control+Alt+` produces some assigned keysym, + // then when pressing just ``, `Control` and `Alt` are consumed, + // even though they are not active, since if they *were* active they would + // have affected key translation. + ConsumedModeXKB ConsumedMode = C.XKB_CONSUMED_MODE_XKB + + // This is the mode used by the GTK+ toolkit. + // + // The mode consists of the following two independent heuristics: + // + // - The currently active set of modifiers, excluding modifiers which do + // not affect the key (as described for @ref XKB_CONSUMED_MODE_XKB), are + // considered consumed, if the keysyms produced when all of them are + // active are different from the keysyms produced when no modifiers are + // active. + // + // - A single modifier is considered consumed if the keysyms produced for + // the key when it is the only active modifier are different from the + // keysyms produced when no modifiers are active. + ConsumedModeGTK ConsumedMode = C.XKB_CONSUMED_MODE_GTK +) + +// Get the mask of modifiers consumed by translating a given key. +// +// @param state The keyboard state. +// @param key The keycode of the key. +// @param mode The consumed modifiers mode to use; see enum description. +// +// @returns a mask of the consumed modifiers. +// +// @memberof xkb_state +// @since 0.7.0 +func (s *State) KeyGetConsumedMods2(key Keycode, mode ConsumedMode) ModMask { + return ModMask(C.xkb_state_key_get_consumed_mods2(s.value(), + C.xkb_keycode_t(key), C.enum_xkb_consumed_mode(mode))) +} + +// Same as xkb_state_key_get_consumed_mods2() with mode XKB_CONSUMED_MODE_XKB. +// +// @memberof xkb_state +// @since 0.4.1 +func (s *State) KeyGetConsumedMods(key Keycode) ModMask { + return ModMask(C.xkb_state_key_get_consumed_mods(s.value(), C.xkb_keycode_t(key))) +}