docs/helper-reference.md

Open Device Helper: reference

On this page

The developer side of Open Device Helper: every feature with its kernel interface, the D-Bus API, the design of kernel and helper updates, packaging and tests. What the helper is and how to use it: helper.md. Code and build instructions: helper/. The layer numbers below (1 platform files, 2 helper daemon, 3 front-ends) are those of helper.md → Layers.

Feature inventory#

Every feature with its kernel interface, privilege, layer and hazards
FeatureKernel / sysfs interfacePrivilegeLayerNotes and hazards
Charge limit/sys/class/power_supply/qcom-battmgr-bat/charge_control_end_thresholdroot2start threshold = limit − recharge gap (3..20 %, default 10). "Full by": the daemon raises the limit to 100 % when the estimated charging time before the set time begins (charge left from charge_counter and the capacity, 85 % of the measured input power — at least 10 W — into 4.2 V, ×1.5 for the constant-voltage taper, + 30 min), and puts the limit back two hours after the time, or at once when unplugged after it; not while Bypass is on. The helper is the only owner. The CHARGE_LIMIT udev hint is dropped so UPower/GNOME's "preserve battery health" switch no longer writes the threshold (two writers would overwrite each other); the helper's app and quick settings replace it
Switch to Androidwrites the Android boot image kept in boot_b back into boot_a and reboots; refuses unless boot_b matches the recorded SHA-256root; authentication optional (android.require_auth)2 (tool in 1)back-to-android itself stays a layer-1 tool (also used by the emergency chord). The daemon method only wraps it: checks the hash file exists, emits a signal, runs it. Irreversible until the user switches back from Android
Emergency chord (volume up+down 10 s → Android)evdev on pmic_resin and gpio-keysroot1must work with no desktop and no helper; stays a plain service
Torch/sys/class/leds/white:flash/brightness (torch level ~96; never the flash strobe)root (daemon); video group via the optional udev 74-…-leds.rules without the helper2daemon exposes on/off/level with a safe maximum; front-ends never write sysfs
LED ring (charge indicator / effects)/sys/class/leds/aw22127:rgb:indicator/{brightness,multi_intensity,trigger,hw_pattern}root (daemon); video group via the optional udev 74-…-leds.rules without the helper2the charge indicator (amber charging, green full or held, red at or below the low percentage) runs in the daemon. A daemon thread owns every write: solid color, breathing and Pulse (notification blinks). Breathing runs on the LED chip (pattern trigger, an eight-point hw_pattern per cycle; kernels from t37); on older kernels the thread draws it (12.5 frames/s, only the brightness changes, stopped while the screen is off). The vendor effect scripts program registers directly — not used (they would race the driver)
Idle refresh rate/sys/module/msm/parameters/idle_refresh_{policy,hz,ms60,ms30,min_hz,input,selfflush,state}root (parameters are 0644 root)2The kernel always restores the mode's full rate before suspend or display-off, so the daemon needs no sleep handling. selfflush is a debug knob — not exposed.
Panel modes (90 / 120 / 164 Hz)DRM modes from the panel driveruser (compositor)3 (no daemon)handled by each desktop's display settings; the helper does not switch modes
Performance profile (power-saver / balanced / performance)GPU /sys/class/devfreq/3d00000.gpu/{min,max}_freq, CPU /sys/devices/system/cpu/cpufreq/policy{0,6}/scaling_{min,max}_freqroot2one profile sets GPU and CPU limits (and, when chosen, the thermal profile and Wi-Fi power saving); follows power-profiles-daemon when present (it offers only power-saver and balanced on this kernel: placeholder driver), otherwise the daemon's own choice. Widen before narrowing (min ≤ max at every step). CPU values are rounded to the nearest available frequency; a maximum at the top of the range writes cpuinfo_max_freq, so boost frequencies stay reachable; floors at most 1996 MHz (little) and 2880 MHz (big), the first thermal step — higher floors only add heat. Changing boost resets scaling_max_freq, so the limits are written again after it. Governor and energy_performance_preference (absent on scmi) are not exposed
Speaker level (protected mode)ALSA controls aw_dev_0_volume, aw_dev_1_volumeaudio group1user unit tb323fu-speaker-gain: amplifier attenuation 21 while the PipeWire protection filter runs, 159 otherwise (/etc/tb323fu/audio.conf). Safety-relevant: the loud value is only safe behind the filter chain. Do not move into the daemon; do not expose a free slider. Never restart PipeWire during playback (it stalls the audio DSP)
Microphone gainALSA ADC1/ADC3 Volumeaudio1boot default only (MIC_GAIN, applied by tb323fu-audio-defaults)
Auto brightnessdesktop setting (GNOME: org.gnome.settings-daemon.plugins.power ambient-enabled)user3desktop-owned; a separate GNOME tile binds the GSettings key, no daemon involvement
Vibration strength and testevdev EV_FF on the two AW86937 inputs (aw86927-haptics): FF_GAIN (0..0xffff, kept by ff-memless for every application), a 0.3 s FF_RUMBLE for the testroot (daemon)2the gain applies to everything that vibrates (on-screen keyboard, games); motors named by I2C address (0x5b = left, as in the device tree). Re-applied when the motors reappear (driver reload); on-screen-keyboard feedback itself stays the desktop's
Wake on charger plug/sys/class/power_supply/*/power/wakeup (battery manager, USB and UCSI supplies)root2on in the kernel; "off" stops a charger plugged in or out (and battery-manager events) from waking the tablet. Left as the kernel has it unless set. USB-C roles are shown read-only (typec/port*)
Board-temperature (thermal) profilequiet-thermal zone, trip_point_{1..8}_temp (passive, 43–50 °C in the device tree; mainline marks every DT trip writable)root2quiet −3 °C, default (DT), performance +8 °C. Allow-list and hard bounds in code: only that zone, only passive trips (never hot, never a chip, PMIC or NSP zone, never mode/policy/emul_temp), the highest trip never above 58 °C (Android's game mode uses 58–62 °C). The DT values are recorded once per boot (/var/lib/tb323fu/thermal-base, with the boot id) so a restarted daemon never takes an offset as the base; a base outside 35–55 °C or out of order is refused. Raising writes the highest trip first, lowering the lowest first. performance ends by itself (persisted as default, signal ProfileFallback) when batt/batt2 reach 45 °C or the board passes its top step by 2 °C, and switches Bypass on while on a charger (Linux has no temperature-based charge-current reduction like Android's thermal-engine). A reboot restores the DT steps until the daemon applies the profile again
Panel heat limitlcm-thermal temperature, /sys/class/backlight/aw99706-backlight/brightnessroot2Android's lcm-thermal policy: from 55 °C the backlight is held at 178/255 (2858/4095), released below 52 °C; the previous brightness comes back unless someone changed it meanwhile. On by default; switching it off asks for authentication
Wi-Fi power saving in the performance profileiw dev IFACE set power_save (nl80211)root2NetworkManager cannot change power saving on an active connection (Reapply refuses 802-11-wireless.powersave), and a connection with default/ignore leaves the interface alone — so the helper sets it with iw, only on interfaces whose applied connection does not set powersave itself (2/3: NetworkManager's), re-checks once a minute, and switches it back on when the profile ends. Connection profiles are never modified. Off by default

Not implemented: see Not exposed in the user guide. Chip protection trips (95/105 °C) are never exposed, and speaker limits stay in layer 1.

D-Bus API#

System bus name io.github.joonhoekim.OpenDeviceHelper1, one object per feature under /io/github/joonhoekim/OpenDeviceHelper1/…. Standard org.freedesktop.DBus.Properties for properties (with PropertiesChanged), plus methods below.

Objects, properties, methods and polkit actions

When the helper asks for a password (10-03): only where a mistake is hard to undo or opens the device to others — writing a kernel to boot_a (install, rollback, install from a file every time), replacing the helper itself (update, rollback), the USB developer mode (a root console for anyone with a cable), and the Android-switch authentication setting itself. Everything else, including the performance thermal profile (its ceiling is fixed in code and the app warns first), turning off the panel protection or the emergency key, the default system and the update channel, is allowed for the active local user. Administrators can tighten this with polkit rules.

Object / interfacePropertiesMethodsSignalspolkit action (default for the active local user)
/…/Battery · io.github.joonhoekim.OpenDeviceHelper1.BatteryChargeLimit (u, %), Bypass (b: limit ≤ capacity with external power → battery idle, current ≈ 0), Status (s, raw kernel), State (s: charging / discharging / bypass / full / not-charging, derived from status + current + limit + the Bypass switch: the firmware keeps saying "Charging" while the battery is held, so with Bypass on, external power and |current| ≤ 300 mA it is bypass (185 mA seen in bypass on a 65 W charger); while the current is still larger — just switched on, or the charger cannot carry the load — it stays charging / discharging; without the switch, "Charging" at or above the limit with |current| ≤ 300 mA is bypass too, shown as "Held at Limit"), Capacity (u), CurrentMa (i), VoltageMv (u), TemperatureC (d), Health (s), CycleCount (i, −1 unknown), DesignCapacityMah (i, −1 unknown), ChargerType (s: UCSI usb_type, e.g. C, PD; USB without UCSI, none), ChargerContract (s: the UCSI contract, e.g. PD 9.0 V 3.00 A; the firmware reports 0 for PD/PPS chargers, then the measured input marked in: PPS · 9.2 V in · ~40 W; unknown / none), ChargerAdapter (s: adapter the battery manager detected — SDP, DCP, CDP, PD, PD_PPS, … — "" unknown), InputVoltageMv (u), InputCurrentMa (u: measured charger input from the battmgr USB supply, 0 unplugged), RechargeGap (u, %: charging resumes this far below the limit — the start threshold; default 10), FullBy (s: HH:MM local time to be full by, "" off), FullByDays (as: mon..sun, empty = every day), FullByActive (b: the limit is at 100 % for FullBy now; ChargeLimit keeps showing the configured limit), StateOfHealth (i, % of design capacity, −1 unknown), OcvMv (u: open-circuit voltage estimate)SetChargeLimit(u), SetBypass(b), SetRechargeGap(u) (3..20), SetFullBy(s time, as days) ("" switches it off)Changed()…charge-limit — allow (yes)
/…/Android · …AndroidAvailable (b: hash file present and boot_b recorded), ImageSha256 (s), RequireAuth (b)SwitchToAndroid(), SetRequireAuth(b) (…admin)SwitchingToAndroid()…android-switch — allow (yes); …android-switch-auth — auth_admin_keep. The daemon checks the second one only when android.require_auth = true in the config (default false)
/…/Torch · …TorchOn (b), Level (u), MaxLevel (u)Set(b), SetLevel(u)—…torch — allow
/…/LedRing · …LedRingMode (s: charge / solid / breathe / off), Brightness (u), LowPercent (u), Color (s: #rrggbb of solid and breathe), Speed (u: breathing cycle, ms), ChargeOverride (b: in solid/breathe the charge colors take over while charging, full or held, or low), NotifyPulse (b: front-ends pulse the ring for desktop notifications — the GNOME extension does)SetMode(s), SetBrightness(u), SetLowPercent(u), SetColor(s), SetSpeed(u) (1000..20000), SetChargeOverride(b), SetNotifyPulse(b), Pulse(s color, u count) (blink 1..10 times, "" = the ring color; for notifications and scripts)—…led-ring — allow
/…/Refresh · …RefreshPolicy (s: off / auto / manual), Rate (u, manual Hz), IdleMs60 (u), IdleMs30 (u), MinHz (u), InputWakes (b), MaxHz (u: full rate of the current display mode, from idle_refresh_state — 120, or 164 in that mode), LiveRate (u, read-only from state)SetPolicy(s), SetRate(u), SetIdle(u ms60, u ms30), ApplyPreset(s) (power-saver 0.5 s/2 s, balanced 1 s/5 s, smooth 3 s/15 s)—…refresh — allow
/…/Gpu · …Gpu (the performance profile; the name stays for compatibility)Profile (s: power-saver / balanced / performance), FollowPowerProfiles (b), Floors (a{s(uu)}: GPU MHz per profile), CpuLimits (a{s(uuuu)}: profile → little min, little max, big min, big max MHz; the whole range without an entry), CpuRange (a{s(uu)}: little / big → hardware MHz, boost not counted), CpuBoost (b: cpufreq boost, the fast cores' 4.512/4.608 GHz; on by default, [cpu] boost), WifiLowLatency (b: Wi-Fi power saving off in performance), WifiAvailable (b: a wireless interface and iw)SetProfile(s) (also stops following), SetFollowPowerProfiles(b), SetLimits(s profile, u min_mhz, u max_mhz), SetCpuLimits(s profile, u little_min, u little_max, u big_min, u big_max), SetCpuBoost(b), SetWifiLowLatency(b)—…gpu — allow (yes); SetProfile("performance") also checks …thermal-performance when the thermal profile follows
/…/Usb · …UsbWakeEnabled (b: USB host/port wakeup from suspend), DevMode (b: USB gadget network + serial console for developers), ChargerWake (b: plugging or unplugging a charger wakes the tablet — the power supplies' wakeup sources; kernel default on, [usb] charger_wake), Ports (a(sssb): USB-C port, data role, power role, partner attached; read-only)SetWake(b), SetDevMode(b), SetChargerWake(b)—…usb-wake — allow; …dev-mode — auth_admin_keep
/…/EmergencyKey · …EmergencyKeyEnabled (b), HoldSeconds (u) — writes /etc/tb323fu/emergency-key.conf (ENABLED, HOLD_SECONDS, 3..30 s) that the layer-1 service reads; the service itself stays in layer 1SetEnabled(b), SetHoldSeconds(u)—…emergency-key — allow; …emergency-key-disable — allow
/…/Diagnostics · …DiagnosticsCrashRecords (u: pstore archive entries), LastBootClean (b: no panic record archived this boot and the previous boot's journal ends with a clean shutdown — a dump-mode crash leaves no pstore record)Export() → s (path of a tarball with pstore, previous-boot journal tail, dmesg head, versions; user names/addresses/serials stripped)—…diagnostics — allow
/…/Boot · …BootRoots (a(ssbs): GPT partition name, os-release PRETTY_NAME, present, init kind systemd/nixos/none, or unknown when it could not be mounted read-only to look, e.g. after an unclean shutdown), Default (s), Next (s, one-shot), Current (s: the partition / came from), RootHealth (a{sas}: root → problems for the running kernel — no audio DSP or Wi-Fi firmware; without shared modules or in own mode also no lib/modules/$(uname -r)/modules.dep, no extra/ (aw882xx amplifier driver), or "own modules"; roots without problems and NixOS roots are left out)SetNext(s), ClearNext(), SetDefault(s), RebootInto(s), Rescan()—…boot-next, …reboot-into, …boot-default — allow
/…/Thermal · …ThermalSurface (d, °C: skin NTC, else quiet), CpuMax (d: hottest cpu-*/cpullc-* tsens zone), GpuMax (d: hottest gpuss-*), Throttling (b: a cpufreq-*/devfreq-* cooling device above state 0), Zones (a{sd}: board sensors by zone type without -thermal: skin, quiet, batt, batt2, usb, usb2-conn, lcm, wlan, ddr, ufs, xo, rear-cam, fcam, wls); NaN = absent , Profile (s: quiet / default / performance as the trips are now, custom when they match none, "" without the zone), Profiles (as, empty when the kernel has no writable quiet-thermal steps), TripOffset (i, °C), Trips (ad: the board steps now, °C), FollowPerformance (b: power-saver → quiet, balanced → default, performance → performance), PerformanceBypass (b), PanelLimit (b), PanelLimited (b: the backlight is held now)SetProfile(s) (also stops following; refused while too hot), SetFollowPerformance(b), SetPerformanceBypass(b), SetPanelLimit(b)ProfileFallback(s reason)…thermal — allow (quiet, default, following, bypass, panel protection on); …thermal-performance — allow (performance, following while the performance profile is in effect, panel protection off; the app warns first, the 58 °C ceiling is fixed in code)
/…/Haptics · …HapticsStrength (u, 0..100 %), Motors (as: left, right)SetStrength(u), Test(s motor) (left / right / both: a 0.3 s buzz at the current strength)—…haptics — allow
/…/Kernel · …Kernel (when boot_a and boot_b exist)Running (s: uname -r), RunningBuild (s: /proc/version), SharedModules (b: /lib/modules/<release> is the boot image's squashfs), Channel (s), Source (s: github:OWNER/REPO), Available (a(sssu): tag, release title, release page URL, serial — the channel's release when newer than the running kernel), Downloaded (as: verified, ready to install), State (s: idle / checking / downloading / verifying / ready / installing / pending-reboot / trial / rolled-back, also rolling-back / keeping / dismissing while those run), Progress (u, % of a download), Trial (s), TrialChannel (s: stable / testing / local), TrialLabel (s), Tries (u), MaxTries (u), KeepPending (b: the running kernel is a trial that waits for Keep), Good (s: release of linux-good.img), GoodLabel (s), LastFailed (s), LastCheck (t, unix time), IndexExpired (b: always false since the GitHub Releases channel; kept for older clients), AutoCheck (b), RequireSignature (b), HelperLatest (s: a newer helper-vX.Y.Z release, "" none), HelperUpdateCommand (s), Message (s: the last operation's result)Check() → s, Download(s tag) → s, Install(s tag, b reboot) → s, InspectLocal(h file) → (s release, s banner, s format, b shared_modules, as warnings), InstallLocal(h file, s name, b auto_confirm, b reboot) → s (a file descriptor the caller opened), Rollback(b reboot) → s, Keep() → s, Dismiss() → s, Notes(s tag) → s, SetChannel(s), SetAutoCheck(b), SetHelperNotify(b), Refresh() (re-read kernel-state; no authorization). The long methods answer when doneFinished(s operation, b ok, s message)…kernel-check (also Dismiss, InspectLocal), …kernel-download, …kernel-keep — allow; …kernel-channel (also SetAutoCheck, SetHelperNotify) — allow; …kernel-install, …kernel-rollback — auth_admin_keep; …kernel-install-local — auth_admin (every time)
/…/Mesa · …Mesa (always; since 0.4.0)Installed (s: version current points at), Previous (s), Available (s: the newest mesa-* release of the kernel channel newer than Installed), Downloaded (s: verified and unpacked, not current), Enabled (b: env.conf selects the build), Trial (s), Tries (u), MaxTries (u), Failed (s: the version switched off after a trial), FailedReason (s), Drivers (as: vulkan, opencl from the MANIFEST), Base (s: upstream Mesa commit), State (s: idle / ready / trial, or the running operation), Progress (u), Message (s)Check() → s, Download(s version) → s ("" the newest), Install(s version) → s, SetEnabled(b) → s, Keep() → s, Rollback() → s, Notes(s version) → sFinished(s operation, b ok, s message)…kernel-check (Check, Notes), …kernel-download, …kernel-keep — allow; …mesa-install (Install, SetEnabled, Rollback) — auth_admin_keep
/…/HelperUpdate · …HelperUpdate (always; since 0.3.0)Version (s), Available (s: the newest helper-vX.Y.Z release newer than this helper, "" none or kernel.helper_notify off), Installable (b: it carries the asset set and this helper may replace itself), Method (s: self / dpkg / pacman / rpm / nix), UpdateCommand (s), Reason (s: why not self, ""), Downloaded (s: a newer version downloaded and checked), Previous (s: what Rollback puts back), State (s: idle / checking / downloading / verifying / ready / installing / interrupted, also rolling-back), Progress (u, %), LastUpdate (a{ss}: result — installing / ok / rolled-back / failed —, kind, from, to, time, reason), Message (s), LastCheck (t)Check() → s, Download(s version) → s, Install(s version) → s (returns once the transient unit runs; the helper restarts), Rollback() → s, Notes(s version) → s ("" = the newest release)Finished(s operation, b ok, s message)…kernel-check (Check), …kernel-download (Download) — allow; …helper-update (Install, Rollback) — auth_admin_keep
/… · …HelperVersion (s), Features (as), Kernel (s), SeriesTag (s: patch-series identity if the kernel exposes it, else unknown), Firmware (a{ss}: file → sha256 match state vs the manifest)Reload()—…admin — auth_admin

Implementation notes: property getters read the device live; a 5 s poller runs the LED-ring charge indicator and the GPU profile follow, re-asserts the USB wakeup setting, and emits PropertiesChanged (changed properties invalidated) when values change underneath; Battery.Changed() fires on state-like battery changes (not on every current sample), Thermal on a change of a whole degree.

Capability detection: each object is only exported when its sysfs interface exists (for example no Refresh object on a kernel without the idle-refresh patch). Inactive/remote sessions get no for everything except reading properties.

Kernel updates#

How kernel updates work in detail. The user's view (channels, Keep, going back): helper.md → Kernel updates. Design and the reasons behind it: kernel-updates-design.md.

What a release is. A GitHub Release (tag e.g. kernel-t31) of the configured repository with a kernel Image, never a boot image: a boot image built by the project would carry Lenovo's header, GKI signature and vbmeta blobs, which must come from your own tablet. So the helper packs the kernel into your own stock boot image — the copy in boot_b, checked against the recorded Android hash first — exactly as tools/boot-repack-kernel.py does during the install (the helper's Rust port gives byte-identical images; checked on a real stock image with a raw and a gzip kernel). The assets:

