Merge commit '4a0a055234936acde746b606ee23fcf7a546eebc' as 'faxmachine'
This commit is contained in:
commit
69604eb5a4
24 changed files with 3874 additions and 0 deletions
12
faxmachine/LICENSE
Normal file
12
faxmachine/LICENSE
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
Copyright (C) 2023 by Guacamolie <guac@amolie.nl>
|
||||||
|
|
||||||
|
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.
|
68
faxmachine/cmd/printimg/main.go
Normal file
68
faxmachine/cmd/printimg/main.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
31
faxmachine/cmd/printtext/main.go
Normal file
31
faxmachine/cmd/printtext/main.go
Normal file
|
@ -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()
|
||||||
|
}
|
80
faxmachine/config/config.go
Normal file
80
faxmachine/config/config.go
Normal file
|
@ -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
|
||||||
|
}
|
157
faxmachine/escpos/component.go
Normal file
157
faxmachine/escpos/component.go
Normal file
|
@ -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)
|
||||||
|
}
|
114
faxmachine/escpos/encoding.go
Normal file
114
faxmachine/escpos/encoding.go
Normal file
|
@ -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,
|
||||||
|
}
|
||||||
|
)
|
274
faxmachine/escpos/escpos.go
Normal file
274
faxmachine/escpos/escpos.go
Normal file
|
@ -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
|
||||||
|
}
|
115
faxmachine/escpos/printer/printer.go
Normal file
115
faxmachine/escpos/printer/printer.go
Normal file
|
@ -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
|
||||||
|
}
|
166
faxmachine/escpos/protocol/build.go
Normal file
166
faxmachine/escpos/protocol/build.go
Normal file
|
@ -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 <Function 50> ("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 <Function 112> ("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 <Function 50> ("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
|
||||||
|
}
|
||||||
|
}
|
71
faxmachine/escpos/protocol/parse.go
Normal file
71
faxmachine/escpos/protocol/parse.go
Normal file
|
@ -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<<nbit) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return ASBStatus{
|
||||||
|
DrawerKickoutConnector: DrawerKickoutConnectorStatus{
|
||||||
|
IsPin3High: checkbit(0, 2),
|
||||||
|
},
|
||||||
|
Online: OnlineStatus{
|
||||||
|
IsOffline: checkbit(0, 3),
|
||||||
|
IsCoverOpen: checkbit(0, 5),
|
||||||
|
IsPaperFedByFeedButton: checkbit(0, 5),
|
||||||
|
IsWaitingForOnlineRecovery: checkbit(1, 0),
|
||||||
|
},
|
||||||
|
Error: ErrorStatus{
|
||||||
|
RecoverableErrorOccurred: checkbit(1, 2),
|
||||||
|
AutocutterErrorOccurred: checkbit(1, 3),
|
||||||
|
UnrecoverableErrorOccurred: checkbit(1, 5),
|
||||||
|
AutomaticallyRecoverableErrorOccurred: checkbit(1, 6),
|
||||||
|
},
|
||||||
|
Paper: p.ParsePaperSensorStatus(status[2]),
|
||||||
|
PanelSwitch: PanelSwitchStatus{
|
||||||
|
IsFeedButtonPressed: checkbit(1, 1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
8
faxmachine/escpos/protocol/protocol.go
Normal file
8
faxmachine/escpos/protocol/protocol.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package protocol
|
||||||
|
|
||||||
|
type Protocol int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Generic Protocol = iota
|
||||||
|
TMT88IV Protocol = iota
|
||||||
|
)
|
189
faxmachine/escpos/rasterize.go
Normal file
189
faxmachine/escpos/rasterize.go
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
// This file was origionally from:
|
||||||
|
// https://github.com/mugli/png2escpos/blob/56fca745daa0149280e04b5155cb59f1796e8842/escpos/raster.go
|
||||||
|
//
|
||||||
|
// Copyright (c) 2019, Mehdi Hasan Khan
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
// this software and associated documentation files (the "Software"), to deal in
|
||||||
|
// the Software without restriction, including without limitation the rights to
|
||||||
|
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||||
|
// the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
// subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
//
|
||||||
|
// And adapted at:
|
||||||
|
// https://github.com/hennedo/escpos/blob/475ba147a030cd572bd9137e62a55185238738aa/bitimage.go
|
||||||
|
//
|
||||||
|
// MIT License
|
||||||
|
//
|
||||||
|
// Copyright (c) 2021 Hendrik Fellerhoff
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package escpos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
)
|
||||||
|
|
||||||
|
func closestNDivisibleBy8(n int) int {
|
||||||
|
q := n / 8
|
||||||
|
n1 := q * 8
|
||||||
|
|
||||||
|
return n1
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPrintImageData(img image.Image) (x int, y int, data []byte) {
|
||||||
|
width, height, pixels := getPixels(img)
|
||||||
|
|
||||||
|
removeTransparency(&pixels)
|
||||||
|
makeGrayscale(&pixels)
|
||||||
|
|
||||||
|
printWidth := closestNDivisibleBy8(width)
|
||||||
|
printHeight := closestNDivisibleBy8(height)
|
||||||
|
bytes, _ := rasterize(printWidth, printHeight, &pixels)
|
||||||
|
|
||||||
|
return printWidth, printHeight, bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGrayscale(pixels *[][]pixel) {
|
||||||
|
height := len(*pixels)
|
||||||
|
width := len((*pixels)[0])
|
||||||
|
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
row := (*pixels)[y]
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
pixel := row[x]
|
||||||
|
|
||||||
|
luminance := (float64(pixel.R) * 0.299) + (float64(pixel.G) * 0.587) + (float64(pixel.B) * 0.114)
|
||||||
|
var value int
|
||||||
|
if luminance < 128 {
|
||||||
|
value = 0
|
||||||
|
} else {
|
||||||
|
value = 255
|
||||||
|
}
|
||||||
|
|
||||||
|
pixel.R = value
|
||||||
|
pixel.G = value
|
||||||
|
pixel.B = value
|
||||||
|
|
||||||
|
row[x] = pixel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeTransparency(pixels *[][]pixel) {
|
||||||
|
height := len(*pixels)
|
||||||
|
width := len((*pixels)[0])
|
||||||
|
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
row := (*pixels)[y]
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
pixel := row[x]
|
||||||
|
|
||||||
|
alpha := pixel.A
|
||||||
|
invAlpha := 255 - alpha
|
||||||
|
|
||||||
|
pixel.R = (alpha*pixel.R + invAlpha*255) / 255
|
||||||
|
pixel.G = (alpha*pixel.G + invAlpha*255) / 255
|
||||||
|
pixel.B = (alpha*pixel.B + invAlpha*255) / 255
|
||||||
|
pixel.A = 255
|
||||||
|
|
||||||
|
row[x] = pixel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rasterize(printWidth int, printHeight int, pixels *[][]pixel) ([]byte, error) {
|
||||||
|
if printWidth%8 != 0 {
|
||||||
|
return nil, fmt.Errorf("printWidth must be a multiple of 8")
|
||||||
|
}
|
||||||
|
|
||||||
|
if printHeight%8 != 0 {
|
||||||
|
return nil, fmt.Errorf("printHeight must be a multiple of 8")
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes := make([]byte, (printWidth*printHeight)>>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
|
||||||
|
}
|
36
faxmachine/go.mod
Normal file
36
faxmachine/go.mod
Normal file
|
@ -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
|
||||||
|
)
|
81
faxmachine/go.sum
Normal file
81
faxmachine/go.sum
Normal file
|
@ -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=
|
149
faxmachine/keyboard/keyboard.go
Normal file
149
faxmachine/keyboard/keyboard.go
Normal file
|
@ -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<<k.keymap.ModGetIndex("Control") |
|
||||||
|
1<<k.keymap.ModGetIndex("Alt") |
|
||||||
|
1<<k.keymap.ModGetIndex("Shift") |
|
||||||
|
1<<k.keymap.ModGetIndex("Super"))
|
||||||
|
|
||||||
|
shortcutMods := stateMods & ^consumedMods & significantMods
|
||||||
|
return k.parseModMask(shortcutMods)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Keyboard) parseModMask(modMask xkbcommon.ModMask) (mod ModMask) {
|
||||||
|
if modMask&(1<<k.keymap.ModGetIndex("Control")) != 0 {
|
||||||
|
mod |= Control
|
||||||
|
}
|
||||||
|
if modMask&(1<<k.keymap.ModGetIndex("Alt")) != 0 {
|
||||||
|
mod |= Alt
|
||||||
|
}
|
||||||
|
if modMask&(1<<k.keymap.ModGetIndex("Shift")) != 0 {
|
||||||
|
mod |= Shift
|
||||||
|
}
|
||||||
|
if modMask&(1<<k.keymap.ModGetIndex("Super")) != 0 {
|
||||||
|
mod |= Super
|
||||||
|
}
|
||||||
|
return mod
|
||||||
|
}
|
||||||
|
|
||||||
|
func keycode(event *evdev.InputEvent) xkbcommon.Keycode {
|
||||||
|
return xkbcommon.Keycode(event.Code + 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyPress struct {
|
||||||
|
Sym xkbcommon.Keysym
|
||||||
|
ShortcutMods ModMask
|
||||||
|
Char string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModMask int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Control ModMask = 1 << iota
|
||||||
|
Alt
|
||||||
|
Shift
|
||||||
|
Super
|
||||||
|
)
|
401
faxmachine/main.go
Normal file
401
faxmachine/main.go
Normal file
|
@ -0,0 +1,401 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
"git.sr.ht/~guacamolie/faxmachine/config"
|
||||||
|
"git.sr.ht/~guacamolie/faxmachine/escpos"
|
||||||
|
"git.sr.ht/~guacamolie/faxmachine/escpos/protocol"
|
||||||
|
"git.sr.ht/~guacamolie/faxmachine/keyboard"
|
||||||
|
"git.sr.ht/~guacamolie/faxmachine/matrix"
|
||||||
|
"git.sr.ht/~guacamolie/faxmachine/ntfy"
|
||||||
|
"git.sr.ht/~guacamolie/faxmachine/printjob"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := pflag.String("config", "", "Use alternative config file location")
|
||||||
|
pflag.Parse()
|
||||||
|
|
||||||
|
matrixSlogHandler := matrix.NewSlogHandler(nil)
|
||||||
|
|
||||||
|
logger := slog.New(slogMultiHandler{
|
||||||
|
slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelDebug,
|
||||||
|
}),
|
||||||
|
matrixSlogHandler,
|
||||||
|
})
|
||||||
|
|
||||||
|
conf := config.Load(*configPath)
|
||||||
|
|
||||||
|
logger.Info("program init",
|
||||||
|
slog.Group("conf",
|
||||||
|
slog.Group("devices",
|
||||||
|
slog.String("printer", conf.Devices.Printer),
|
||||||
|
slog.String("keyboard", conf.Devices.Keyboard),
|
||||||
|
),
|
||||||
|
slog.Group("ntfy",
|
||||||
|
slog.String("host", conf.Ntfy.Host),
|
||||||
|
),
|
||||||
|
slog.Group("matrix",
|
||||||
|
slog.String("user", conf.Matrix.UserID),
|
||||||
|
slog.String("admin_room", conf.Matrix.AdminRoom),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
var kb *keyboard.Keyboard
|
||||||
|
if conf.Devices.Keyboard != "" {
|
||||||
|
var err error
|
||||||
|
kb, err = keyboard.Open(conf.Devices.Keyboard)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to open keyboard: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ntfyInstance := ntfy.NewInstance(conf.Ntfy, logger.WithGroup("ntfy"))
|
||||||
|
matrixInstance, err := matrix.NewBot(context.Background(),
|
||||||
|
conf.Matrix, logger.WithGroup("matrix"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to connect to Matrix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
matrixSlogHandler.Attach(matrixInstance)
|
||||||
|
|
||||||
|
daemon := &printDaemon{
|
||||||
|
lp: conf.Devices.Printer,
|
||||||
|
ntfy: ntfyInstance,
|
||||||
|
matrix: matrixInstance,
|
||||||
|
keyboard: kb,
|
||||||
|
}
|
||||||
|
|
||||||
|
daemon.run(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
type printDaemon struct {
|
||||||
|
lp string
|
||||||
|
ntfy *ntfy.Instance
|
||||||
|
matrix *matrix.Bot
|
||||||
|
keyboard *keyboard.Keyboard
|
||||||
|
|
||||||
|
history []printjob.Job
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *printDaemon) run(ctx context.Context) {
|
||||||
|
queue := make(chan printjob.Job, 10)
|
||||||
|
inputCh := make(chan keyboard.KeyPress)
|
||||||
|
|
||||||
|
if err := d.ntfy.Subscribe(ctx, queue); err != nil {
|
||||||
|
log.Fatalf("failed to subscribe to ntfy: %v\n", err)
|
||||||
|
}
|
||||||
|
d.matrix.Subscribe(ctx, queue)
|
||||||
|
|
||||||
|
if d.keyboard != nil {
|
||||||
|
d.keyboard.Listen(ctx, inputCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
sighubCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sighubCh, unix.SIGHUP)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := d.handlePrintJobs(ctx, queue, inputCh); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("waiting for SIGHUB to retry...")
|
||||||
|
<-sighubCh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *printDaemon) handlePrintJobs(ctx context.Context,
|
||||||
|
queue <-chan printjob.Job, inputCh <-chan keyboard.KeyPress) error {
|
||||||
|
|
||||||
|
defer d.matrix.BecomeUnavailable(ctx)
|
||||||
|
|
||||||
|
p, err := escpos.StartUSBPrinter(d.lp, protocol.TMT88IV, escpos.FlagNone)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to printer: %w", err)
|
||||||
|
}
|
||||||
|
defer p.Close()
|
||||||
|
|
||||||
|
d.matrix.BecomeOnline(ctx)
|
||||||
|
|
||||||
|
if err := p.EnableASB(protocol.ASBReportAll); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable ASB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnected := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
for status := range p.ASBStatus() {
|
||||||
|
log.Printf("received ASB status: %#v\n", status)
|
||||||
|
}
|
||||||
|
disconnected <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case job := <-queue:
|
||||||
|
if err := p.SetPrintSpeed(5); err != nil {
|
||||||
|
return fmt.Errorf("failed to set print speed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handleJob(ctx, p, job); err != nil {
|
||||||
|
var ioerr *escpos.IOError
|
||||||
|
if errors.As(err, &ioerr) {
|
||||||
|
return fmt.Errorf("fatal print failure: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("print failure: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.history = append(d.history, job)
|
||||||
|
if len(d.history) > 100 {
|
||||||
|
d.history = d.history[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
case inputEvent := <-inputCh:
|
||||||
|
if name, ok := inputEvent.Sym.Name(); !ok || name != "r" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.SetPrintSpeed(0); err != nil {
|
||||||
|
return fmt.Errorf("failed to set print speed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInteractiveReply(ctx, p, d.history, inputCh)
|
||||||
|
|
||||||
|
case <-disconnected:
|
||||||
|
return fmt.Errorf("printer disconnected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleInteractiveReply(ctx context.Context,
|
||||||
|
p *escpos.Printer, history []printjob.Job,
|
||||||
|
inputCh <-chan keyboard.KeyPress) {
|
||||||
|
fmt.Fprintf(p, "CHOOSE MESSAGE FOR REPLY:\n")
|
||||||
|
|
||||||
|
replyOptions := []printjob.JobReplyer{}
|
||||||
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
if job, ok := history[i].(printjob.JobReplyer); ok {
|
||||||
|
replyOptions = append(replyOptions, job)
|
||||||
|
}
|
||||||
|
if len(replyOptions) >= 9 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := len(replyOptions) - 1; i >= 0; i-- {
|
||||||
|
option := replyOptions[i]
|
||||||
|
|
||||||
|
description := option.Description()
|
||||||
|
description = strings.ReplaceAll(description, "\r", "")
|
||||||
|
description = strings.ReplaceAll(description, "\n", " ")
|
||||||
|
|
||||||
|
fmt.Fprintf(p, "\n")
|
||||||
|
fmt.Fprintf(p, " %d. \"%.36s\"\n", i+1, description)
|
||||||
|
fmt.Fprintf(p, " sent %s ago by %s\n",
|
||||||
|
time.Since(option.Time()).Round(time.Second),
|
||||||
|
option.Sender())
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollUp(p)
|
||||||
|
|
||||||
|
var chosenJob printjob.JobReplyer
|
||||||
|
for inputEvent := range inputCh {
|
||||||
|
if name, ok := inputEvent.Sym.Name(); ok && name == "Escape" {
|
||||||
|
fmt.Fprintf(p, "REPLY ABORTED\n\n\n")
|
||||||
|
p.CutPaper()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
num, err := strconv.Atoi(inputEvent.Char)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if num-1 >= len(replyOptions) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(p, "REPLYING TO MESSAGE %d\n\n", num)
|
||||||
|
chosenJob = replyOptions[num-1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(p, "ENTER REPLY:\n")
|
||||||
|
scrollUp(p)
|
||||||
|
|
||||||
|
var buffer textBuffer
|
||||||
|
|
||||||
|
for inputEvent := range inputCh {
|
||||||
|
mods := inputEvent.ShortcutMods
|
||||||
|
key, _ := inputEvent.Sym.Name()
|
||||||
|
|
||||||
|
if mods == 0 && key == "Escape" {
|
||||||
|
fmt.Fprintf(p, "REPLY ABORTED\n\n\n")
|
||||||
|
p.CutPaper()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mods == 0 && key == "Return" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case mods == 0 && key == "Tab":
|
||||||
|
fmt.Fprintf(p, "%s\n", buffer.preview())
|
||||||
|
scrollUp(p)
|
||||||
|
|
||||||
|
case mods == 0 && key == "Delete":
|
||||||
|
buffer.reset()
|
||||||
|
|
||||||
|
case mods == 0 && key == "BackSpace":
|
||||||
|
buffer.backspace()
|
||||||
|
|
||||||
|
case mods == 0 && key == "Left":
|
||||||
|
buffer.moveCursor(-1)
|
||||||
|
|
||||||
|
case mods == 0 && key == "Right":
|
||||||
|
buffer.moveCursor(+1)
|
||||||
|
|
||||||
|
case mods == keyboard.Control && key == "w":
|
||||||
|
buffer.removeWord()
|
||||||
|
|
||||||
|
case mods == 0 && inputEvent.Char != "":
|
||||||
|
buffer.typeChar(inputEvent.Char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := chosenJob.Reply(ctx, buffer.String()); err != nil {
|
||||||
|
fmt.Fprintf(p, "failed to reply: %v\n\n\n", err)
|
||||||
|
p.CutPaper()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(p, "REPLY SENT\n\n\n")
|
||||||
|
p.CutPaper()
|
||||||
|
}
|
||||||
|
|
||||||
|
type textBuffer struct {
|
||||||
|
// We store each entered key seperately here. This allows us to the same
|
||||||
|
// amount of text that was entered when pressing backspace. It's a bit of a
|
||||||
|
// hack, but it means I don't have to figure out the (complex) rules as to
|
||||||
|
// what exactly should happen when you press backspace.
|
||||||
|
buffer []string
|
||||||
|
cursor int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *textBuffer) reset() {
|
||||||
|
*tb = textBuffer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *textBuffer) typeChar(c string) {
|
||||||
|
tb.buffer = slices.Insert(tb.buffer, tb.cursor, c)
|
||||||
|
tb.cursor++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *textBuffer) backspace() {
|
||||||
|
if tb.cursor > 0 {
|
||||||
|
tb.buffer = slices.Delete(tb.buffer, tb.cursor-1, tb.cursor)
|
||||||
|
tb.cursor--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeWord implements deletion of a word using the conventional
|
||||||
|
// "unix-word-rubout" method. It first deletes spaces until it can find a
|
||||||
|
// non-space character, and then deletes non-space characters until it reaches
|
||||||
|
// a space again.
|
||||||
|
func (tb *textBuffer) removeWord() {
|
||||||
|
hasSpace := func(s string) bool {
|
||||||
|
return strings.IndexFunc(s, unicode.IsSpace) != -1
|
||||||
|
}
|
||||||
|
|
||||||
|
for tb.cursor > 0 {
|
||||||
|
if hasSpace(tb.buffer[tb.cursor-1]) {
|
||||||
|
tb.backspace()
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for tb.cursor > 0 {
|
||||||
|
if !hasSpace(tb.buffer[tb.cursor-1]) {
|
||||||
|
tb.backspace()
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *textBuffer) moveCursor(movement int) {
|
||||||
|
tb.cursor = max(min(tb.cursor+movement, len(tb.buffer)), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *textBuffer) preview() string {
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
for _, s := range tb.buffer[:tb.cursor] {
|
||||||
|
builder.WriteString(s)
|
||||||
|
}
|
||||||
|
builder.WriteString("▒")
|
||||||
|
for _, s := range tb.buffer[tb.cursor:] {
|
||||||
|
builder.WriteString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *textBuffer) String() string {
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
for _, s := range tb.buffer {
|
||||||
|
builder.WriteString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollUp(p *escpos.Printer) {
|
||||||
|
fmt.Fprintf(p, "\n\n\n\n\n\n\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleJob(ctx context.Context, p *escpos.Printer, job printjob.Job) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if err == nil {
|
||||||
|
job.OnPrinted(ctx)
|
||||||
|
} else {
|
||||||
|
job.OnShredded(ctx, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := job.Print(ctx, p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(p, "\n\n\n")
|
||||||
|
|
||||||
|
if err := p.CutPaper(); err != nil {
|
||||||
|
return fmt.Errorf("failed to cut paper: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.Wait(); err != nil {
|
||||||
|
return fmt.Errorf("failed to print: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
535
faxmachine/matrix/bot.go
Normal file
535
faxmachine/matrix/bot.go
Normal file
|
@ -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 <name>")
|
||||||
|
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 <content-URI>")
|
||||||
|
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 <on|off>")
|
||||||
|
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 <on|off>")
|
||||||
|
}
|
||||||
|
|
||||||
|
case "dithering":
|
||||||
|
if len(args) != 2 {
|
||||||
|
msg.Reply(ctx, "Usage: 📠 dithering <on|off>")
|
||||||
|
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 <on|off>")
|
||||||
|
}
|
||||||
|
|
||||||
|
case "concise":
|
||||||
|
if len(args) != 2 {
|
||||||
|
msg.Reply(ctx, "Usage: 📠 concise <on|off>")
|
||||||
|
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 <on|off>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
422
faxmachine/matrix/matrix.go
Normal file
422
faxmachine/matrix/matrix.go
Normal file
|
@ -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
|
||||||
|
}
|
47
faxmachine/matrix/slog.go
Normal file
47
faxmachine/matrix/slog.go
Normal file
|
@ -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
|
||||||
|
}
|
188
faxmachine/ntfy/ntfy.go
Normal file
188
faxmachine/ntfy/ntfy.go
Normal file
|
@ -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.
|
||||||
|
}
|
38
faxmachine/printer/printer.go
Normal file
38
faxmachine/printer/printer.go
Normal file
|
@ -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()
|
||||||
|
}
|
22
faxmachine/printjob/job.go
Normal file
22
faxmachine/printjob/job.go
Normal file
|
@ -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
|
||||||
|
}
|
53
faxmachine/slog_multi.go
Normal file
53
faxmachine/slog_multi.go
Normal file
|
@ -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)
|
||||||
|
}
|
617
faxmachine/xkbcommon/xkbcommon.go
Normal file
617
faxmachine/xkbcommon/xkbcommon.go
Normal file
|
@ -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 <stdlib.h>
|
||||||
|
#include <xkbcommon/xkbcommon.h>
|
||||||
|
*/
|
||||||
|
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 \<A\> 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<codepoint>`, 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+<Backspace>` produces some assigned keysym,
|
||||||
|
// then when pressing just `<Backspace>`, `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)))
|
||||||
|
}
|
Loading…
Reference in a new issue