Relay browser touches through the harness into the real touchscreen

This commit is contained in:
flamingcow
2026-08-31 14:59:01 -07:00
parent 7fa48da73c
commit 10d97ee82a
2 changed files with 196 additions and 1 deletions
+33 -1
View File
@@ -22,7 +22,7 @@ const page = `<!doctype html>
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<title>cabletest</title> <title>cabletest</title>
<body style="margin:0;background:#000;min-height:100vh;display:grid;place-items:center"> <body style="margin:0;background:#000;min-height:100vh;display:grid;place-items:center">
<canvas style="max-width:100vw;max-height:100vh"></canvas> <canvas style="max-width:100vw;max-height:100vh;touch-action:none"></canvas>
<script> <script>
(async () => { (async () => {
const cv = document.querySelector('canvas'), g = cv.getContext('2d'); const cv = document.querySelector('canvas'), g = cv.getContext('2d');
@@ -59,6 +59,34 @@ addEventListener('keydown', e => {
fetch('keys', {method: 'POST', body: e.key}); fetch('keys', {method: 'POST', body: e.key});
} }
}); });
(() => {
const cv = document.querySelector('canvas');
let q = Promise.resolve(), held = -1, lastMove = 0;
const send = b => q = q.then(() => fetch('touch', {method: 'POST', body: b}).catch(() => {}));
const at = e => {
const r = cv.getBoundingClientRect();
return Math.round((e.clientX - r.left) * cv.width / r.width) + ' ' +
Math.round((e.clientY - r.top) * cv.height / r.height);
};
cv.addEventListener('pointerdown', e => {
e.preventDefault();
held = e.pointerId;
cv.setPointerCapture(held);
send('down ' + at(e));
});
cv.addEventListener('pointermove', e => {
if (e.pointerId !== held || e.timeStamp - lastMove < 50) return;
lastMove = e.timeStamp;
send('move ' + at(e));
});
const lift = e => {
if (e.pointerId !== held) return;
held = -1;
send('up');
};
cv.addEventListener('pointerup', lift);
cv.addEventListener('pointercancel', lift);
})();
</script> </script>
` `
@@ -87,6 +115,9 @@ func serveStream() error {
if err != nil { if err != nil {
return err return err
} }
if touchscreen, err = openTouchscreen(); err != nil {
return err
}
ln, err := net.Listen("tcp", streamAddr) ln, err := net.Listen("tcp", streamAddr)
if err != nil { if err != nil {
return err return err
@@ -104,6 +135,7 @@ func serveStream() error {
}) })
mux.HandleFunc("/frames", frames) mux.HandleFunc("/frames", frames)
mux.HandleFunc("/keys", keys) mux.HandleFunc("/keys", keys)
mux.HandleFunc("/touch", touch)
go http.Serve(ln, mux) go http.Serve(ln, mux)
return nil return nil
} }
+163
View File
@@ -0,0 +1,163 @@
package main
import (
"encoding/binary"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"unsafe"
"golang.org/x/sys/unix"
)
const (
evSyn = 0x00
evKey = 0x01
evAbs = 0x03
btnTouch = 0x14a
absX = 0x00
absY = 0x01
inputPropDirect = 0x01
sizeofInputEvent = 24
)
func evIoc(nr, size uintptr) uintptr {
const iocRead = 2
return iocRead<<30 | size<<16 | 'E'<<8 | nr
}
type touchDev struct {
f *os.File
minX, maxX, minY, maxY int32
}
var touchscreen *touchDev
// Picks the direct-input device that reports absolute X and Y — the same
// probe cabletest runs, so both ends name the same hardware. Events written
// to the node are fed into the input core as if the hardware produced them,
// so cabletest sees a finger without carrying any injection code.
func openTouchscreen() (*touchDev, error) {
paths, err := filepath.Glob("/dev/input/event*")
if err != nil {
return nil, err
}
for _, p := range paths {
f, err := os.OpenFile(p, os.O_RDWR, 0)
if err != nil {
continue
}
var props, absBits uint64
if _, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(),
evIoc(0x09, 8), uintptr(unsafe.Pointer(&props))); errno != 0 {
f.Close()
continue
}
if _, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(),
evIoc(0x20+evAbs, 8), uintptr(unsafe.Pointer(&absBits))); errno != 0 {
f.Close()
continue
}
if props&(1<<inputPropDirect) == 0 ||
absBits&(1<<absX) == 0 || absBits&(1<<absY) == 0 {
f.Close()
continue
}
t := &touchDev{f: f}
if t.minX, t.maxX, err = axisRange(f, absX); err != nil {
f.Close()
return nil, fmt.Errorf("%s x range: %w", p, err)
}
if t.minY, t.maxY, err = axisRange(f, absY); err != nil {
f.Close()
return nil, fmt.Errorf("%s y range: %w", p, err)
}
if t.maxX <= t.minX || t.maxY <= t.minY {
f.Close()
return nil, fmt.Errorf("%s: degenerate axes x %d..%d y %d..%d",
p, t.minX, t.maxX, t.minY, t.maxY)
}
return t, nil
}
return nil, fmt.Errorf("no touchscreen found among /dev/input/event*")
}
func axisRange(f *os.File, axis uintptr) (min, max int32, err error) {
var info [6]int32
if _, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(),
evIoc(0x40+axis, 24), uintptr(unsafe.Pointer(&info[0]))); errno != 0 {
return 0, 0, errno
}
return info[1], info[2], nil
}
type inputEvent struct {
typ, code uint16
val int32
}
// Timestamps are left zero: evdev stamps injected events at delivery.
func (t *touchDev) send(evs []inputEvent) error {
buf := make([]byte, len(evs)*sizeofInputEvent)
for i, e := range evs {
o := i * sizeofInputEvent
binary.NativeEndian.PutUint16(buf[o+16:], e.typ)
binary.NativeEndian.PutUint16(buf[o+18:], e.code)
binary.NativeEndian.PutUint32(buf[o+20:], uint32(e.val))
}
_, err := t.f.Write(buf)
return err
}
// The page speaks portrait canvas pixels; the device speaks the panel's
// landscape axes, so the quarter turn cabletest applies on the way in is
// inverted on the way out.
func touch(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
return
}
f := strings.Fields(string(body))
if len(f) == 1 && f[0] == "up" {
if err := touchscreen.send([]inputEvent{{evKey, btnTouch, 0}, {evSyn, 0, 0}}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if len(f) != 3 || (f[0] != "down" && f[0] != "move") {
http.Error(w, "want down x y, move x y, or up", http.StatusBadRequest)
return
}
cx, errX := strconv.Atoi(f[1])
cy, errY := strconv.Atoi(f[2])
if errX != nil || errY != nil {
http.Error(w, "bad coordinates", http.StatusBadRequest)
return
}
latestMu.RLock()
k := latest
latestMu.RUnlock()
if k.buf == nil {
http.Error(w, "no frame to locate the touch on", http.StatusServiceUnavailable)
return
}
cx = min(max(cx, 0), k.ph-1)
cy = min(max(cy, 0), k.pw-1)
xl, yl := cy, k.ph-1-cx
t := touchscreen
rawX := t.minX + int32(int64(xl)*int64(t.maxX-t.minX)/int64(k.pw-1))
rawY := t.minY + int32(int64(yl)*int64(t.maxY-t.minY)/int64(k.ph-1))
evs := []inputEvent{{evAbs, absX, rawX}, {evAbs, absY, rawY}}
if f[0] == "down" {
evs = append(evs, inputEvent{evKey, btnTouch, 1})
}
evs = append(evs, inputEvent{evSyn, 0, 0})
if err := t.send(evs); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}