Asset
Image-tb323fu-tNNthe raw arm64 Image; its release string ends in -tb323fu-tNN (the helper refuses anything else)
Image-tb323fu-tNN.gzthe same, gzip (optional; when present the helper downloads and installs this one — the bootloader decompresses gzip)
SHA256SUMSsha256sum of every file of the release
SHA256SUMS.minisigoptional, only for helpers with require_signature (below)
the release bodythe release notes (Markdown), shown in the app; a line <!-- tb323fu: min_helper=X min_platform=Y --> states the helper and platform versions the kernel needs

tNN is the release's serial: a release is offered when its serial is higher than the running kernel's. Channels: stable (default) offers the newest release that is not a pre-release; testing offers the newest of all, pre-releases included. Releases are prepared with tools/kernel-release.py and published with gh release create.

Source. [kernel] source = "github:OWNER/REPO" in /etc/tb323fu/helper.toml (default github:joonhoekim/tb323fu-linux). A fork publishes the same asset set and its users point source at it — nothing else changes. The helper reads the release list through the GitHub REST API (/repos/OWNER/REPO/releases) without a token; GitHub allows 60 such requests an hour per address, the daily check needs one, and an unchanged list is answered from the cached copy (ETag, a "not modified" answer does not count). When the limit is reached the check says until when. A token is needed only for a private repository (or many tablets behind one address): put a fine-grained token with read-only access to that repository's contents into /etc/credstore/tb323fu-github-token (mode 600). Only the download unit reads it (as a systemd credential), and it is sent only to api.github.com.

