Tablet mode for Linux convertibles — for the machines the kernel gets wrong.
Written in Go. The core is a single static binary with no runtime dependencies.
Status: working. Runs as the daily driver on the reference machine. Switch synthesis is verified end to end: folding suspends the internal keyboard through libinput, exactly as a kernel-provided switch would. See Roadmap.
hinged doctorIt needs no privileges and changes nothing. Its output is also exactly what to paste into a bug report.
Most people don't need this tool, and it will tell you so. If your kernel
already reports SW_TABLET_MODE correctly, libinput handles the rest and
hinged stays out of the way. This is for the machines where that doesn't work.
When the kernel emits SW_TABLET_MODE, libinput already disables the internal
keyboard, touchpad and trackpoint (docs). That happens inside
libinput, so it works on every Wayland compositor and on X11 alike. Detecting a
fold and running xinput disable reimplements — badly, and X11-only — something
you already have.
The real problem is that a great many convertibles never emit that switch, or emit it wrongly:
- The kernel's
hp-wmidriver has had tablet-mode reporting disabled by default since ~5.10, because HP models claimed support and then always reported 0 — breaking auto-rotation and the on-screen keyboard for everyone. intel-vbtnandintel-hidgate the switch behind DMI allow-lists. If your chassis isn't listed, you get nothing.- Some machines assert it falsely at boot, permanently disabling the keyboard.
The usual folk remedy is blacklisting
intel_ish_ipc, which also costs you rotation and lid-wake.
Meanwhile plenty of those same machines expose a perfectly good hinge angle that nothing in the stack consumes.
hinged bridges the two. It reads whatever your machine actually has, applies a
real thresholding policy, and synthesizes a uinput device that emits
SW_TABLET_MODE. Everything downstream — libinput, GNOME, KDE, sway,
Hyprland — then works unmodified.
hinge angle ─┐
dual accel ─┼─→ policy engine ─→ uinput SW_TABLET_MODE
vendor keys ─┘ hysteresis │
wrap guard ▼
debounce libinput disables the keyboard
slew gate your desktop reacts, unmodified
lid override
The thresholding is the part that doesn't exist elsewhere, and it is a pure function with no I/O — so all of it is tested without hardware.
- Hysteresis. Enter tablet at ≥210°, return to laptop at ≤180°. The dead band between them is what stops the posture flapping while the hinge rests near a threshold. Never set them equal.
- Wrap guard. The sensor wraps 360→0 at full fold, so a small angle can mean folded all the way back — but it can equally mean a shut lid or a sensor glitch. The angle alone cannot tell them apart, so the decision uses the physical path instead: you cannot reach a wrapped near-zero from laptop posture without passing through tent and tablet first, so from laptop such a reading is always rejected. At startup, where there is no path to reason from, the lid switch settles it.
- Asymmetric debounce. One sample to enter tablet mode on the ordinary path, three when the evidence is weak (startup, after a gap, near-zero angles), and accumulated evidence to leave. Entering late means the keyboard is already face-down registering keypresses; asserting wrongly means the keyboard stops working. The second error is worse, so ambiguity always costs corroboration.
- Slew gate. Angular change faster than 720°/s is rejected as a glitch. The comparison is circular, so the genuine 359°→0° wrap is a 1° move and is never rejected. This is not theoretical — see Hardware notes.
- Lid override. A shut lid and a fully folded hinge both read near zero. The lid switch is the only thing that distinguishes them, and a shut lid must never assert tablet mode — at any angle, not just near zero.
- Dead-man release. If the sensor stops answering while tablet mode is asserted, the switch is released anyway. Releasing must never depend on the sensor's cooperation, because a wedged sensor is exactly when it matters.
- Tent posture. A hinge angle gives you states the binary switch cannot
express. Tent asserts
SW_TABLET_MODE(the keyboard faces away) but is reported distinctly over the API.
The worst failure in this domain is a machine left with no working input.
Synthesis does not fail safe, and you should not assume it does. An earlier
version of this README claimed that if the daemon dies the virtual switch
disappears and libinput re-enables everything. That is false, and the asymmetry
is visible in libinput's own source. When a tablet-mode switch device is
removed while asserting, the touchpad path calls tp_resume()
(evdev-mt-touchpad.c) but the keyboard path only detaches its
listener and nulls the pointer, never calling fallback_resume()
(evdev-fallback.c). The keyboard was suspended by closing its
fd, and only a switch event of 0 reopens it — an event a destroyed device can
never send.
So a SIGKILL, OOM kill or panic while asserting can return your touchpad and
leave the internal keyboard dead until the compositor rebuilds its libinput
context. The mitigations that actually work are to emit an explicit
SW_TABLET_MODE = 0 before destroying the device on every ordinary exit path,
to emit 0 at startup so a restart clears a latched state, and to run under a
supervisor that can do the same. Those are not yet implemented — see the
roadmap.
If you are ever stuck with a dead keyboard, switch to a TTY and:
sudo systemctl stop hingedThat works because the kernel VT console reads evdev directly rather than through libinput.
This was the project's one load-bearing unknown, since libinput's docs say nothing about device provenance and its model-keyed quirks imply identity can matter. Confirmed on the reference machine (libinput 1.25.0, kernel 7.0):
Device: hinged virtual tablet mode switch
Kernel: /dev/input/event19
Capabilities: switch
Folding past the threshold asserted SW_TABLET_MODE, and the internal
keyboard stopped responding until the machine was unfolded. libinput treats a
uinput switch exactly as it treats a kernel-driver one.
Worth noting because it was a live worry: that keyboard carries no
ID_INTEGRATION udev property, only ID_PATH=platform-i8042-serio-0. libinput
infers internal-ness from the platform bus, so ordinary laptop keyboards pair
without needing per-machine tagging.
Untested elsewhere: this is one machine, one libinput version, one input stack.
Reports from other hardware are welcome — hinged doctor output is the useful
thing to include.
Requires the Go toolchain named in go.mod. No runtime dependencies.
git clone https://github.com/denelson1-dot/hinged-convertible
cd hinged-convertible
CGO_ENABLED=0 go build -o hinged ./cmd/hinged
./hinged doctorReading the hinge angle needs no privileges. Reading the kernel's switch devices
does; packaging/udev/70-hinged-switch.rules grants access to switch devices
only, and only to the locally active user. The rule is numbered 70 on purpose:
ID_INPUT_SWITCH is set by systemd's 60-input-id.rules, so a rule numbered 60
sorts before the property exists and silently never matches on a cold boot.
sudo install -m644 packaging/udev/70-hinged-switch.rules /etc/udev/rules.d/
sudo udevadm control --reload && sudo udevadm triggerThis is deliberately narrower than usermod -aG input $USER, which would grant
your whole session read access to every input device including the keyboard.
Note that uaccess is implemented by systemd-logind; on OpenRC, runit or s6 the
tag does nothing and the group-based alternative in the rule file applies.
| Command | |
|---|---|
hinged daemon |
Run it: synthesize the switch and fire hooks |
hinged doctor |
What this machine exposes, what's reachable, what would be used |
hinged watch |
Live posture decisions, read-only, changes nothing |
hinged config |
Show the loaded configuration and where it came from |
hinged release |
Publish SW_TABLET_MODE=0 and exit, for recovery |
hinged daemon --dry-run decides and logs everything while creating no device
and running no hooks. Use it on hardware whose behaviour you do not yet trust.
Everything is optional; delete the file and hinged autodetects. See
examples/config.toml. Unknown keys are rejected rather
than ignored — a silent typo in a file that decides whether your keyboard
switches off is not an acceptable failure mode.
All desktop behaviour lives in hooks rather than being compiled in, which is what lets one binary serve GNOME, KDE, sway and everything else. Commands are argv arrays, never shell strings, and every hook's exit code and stderr are logged so a broken one is visible.
hinged does not ship a keyboard. A good OSK is a large project on its own and several maintained ones exist; what is missing on a convertible is something that knows when to show one. The panel drives whichever your desktop has, detected automatically:
| Desktop | Keyboard used |
|---|---|
| GNOME, KDE | nothing — the shell reacts to SW_TABLET_MODE itself |
| wlroots | wvkbd |
| anything else, incl. Cinnamon | Onboard |
A desktop shell's menus take a modal grab, and clicking any other X client dismisses them. So typing into the start menu with Onboard closes the menu you were typing into. The shell's own keyboard does not have this problem, because it lives in the same process as the menu.
But on Cinnamon that keyboard's size is hardcoded: _relayout() sets its
height to monitor.height / 3 at full width and never consults
keyboard-size. On a 1080p display it is always 360px tall.
So it is a genuine tradeoff, and hinged does not pretend otherwise. Onboard is the default because giving up a third of the screen permanently is the worse deal for most people. To go the other way on Cinnamon:
HINGED_OSK=cinnamon hinged-panelor set -osk-show / -osk-hide explicitly. Voice is the third option, and the
only one with no drawback here: vox types through a kernel-level virtual
keyboard, so dictating into a menu involves no pointer and breaks no grab.
Only one panel runs at a time, enforced with a lock rather than left to the configuration. Posture hooks make duplicates easy — a fold passes through tent and then tablet, so a hook on each fires twice — and a second launch simply exits.
Drag it. A press that moves more than 8px is a drag, anything less is a tap, so
one finger does both. The position is remembered in
$XDG_STATE_HOME/hinged/panel-position and rescaled if the screen geometry
changes, so it survives a rotation.
For dictation it calls vox, a separate system-wide service. That split is deliberate: the speech model loads once and is shared by everything on the machine, instead of every project carrying its own copy of the same few hundred megabytes.
The defaults are calibrated for one chassis. Every threshold can be overridden, and an inconsistent set is rejected at startup rather than producing undefined posture bands:
hinged watch -enter-angle 200 -leave-angle 160 -wrap-guard 15| Flag | Meaning |
|---|---|
-enter-angle |
Assert tablet mode at or above this angle (default 210) |
-leave-angle |
Laptop posture at or below this angle (default 180) |
-wrap-guard |
Below this, a reading may be a hinge wrapped past 360 (default 30) |
-tablet-angle |
Fully folded at or above this angle (default 300) |
-max-slew |
Reject change faster than this in °/s; 0 disables (default 720) |
For reference, the ChromeOS EC ships 180° with 20° of hysteresis, and treats under 15° as closed — so other vendors do land in a different place.
Findings from developing against an HP ENVY x360 15-bp1xx that may save you time on similar hardware:
Reading IIO sensor attributes from Go is unreliable. A HID-sensor attribute
read triggers a round trip to the sensor hub, and the driver intermittently
answers 0 rather than waiting. Reading through Go's os package is
measurably worse than issuing the syscalls directly. Interleaved A/B at 20
samples/second with the hinge stationary:
| Method | Bad reads |
|---|---|
os.ReadFile |
~3% |
syscall.Open + Read |
~0.3% |
The mechanism is not known. An earlier version of this file blamed
O_NONBLOCK and the runtime poller. That explanation was tested and is wrong:
an os.NewFile wrapper around a plain blocking descriptor, never registered
with the poller, is affected just as badly. Something in the os.File read path
is responsible, but this project has not identified what. The effect replicates;
the cause is open.
Raw syscalls reduce the glitch rate by roughly an order of magnitude but do not
eliminate it, which is why the policy layer also refuses to read a near-zero
angle as a fold unless the physical path supports it. A spurious 0 is
otherwise indistinguishable from a fully folded hinge, and unfiltered it
switches your keyboard off at random.
Angle units are not degrees by default. The IIO ABI specifies radians after
scale and offset are applied. This machine's in_angl_scale is
0.017453293 — exactly π/180 — so raw values happen to be degrees. That is a
coincidence, not a rule; assuming it elsewhere scales every threshold by 57×.
Scale and offset may not be per-channel. Here they are in_angl_scale and
in_angl_offset, device-wide, while the raw values are in_angl0_raw etc.
One device, three angles. in_angl0_label through 2 are hinge, screen
and keyboard. Only the first is the lid-to-base angle. Select by label; IIO
device numbering is not stable across boots.
The sensor advertises its own rate. in_angl_sampling_frequency is 10 Hz
here, so polling faster than 20 Hz only re-reads unchanged values.
- Pure policy engine with hysteresis, wrap guard, debounce, slew gate
-
doctor— permission-aware hardware probe -
watch— live read-only posture decisions - Configurable thresholds
- Dead-man release when the sensor stops reporting
- Verified: libinput honours a synthetic switch and suspends the keyboard
-
uinputswitch synthesis, with explicit release before device destroy - Daemon, hooks, config, systemd units
- Switch-health auditing and repair for lying firmware
- Command hooks and D-Bus API
- Accelerometer-pair angle derivation
- Optional offline voice dictation module
policy/ the pure decision engine — importable, no I/O, no hardware
internal/source/ IIO and evdev readers
internal/probe/ unprivileged hardware discovery, behind `doctor`
internal/watch/ the live loop
cmd/hinged/ CLI
policy is deliberately not under internal/: it is the reusable part, and
it has no dependency on anything else here.
Everything decision-shaped is tested without hardware. policy is pure and
covered at ~91% including a fuzz target; source and probe are tested against
sysfs fixtures and captured /proc/bus/input/devices records, including a
malicious device name that tries to forge a record.
| Package | Coverage |
|---|---|
policy |
91% |
internal/probe |
49% |
internal/source |
25% |
internal/watch, cmd/hinged |
0% |
| total | 33% |
The live loop and the CLI are the untested parts. They are mostly I/O orchestration, but that is an explanation rather than an excuse.
go test ./...
go test -coverpkg=./... ./... # coverage across all packagesCI additionally runs staticcheck, govulncheck, go mod tidy -diff, and
builds for amd64, arm64, arm and 386. That matrix is not decoration:
sizeof(struct input_event) differs on 32-bit, and the wrong value compiles
cleanly everywhere while silently desynchronising the event stream.
hinged is meant to interoperate with these, not replace them.
- libinput — reacts to
SW_TABLET_MODE. Does the actual disabling.hingedgives it a switch to react to. - iio-sensor-proxy — the standard rotation provider. Explicitly
declined tablet-mode detection.
hingedleaves rotation to it. - libinput device quirks — if your machine's problem is lying firmware
rather than missing firmware,
ModelTabletModeSwitchUnreliablein libinput's quirks database may fix it with no daemon, no/dev/uinputand no privileges. Try that first. - minibook-dual-accelerometer — the best existing thresholding policy, and the source of the jerk/tilt-gating ideas here. Chuwi MiniBook only, needs a DKMS module.
- asus-accel-tablet-mode-driver — accelerometer-derived tablet mode, Asus only.
MIT