Merge commit '4a0a055234936acde746b606ee23fcf7a546eebc' as 'faxmachine'

This commit is contained in:
TT-392 2024-09-12 20:45:00 +02:00
commit 69604eb5a4
24 changed files with 3874 additions and 0 deletions

12
faxmachine/LICENSE Normal file
View 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.

View 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)
}
}

View 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()
}

View 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
}

View 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)
}

View 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
View 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
}

View 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
}

View 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
}
}

View 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),
},
}
}

View file

@ -0,0 +1,8 @@
package protocol
type Protocol int
const (
Generic Protocol = iota
TMT88IV Protocol = iota
)

View 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
View 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
View 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=

View 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
View 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
View 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
View 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
View 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
View 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.
}

View 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()
}

View 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
View 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)
}

View 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&nbsp;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)))
}