Trust. The project's releases are published from the maintainer's GitHub account, which uses two-factor authentication (the account rules: design 3.8). What the helper checks: the file's size, its SHA-256 against SHA256SUMS and against the digest GitHub computed at upload, a bootable format, and the kernel's own version banner (release …-tb323fu-tNN of the asset's name). SHA256SUMS comes from the same place as the kernel, so it catches a damaged download, not a changed release: whoever can publish a release in that repository decides what you install. Two optional steps: require_signature = true with public_keys = ["RW…"] (or /etc/tb323fu/keys/kernel-*.pub) makes the helper also require SHA256SUMS.minisig from one of those minisign keys (off by default; no key is built in) — for whoever runs their own channel. Build attestations (a release built by GitHub Actions, verifiable without any key) are planned: design 3.9.

The flow.

  1. Check (daily when kernel.auto_check is on, or on request): the daemon writes the URLs it wants to /run/tb323fu/kernel-fetch.list and starts tb323fu-kernel-fetch.service — a dynamic user with network access and its cache directory /var/cache/tb323fu-kernel as the only writable place; the daemon itself never opens a network connection. It parses the release list (size-limited) and picks the channel's release.
  2. Download: the same unit fetches SHA256SUMS and the kernel file; the daemon checks them as above and keeps them in /var/lib/tb323fu/kernel/staged/<tag>/.
  3. Install (admin): the staged file is checked again; battery ≥ 30 % or a charger; boot_b must be the recorded Android image; the running kernel must not be on trial itself. If boot_a holds the running kernel, it is saved first as /var/lib/tb323fu/linux-good.img on the state root (tmp file, hash check, rename); otherwise an existing, verified linux-good.img is required. Then the trial record, then the repacked image goes to boot_a, is read back and compared; on a mismatch linux-good.img is written back.
  4. Trial: on each start the initramfs compares the record with /proc/version and counts the start in kernel-state (volume-up held: not counted). The third start of a kernel that never got confirmed writes linux-good.img back into boot_a (checked against its record), records failed= and restarts.
  5. Confirm: stable channel — tb323fu-kernel-confirm.service (platform files, layer 1, so it works without the helper) waits until the system reached graphical.target (or multi-user.target) and 90 s more, on any root, then copies boot_a to linux-good.img and records the kernel as good. Testing channel, pre-releases and kernels from a file — only Keep (settings app, tb323fu-ctl kernel keep) confirms (trial_keep=1); without it the kernel goes back after two more starts. The same unit records a hand-flashed kernel as good once it ran 90 s, so a later trial always has something to go back to.

Android's Switch to Linux writes linux-good.img instead of the saved image when that image is a kernel still on trial or one that failed (android/README.md). A kernel that dies before the initramfs runs cannot be caught (nothing of ours runs). Fastboot is read-only on this bootloader, so the way back is EDL, writing boot_a from a PC — see recovery. The bootloader's A/B retry bits are not used (slot _b is not a working slot here).

A kernel from a file. tb323fu-ctl kernel install-local PATH or Install Kernel from File… in the app installs an Image, Image.gz or a boot image (only its kernel is used) the same way as a release: repacked into your stock boot_b image, linux-good.img kept, written with read-back, tried, rolled back after failed starts. First the helper shows what is in the file — release, /proc/version banner, whether its initramfs carries the shared modules image (/lib/modules/<release>.sqfs; without it every root needs its own modules for that release, own mode, or it starts without them), and warnings. By default such a kernel is kept only when you press Keep (--trial); --keep lets the confirm unit keep it after 90 s like a stable release. --name gives it a label that the status shows. The file is opened by the caller and handed to the daemon as a file descriptor, so the daemon reads only files the caller can read. Authentication: a local install asks for the administrator's password every time (polkit auth_admin, not remembered), while a release install may reuse a recent authentication (auth_admin_keep): a release passed the project's publishing and the helper's checks, a file passed nothing but the choice of the person installing it — it decides what runs as the kernel, with access to everything.

State on the state root (/var/lib/tb323fu/): kernel-state (good=, good_sha256=, good_version=, good_serial=, good_label=, trial=, trial_sha256=, trial_version=, trial_serial=, trial_channel= (stable, testing, local), trial_keep=, trial_label=, tries=, max=, failed=, failed_sha256=; *_version is the /proc/version line, which tells two builds with the same release apart) and linux-good.img (+ .sha256).

Settings ([kernel] in helper.toml): channel (stable), source (github:joonhoekim/tb323fu-linux), api_url (https://api.github.com; another URL — http://, file:// — only for a local test stand-in made with kernel-release.py fake-api), auto_check (true; checks only, never downloads), helper_notify (true: a helper-vX.Y.Z release newer than this helper shows in About and tb323fu-ctl kernel, with what updates the helper on this system — see Helper updates), require_signature (false; kernel and helper releases), public_keys ([]).

Helper updates#

How the helper updates itself in detail. The user's view: helper.md → Helper updates.

What a release is.

Asset
tb323fu-helper-X.Y.Z-aarch64.tar.gzMANIFEST and root/<path>: exactly what helper/install.sh, crates/tb323fu-settings/install.sh and the GNOME extension install with PREFIX=/usr/local
SHA256SUMSsha256sum of every file of the release
SHA256SUMS.minisigoptional, only for helpers with require_signature
the release bodythe notes (Markdown); a line <!-- tb323fu: min_kernel=tNN min_platform=X --> states what the helper needs
anything elseignored (for example the Debian packages of the same version)

MANIFEST (format=1) names the version, arch=aarch64, prefix=/usr/local, min_glibc (the newest GLIBC_x.y symbol the binaries use), the commit, and one file SHA256 MODE SIZE PATH line per file. The helper accepts only these paths — the files install.sh installs, nothing else (ALLOWED_FILES/ALLOWED_DIRS in helperupdate.rs): /usr/local/libexec/tb323fu-{helperd,kernel-fetch}, /usr/local/bin/tb323fu-{ctl,settings}, /etc/systemd/system/tb323fu-{helperd,kernel-fetch}.service, the D-Bus policy and activation file, the polkit policy, the settings app's .desktop/icon/metainfo, /usr/local/share/doc/tb323fu-helper/ and the extension's directory under /usr/local/share/gnome-shell/extensions/. Layer-1 platform files are never part of it. Releases are made with tools/helper-release.py (assets, check) from a release build on the oldest supported system — the Debian 13 (trixie, glibc 2.41) root of the tablet — because binaries built against a newer glibc do not start on an older one; the helper also refuses a release whose min_glibc is newer than the system's (getconf GNU_LIBC_VERSION).

Who updates the helper. Decided by who owns the running daemon binary (and the polkit and D-Bus files in /usr/share), not by the distribution's name:

OwnerMethodWhat happens
none (install.sh, the Fedora and SteamOS builders)selfself-update
dpkg-query -S names a packagedpkgrefused; "install the new version's .deb files (apt install ./tb323fu-helper_*.deb)"
pacman -Qopacmanrefused; build packaging/arch/PKGBUILD and pacman -U it
rpm -qfrpmrefused; update the package
/etc/NIXOS or a /nix/store pathnixrefused; nix flake update tb323fu-linux && sudo nixos-rebuild switch

A self-update over an install at /usr that nobody owns puts the new version in /usr/local (and the unit in /etc/systemd/system, which overrides the one in /usr/lib); the /usr copy stays underneath.

The flow.

  1. Check: the same release list as kernel updates (one request through tb323fu-kernel-fetch.service; the daily check covers both). Available is the newest helper-v release (not a pre-release) newer than this helper, shown while kernel.helper_notify is on.
  2. Download (allowed without a password): SHA256SUMS and the tarball through the fetch unit; the daemon checks size, SHA-256 against SHA256SUMS and GitHub's digest, the signature when required, then unpacks it (plain ustar, regular files only, safe names) into /var/lib/tb323fu/helper/staged/<version>/tree and checks MANIFEST: version = tag, arch, prefix, allowed paths, the daemon, CLI and unit present, every file's size and hash, nothing unlisted.
  3. Install (polkit helper-update, auth_admin_keep like a kernel install): refused when a package manager or NixOS owns the helper, when the release is not newer, when min_kernel (the running kernel's tNN; a development kernel passes), min_platform (platform-version) or min_glibc is not met. The tree is checked again, then the daemon starts this binary as tb323fu-helperd --apply … in a transient unit (systemd-run --unit=tb323fu-helper-update), outside the daemon's sandbox and outliving its restart:
    • a snapshot of every path the release names (and every file the previous self-update installed) into /var/lib/tb323fu/helper/backup/ — missing paths are recorded as absent; a directory or symlink in the way stops the update before anything changes;
    • each new file is written next to its target (*.tb323fu-new), then all are renamed over their targets (each file changes atomically); files of the previous self-update that the new release no longer has are removed;
    • systemctl daemon-reload, D-Bus ReloadConfig, systemctl restart tb323fu-helperd;
    • health check: within 30 s the restarted daemon answers on the system bus with the new Version, and polkit knows the helper's actions (the policy file loaded — a malformed policy blocks every call);
    • passed: the snapshot becomes /var/lib/tb323fu/helper/prev/; failed: the snapshot goes back the same way, the old daemon is restarted, update-state says rolled-back and why.
  4. Rollback (helper-update): the same process with prev/ as the source, so a rollback can be rolled back. An update cut off half-way (power loss) shows State interrupted; Rollback then uses the snapshot of that update.

The settings app needs a restart to show its new version, and the GNOME extension's new code loads at the next login (Wayland). State: /var/lib/tb323fu/helper/ (staged/, current.manifest, prev/, update-state, update.log); the transient unit's log is journalctl -u tb323fu-helper-update.

Trust. The same as for kernel releases: whoever can publish releases in kernel.source decides what runs as root on the tablets that update. SHA256SUMS and GitHub's digest catch damaged downloads, not a changed release; require_signature (minisign, optional) applies to helper releases too. What it rests on: two-factor authentication on the publishing account, a tag ruleset for helper-v* and kernel-* (only the maintainer creates, nobody updates or deletes) and immutable releases once the repository is public (design 3.8). Build attestations verified by the helper are plan 2 (design 3.9); a helper that updates itself changes its own verifier, so it should require them before the kernel channel does.

Compatibility. Front-ends of one version talk to daemons of another (the app and the extension may be newer or older than the daemon). Rule for the D-Bus API: within io.github.joonhoekim.OpenDeviceHelper1 changes are additive only — new objects, properties, methods and signals; existing names, argument types and meanings stay. A change that breaks this gets a new name (OpenDeviceHelper2), served next to the old one while front-ends move. Front-ends treat a missing object or property as "not supported" (the app hides the row; the CLI says the helper is older).

Packaging#

Files (paths for a normal FHS distribution):

FilePath
daemon/usr/libexec/tb323fu/tb323fu-helperd (packages; /usr/local/libexec/ for a manual install)
CLI/usr/bin/tb323fu-ctl
systemd unit/usr/lib/systemd/system/tb323fu-helperd.service (Type=dbus, BusName=io.github.joonhoekim.OpenDeviceHelper1, WantedBy=multi-user.target, hardening: ProtectSystem=strict, ReadWritePaths=/etc/tb323fu /sys)
D-Bus policy/usr/share/dbus-1/system.d/io.github.joonhoekim.OpenDeviceHelper1.conf (own: root; send: everyone; polkit decides)
D-Bus activation/usr/share/dbus-1/system-services/io.github.joonhoekim.OpenDeviceHelper1.service (SystemdService=)
polkit/usr/share/polkit-1/actions/io.github.joonhoekim.opendevicehelper.policy
kernel download/usr/libexec/tb323fu/tb323fu-kernel-fetch + /usr/lib/systemd/system/tb323fu-kernel-fetch.service (oneshot, started by the daemon only: DynamicUser=yes, CacheDirectory=tb323fu-kernel, network, nothing else writable, optional credential tb323fu-github-token; needs curl)
helper self-updateno files of its own: the daemon binary itself (--apply) in a transient unit tb323fu-helper-update.service; state in /var/lib/tb323fu/helper/
kernel signing keysnone shipped (signatures are optional: require_signature); administrators who want them put keys in /etc/tb323fu/keys/kernel-*.pub or public_keys. install.sh removes the key of the earlier signed-index channel
kernel confirmationtb323fu-kernel-confirm.service + /usr/libexec/tb323fu/tb323fu-kernel-confirm — in the platform package (layer 1), enabled with the other platform units
GNOME extension/usr/share/gnome-shell/extensions/tb323fu@joonhoekim.github.io/ (separate package)
settings app/usr/bin/tb323fu-settings, .desktop, icons (separate package)

Recipes (in packaging/, plus flake.nix at the repository root):

DistributionRecipePackagesStatus
Debian / Ubuntupackaging/debian/build-debs.sh (dpkg-deb, no debhelper)tb323fu-platform, tb323fu-helper, tb323fu-settings, tb323fu-helper-gnomebuilt on the device (arm64), contents checked with dpkg -c; tb323fu-settings install + remove tested
Arch Linux ARMpackaging/arch/PKGBUILD (split package, aarch64)same foursyntax only (bash -n); no Arch system tried
NixOSflake.nix → packaging/nix/*.nix, modules nixosModules.default (services.tb323fu) and nixosModules.rootfs (rootfs/nixos)same fourbuilt on the device (aarch64, nixos-unstable 2026-09-29) and installed with rootfs/nixos/build-rootfs.sh; first boot not yet verified
  • Debian: tb323fu-helper Depends: dbus, polkitd | policykit-1, systemd, enables tb323fu-helperd; tb323fu-platform Depends: systemd, udev, bluez, Recommends: PipeWire/WirePlumber, alsa-ucm-conf, iio-sensor-proxy, hexagonrpcd, qrtr-tools, rmtfs, tqftpserv; its postinst enables the platform units and masks bootmac-bluetooth when present. /etc/tb323fu/* are conffiles.
  • Arch installs the daemon under /usr/lib/tb323fu/ (Arch has no libexec); .install files print the enable commands.
  • NixOS: see the module details below.
  • back-to-android ships with the platform package (/usr/sbin); the helper looks it up in PATH first, then /usr/local/sbin, /usr/sbin, /usr/libexec/tb323fu. The Android image hash is read from /etc/tb323fu/android-boot.sha256 (older /etc/android-boot.sha256 as fallback).
NixOS module details

The platform package is installed with PREFIX=$out (install.sh rewrites /usr/libexec/tb323fu to the store path); /etc/tb323fu stays mutable and gets its defaults once through systemd.tmpfiles C rules. The module wires udev rules, systemd units (with path for the tools the scripts call), D-Bus, polkit, PipeWire/WirePlumber configPackages, a merged ALSA_CONFIG_UCM2, LIBCAMERA_IPA_CONFIG_PATH, and options helper.enable, settingsApp.enable, gnome.enable, android.requireAuth (initial value), androidBootSha256.

Also: hexagonrpcd (nixpkgs hexagonrpc) + iio-sensor-proxy when sensors.dataDir points at the device's sensor files; the swh LADSPA plugins through services.pipewire.extraLadspaPackages; hardware.wirelessRegulatoryDatabase; RuntimeWatchdogSec=30s; the units' /lib/firmware is rewritten to /run/current-system/firmware (NixOS links hardware.firmware there; the module keeps it uncompressed). The [Install] sections of packaged units do not apply on NixOS, so the module sets the same wantedBy targets.

Tests#

Test plan (without and on the device)

Without the device

  • Run the daemon against a fake sysfs root (TB323FU_SYSFS_ROOT=/tmp/fake-sys with the relevant files) on a private D-Bus (dbus-run-session): property reads, setters write the right values, capability detection hides objects whose files are missing, persistence round-trips, polkit denials return the right D-Bus error (with a test policy).
  • CLI golden-output tests against the same fake daemon.
  • Kernel updates (tests/kernel-update-test.sh, dbus-run-session, needs python3, curl, minisign): releases prepared with tools/kernel-release.py assets and published on a local stand-in of the GitHub API (kernel-release.py fake-api, file://); stable picks the release, testing the pre-release; check → notes (the release body) → download → install (the repacked boot_a must equal tools/boot-repack-kernel.py's image byte for byte, linux-good.img = the old image) → a "restart" on the new kernel → trial, Keep → linux-good; refusals: low battery, boot_b not the recorded image, install before download, a staged file changed after the download, reinstall of the running release, a damaged download (SHA256SUMS); require_signature: no signature, no key, another key, then a good one; a kernel from a file: inspect (shared modules or not, a boot image, not a kernel), install-local without --yes and no terminal, --trial (Keep, label) and --keep, refused while on trial; rollback. cargo test covers the same in the library (install/confirm/rollback on files, a read-back mismatch restores linux-good.img), and cargo test -- --ignored real_images compares the Rust repack with the Python tool's on real images.
  • Helper updates (tests/helper-update-test.sh, dbus-run-session, needs python3, curl): install.sh into a fake root as the installed version (from Cargo.toml), releases made with tools/helper-release.py (--stage, the daemon a wrapper that reports another version) on the file:// stand-in, the daemon started from the fake root and restarted by a script; check, notes, owned by dpkg (a fake dpkg-query) and NixOS (/etc/NIXOS): refused with the hint, a damaged download, require_signature, min_platform, update (files = the release tree, files outside the manifest untouched, prev/ kept), rollback (and prev/ then holds the newer version), a release whose daemon does not start (automatic rollback, the reason in LastUpdate), a path outside the allow-list (refused by helper-release.py assets and check and by the daemon at download). cargo test covers the manifest, the tar reader, unpack checks, snapshot/apply/back with symlinks and damaged sources, and owner detection.
  • Mesa channel (tests/mesa-update-test.sh, dbus-run-session, needs python3, curl): releases made with tools/mesa-release.py from a stage of small files on the file:// stand-in; check, on refused before an install, a damaged tarball (nothing unpacked), download, install (ICD files point into current/, env.conf empty while off), on (env.conf variables, trial), the tb323fu-mesa wrapper (run, distro, env), the boot count (TB323FU_BOOT_ID: one boot counted once, the third start without Keep switches off and records Failed), Keep (stays on), a second version on trial, rollback, off. cargo test covers release parsing, unsafe paths and the trial state.
  • The initramfs side (kernel/initramfs/test-root-selection.sh: start counting, rollback on the third start, a linux-good.img that does not match its record, a failing boot_a write, volume-up), the confirm script (userspace/platform/test-kernel-confirm.sh) and Android's choice (android/test-state-root.sh) run offline too.

On the device

  • Charge limit: set 60/80 with a PD charger attached; threshold file and charging state follow; the UPower hint is absent (GNOME's switch gone).
  • Android switch: Available false without the hash file; with it, the auth prompt appears and the switch works (then return to Linux).
  • Refresh: policy auto/off/manual, idle presets; the live rate follows (frame-counter check), overview stays open, suspend while stretched resumes at the mode's full rate; in the 164 Hz mode MaxHz is 164 and SetRate takes up to 164.
  • Torch and LED ring: visual check (a person watches).
  • Daemon killed or not installed: boot, audio, sensors and the emergency chord still work.

View this page's Markdown on GitHub