Native Linux support for the Flydigi Apex 5: ForceAdapt adaptive triggers, profiles and button remapping, macros, sticks, vibration, lighting, the pad's device settings and its 160x80 screen — without Flydigi's Windows-only Space Station app. The CD2 charging dock is driven too, as a device of its own.
The Apex 5 is the pad this was built and measured on; the name is the project's, not the model's.
flydigi/ is pure Python with no dependencies; only the desktop app needs Qt, and every tool
that talks to the pad runs on a machine without it.
Force effects and trigger haptics both run over /dev/hidraw, verified on hardware. Six routes
drive them, from a vibration bind the pad plays by itself to a virtual DualSense that carries a
game's PS5 haptic audio to the pad's motors. The 160x80 screen takes pictures and animations
over the same serial route Space Station uses, plus the always-on display and status bar: ~25 s
a frame, wired connection only.
PROTOCOL.md has the wire protocol, PROGRESS.md the project state and the per-route tier table, docs/ the long-form findings behind each route.
- Linux with
hidraw(/dev/hidraw*readable — usually already the case) - Python 3.9+
- The udev rules in
udev/, installed withtools/apex5-setup install-rules: they tag the vendor hidraw node (37d7:2501), the screen'sroot:dialoutbootloader tty (ffaa:5555),/dev/uhid, and the virtual DualSense's input sub-devices. Without the last, touchpad-click is silently dead — that event is reported on the touchpad node, not the gamepad node; without the tty rule a screen upload switches the pad into its bootloader and then cannot open the port. The file must stay numbered below 73, or systemd's73-seat-late.rulesruns beforeTAG+="uaccess"is set and nothing ever acts on it.install-rulesalso deletes a leftover99-flydigi-apex5.rules, which never applied for the same reason;checkfails while one is present, even with the new file in place. - For the memory monitor:
kernel.yama.ptrace_scope = 0, and a host process — the route cannot run from inside a container at all (see Desktop app) - For the virtual DualSense with haptics: the
vhci-hcdmodule (=mon every distribution checked), and one authentication to attach the virtual USB device - For the virtual DualSense without haptics:
/dev/uhidwritable - For either DualSense route: Steam Input off for that game — it masks the pad as an Xbox controller and breaks DS5 semantics
- For
tools/flydigi-screen show,animate,convertandpreview: Pillow (pip install pillow);checkdoes not need it, and neither doesflydigi/screen.py. - For
tools/flydigi-haptics:parecandpactlfrom pulseaudio-utils.pw-recordcannot substitute — it prepends a file header. pkexecforinstall-rulesand the app's DualSense switch. Inside a Flatpak that runs throughflatpak-spawn --host, and inside a distrobox throughhost-spawnordistrobox-host-exec, since pkexec cannot reach polkit from in there.
Every current Flydigi pad opens the same way. find_device matches on the vendor id, the
product id's top nibble and the vendor collection's report-descriptor prefix, which every pad of
the 5a a5 generation shares — a Vader 5 or an Apex 6 would open exactly like an Apex 5.
Nothing older can reach it at all: IsOldProtocol() is VendorId != 0x37D7, so an Apex 3/4, a
Vader 3/4 or a Direwolf is an XInput device with no HID vendor collection, cabled or on its
dongle alike, and only Windows sees HID there because xusb22 synthesises it. Profile and
settings writes are therefore gated on a command-1 DeviceType read that refuses anything but an
Apex 5 (k5).
flydigi-mapping, flydigi-settings and the app call that gate once per connection, so their
reads are refused too; the trigger-effect and screen tools are ungated, and flydigi/ itself
gates nothing — the caller decides. With two Flydigi pads attached, nothing chooses between
them by itself: find_device returns the first match in sorted-by-name /dev/hidraw* order
(hidraw10 before hidraw2). Controller(path=...) takes a node, but of the day-to-day tools
every tool takes --device, which resolves a node, a uid, a mac or a nickname.
The nibble is what keeps the charging dock out. Flydigi number their device families in the
top nibble of the product id — controllers 2, chargers 6, coolers 1 — and their three
HidManagers key on exactly that. It matters here because the CD2 dock is vendor 37d7 too
and its report descriptor also begins 06 a0 ff: for as long as those two were the whole
test, find_device returned the dock whenever the dock's node sorted first, which happens
every time the pad sleeps and leaves the USB bus. Measured on this desk, with the pad asleep,
it returned the dock. Pad-framed packets carry report id 0x03 where the dock expects 0x00,
so the dock ignores them rather than acting on them — the damage was confusion, not corruption,
but the tools sat there getting silence from a device that was wide awake.
tools/flydigi-charger drives the dock deliberately, and picks between docks with --device.
Two of the same kind are told apart by uid. flydigi/registry.py enumerates every Flydigi
device on the bus and resolves a selector — a node path, a uid or a prefix of one, a mac, or a
nickname — to exactly one of them, refusing an ambiguous name rather than guessing;
tools/flydigi-devices list prints all four. The pad's own address field, which would have been
free, reads all zeroes on this hardware, so the uid (one exchange) is what anything stable is
keyed on. The desktop app has a picker, and the daemon reads the pad it chose.
Run from the repository root.
# what the controller exposes
python3 tools/hid_probe.py
# talk to it directly
python3 tools/flydigi_cmd.py info
python3 tools/flydigi_cmd.py race right --stroke 40 --resistance 30
python3 tools/flydigi_cmd.py normal both
# the profiles stored in the pad: remapping, macros, backup/restore
tools/flydigi-mapping list # --profiles N, before the subcommand, scans N slots (4)
tools/flydigi-mapping backup 0 profile0.bin # back up before writing; this is the only copy
# the file is the raw blob, no header; restore only checks its length against the pad's
tools/flydigi-mapping set 0 m1 a --save # --save commits to flash
# --turbo N repeats the target while the key is held, --turbo-toggle latches it instead
tools/flydigi-mapping macro-record 0 m1 --seconds 8 --save
tools/flydigi-mapping gyro 0 right --key m1 --sensitivity 60
# the pad drives the stick itself, so it works in any game; --key is required because
# the factory leaves two buttons in the enable-key bytes and neither was chosen
# the pad's own device settings
tools/flydigi-settings show # the whole block, one read
tools/flydigi-settings sleep 30 # minutes, or `never`
# report-rate and xbox-home need --i-know
# auto-apply per-game config when a game starts
tools/flydigid # --interval 1.0 s; --reassert N re-applies every
# N seconds against Steam Input, 0 (default) is off
# it logs to stdout; under its unit that is the user journal, journalctl --user -u flydigid
tools/flydigi-auto on "Death Stranding"
# only the vibration route acts by itself; routes that spawn a process, read another
# process's memory or take the controller over default to off. State: ~/.config/flydigi/games.json
# per-game via Steam launch options -- vibration-preset games only
/path/to/flydigi-run "DEATH STRANDING 2" -- %command%
# Forza: enable Data Out (127.0.0.1:5300) in game, then
tools/flydigi-forza
tools/flydigi-forza --accept 331:12 # accept a Data Out packet size the parser does not know
# yet. Built in: 311 (offset 0) and 324 (offset 12), which covers every shipped Forza.
# The packet's field layout is in flydigi/forza.py.
# any DualSenseX-compatible mod, on UDP 127.0.0.1:7878
tools/flydigi-dsx
tools/flydigi-dsx --forward 8787 # also relay onward to the port Flydigi's own software uses
# game-memory driven effects (Dark Souls, Cyberpunk, Elden Ring, ...)
tools/fetch-configs --monitor-configs
tools/flydigi-monitor --probe configs/monitor/<game>.json # check offsets first
tools/flydigi-monitor configs/monitor/<game>.json
# pointer chains are per-build: a game update breaks a config until Flydigi ship new offsets
# present the pad to games as a DualSense (triggers, gyro, battery, haptics)
# -- the desktop app's DualSense switch does the same behind one authentication
sudo tools/flydigi-ds5-usbip --haptics --motors
# root is given back as soon as the USB attach is done
# set per-game: SDL_GAMECONTROLLER_IGNORE_DEVICES=0x37d7/0x2501 SDL_JOYSTICK_IGNORE_DEVICES=0x37d7/0x2501 %command%
# both spellings: SDL3 renamed the hint, and a game ignores the one it does not know
# start the game *after* this
# the Apex 5 may sleep and wake as it likes: it leaves the USB bus when it
# sleeps, but the virtual DualSense stays attached, so the game should keep
# it. Covered by tests/test_relay.py against a fake bus; not yet confirmed
# in a real game -- see PROGRESS.md
tools/flydigi-ds5 # the same without haptics, no root,
# for a machine with no vhci-hcd
# the 160x80 screen
tools/flydigi-screen status
tools/flydigi-screen show photo.jpg # ~25s; the pad reboots itself after
tools/flydigi-screen animate spin.gif # ~25s per frame, so keep it short
tools/flydigi-screen off # blank the panel -- Space Station cannot
# the CD2 charging dock -- a separate device, not part of the pad
tools/flydigi-charger show # firmware, uid, the four switches, the lighting header
tools/flydigi-charger light pulse # 162 LEDs, 50 frames, ~5s to upload
tools/flydigi-charger light breath --colour ff0055 --brightness 80
tools/flydigi-charger light rainbow --direction left
tools/flydigi-charger lighting-sync on # keep the dock in step with the pad
# the dock has no effect generator: a mode is 24 kB of frames computed here and uploaded,
# which is why `light` takes seconds rather than being one write
# `default` (the Flydigi logo) is not offered -- Space Station does not compute that one
# either, it uploads a file its installer ships and this repository does not have it
tools/flydigi-charger list # every dock on the bus; --device picks one
# every Flydigi device attached, whichever kind
tools/flydigi-devices list # node, uid, model, firmware, battery, nickname
tools/flydigi-devices show --device Couch
tools/flydigi-devices name "Couch" --device uid:1420 # up to 26 bytes; it asks first
# every other tool takes the same --device: a node, a uid or a prefix, a mac, or a nickname
# devices nobody owns, for trying any of the above with more than one attached
FLYDIGI_MOCK_BUS='pad=Desk,pad:f5=Couch,dock:1=Shelf' tools/flydigi-devices list
# the same variable works for the daemon and the desktop app; a JSON file re-read on every
# scan lets a device be unplugged mid-session. Nothing appears unless it is set
# system setup: udev rules, the daemon's unit, autostart, the menu entry
tools/apex5-setup # the checklist
tools/apex5-setup install-rules # the one subcommand that needs root
# there is no install step: the unit and the menu entry hard-code this checkout's path, so
# moving the repository means writing both again| Tool | What it does |
|---|---|
tools/apex5-setup |
check (default), install-unit/remove-unit (a flydigid.service user unit in ~/.config/systemd/user), enable/disable/start/stop, install-desktop/remove-desktop (openflydigi.desktop, and it removes the older flydigi-apex5.desktop), install-rules — the same functions the app's Setup page drives in-process |
tools/fetch-configs |
Flydigi's game list, the Forza rule config, the monitor configs, the mod archives |
tools/flydigi_cmd.py |
one-off vendor commands: device info, trigger effects, rumble |
tools/flydigi-auto |
which games the daemon may act on by itself, and which route it takes |
tools/flydigi-charger |
the CD2 charging dock: show, list, its four switches, and light <mode> over the eight computable effects |
tools/flydigi-devices |
every device attached: list, show, and name to nickname one (which Space Station cannot do correctly) |
tools/flydigid |
daemon: detect a running game, apply its route, reset on exit |
tools/flydigi-ds5 |
virtual DualSense over uhid |
tools/flydigi-ds5-usbip |
virtual DualSense over usbip + vhci, with haptic audio |
tools/flydigi-dsx |
DSX listener, UDP 127.0.0.1:7878 |
tools/flydigi-forza |
Forza Data Out telemetry through the rule engine |
tools/flydigi-haptics |
Apex 5 rumble driven from a DualSense's haptic-audio sink |
tools/flydigi-mapping |
profiles: list/show/set/clear/rename/apply, backup/restore, macro record and bind, gyro |
tools/flydigi-monitor |
trigger effects from game memory, per Flydigi's XGameMonitor configs |
tools/flydigi-run |
Steam launch wrapper for one named game |
tools/flydigi-screen |
the 160x80 screen: pictures, animations, always-on, status bar |
tools/flydigi-settings |
the pad's device settings block: sleep timer, quick-switch, stick debounce/rebound/auto-calibration, precision, sensitivity, status bar, always-on, restart. Every write but restart is followed by a read of the whole block |
tools/hid_probe.py |
what the pad exposes on the HID bus |
The other twelve files in tools/ are not day-to-day: eight hardware probes, the offline
simulators forza-simulate and haptics-simulate, gen_ds5_usb.py (regenerates
flydigi/ds5_usb.py from descriptors captured off a real DualSense) and generate-qmltypes
(GUI build tooling). They are listed individually in PROGRESS.md.
Profiles, remapping, macros — recorded off the pad or built from nothing, then edited step by step — sticks, the gyro, vibration, triggers, lighting, device settings, the screen and per-game routes — plus a Devices page and a picker for every pad and dock attached, a Dock page for whichever charging dock is selected, a DualSense switch that turns the virtual-DualSense relay on for the whole system, and a Setup page for the daemon's unit, autostart, menu entry and udev rules. The sidebar shows the pages of the device you have selected. QML on Kirigami:
# Fedora / KDE
sudo dnf install python3-pyside6 kf6-kirigami kf6-kirigami-addons \
kf6-qqc2-desktop-style
python3 -m guiNot a pip install. PySide6 from PyPI bundles its own Qt, built with different
private-symbol versioning from the distribution's, and Kirigami will not load against it — the
two are mutually incompatible, in both directions. The app needs the distribution's PySide6,
built against the same Qt as Kirigami. On an immutable system a distrobox with those packages
works without layering anything; see gui/README.md.
Measured from inside the distrobox, every route works except the memory monitor, which needs a cross-process read no container grants; the per-route table is in docs/findings-games.md.
Plain scripts, no test runner. The backend tests need nothing but Python:
python3 tests/test_device.py # and every other tests/test_*.pyThree are Qt-dependent instead: tests/test_models.py needs the distribution's PySide6, and
tests/test_shell.py, tests/test_qml.py and the ten tests/qml/tst_*.qml cases the last of
them drives need Kirigami as well, so they run wherever the app does. Their invocations, and
tools/generate-qmltypes, are in gui/README.md.
No decompiled sources, extracted assemblies, mod binaries or game configs are here (see
Disclaimer). Everything gitignored is reproducible: gamelist.json, configs/
and mods/ are fetched on demand, and decompiled/, bundle/, asar/, bin/,
installer-listing.txt and the work/ scratch tree are produced locally. To reproduce:
- Fetch the game list, configs and mods from Flydigi's public, unauthenticated API
(
GET https://api.flydigi.com/pc/adapter_trigger/list):tools/fetch-configs # gamelist.json + configs/forza.json tools/fetch-configs --monitor-configs # those, plus configs/monitor/*.json tools/fetch-configs --all-mods # those, plus mods/, ~44 MB
- For protocol work, decompile Space Station yourself; the toolchain and the steps are in PROGRESS.md.
This is an independent project. It is not affiliated with, authorised by, or endorsed by Flydigi. "Flydigi", "Space Station" and "Apex 5" are used only to identify the hardware and software this project interoperates with; any trademarks belong to their respective owners.
The protocol was determined by reverse engineering Flydigi's own software for the sole purpose of making hardware you already own work on Linux — an interoperability purpose expressly permitted in some jurisdictions (in the EU, Directive 2009/24/EC Art. 6, which Art. 8 protects from contractual override) and treated differently in others. Check what applies where you are.
No Flydigi code, assembly, asset, game config or mod binary is included or
redistributed here. tools/fetch-configs contacts Flydigi's public API only
when you run it, and stores what it fetches locally without republishing it.
Provided as-is, with no warranty. Writing configuration to a controller carries the ordinary risk of writing to any device's flash.
Per-file, following REUSE: code carries inline SPDX headers, prose
and gui/ are annotated in REUSE.toml, full texts are in LICENSES/, and reuse lint
verifies it.
flydigi/, tools/, tests/ |
MIT, except the Qt-dependent files — tools/generate-qmltypes, tests/test_models.py, tests/test_qml.py, tests/test_shell.py, tests/qml_harness.py, tests/qml/*.qml — which are GPL-3.0-or-later. tests/qml/four-frames.gif is CC0-1.0 |
README.md, PROGRESS.md, docs/ |
MIT |
PROTOCOL.md |
CC0-1.0 |
udev/, pipewire/ |
CC0-1.0 |
gui/ |
GPL-3.0-or-later |
pyproject.toml |
GPL-3.0-or-later — it describes gui/ to PySide6's tooling |
.gitignore, LICENSE, NOTICE, REUSE.toml |
CC0-1.0 |
Copyleft is confined to what links Qt: gui/ may import flydigi/ and never the reverse, which
is what keeps the backend independently reusable and dependency-free. See LICENSE for
the reasoning and gui/README.md for the import rule.
The DualSense report layouts in flydigi/ds5.py follow
inputtino, MIT licensed, and the
placeholder Bluetooth addresses in flydigi/ds5_usb.py are inputtino's public
ones. The descriptors and feature-report blobs themselves are read from
hardware — see NOTICE.
The GUI adds PySide6, which is LGPLv3 — that only creates obligations if you ship Qt yourself.
- Source / PyPI / AUR / COPR — you distribute no Qt at all (pip or the distro provides it), so there is nothing to discharge.
- AppImage — bundles Qt, so it is conveyed as a whole under GPL-3.0. Include
LICENSES/in the image, name the bundled PySide6 and Qt versions in the release notes with a link to their sources, and use PyInstaller--onedirrather than--onefileso the libraries stay swappable. Corresponding source is satisfied twice over: it is a Python app, so the source is inside the bundle, and the git tag sits next to the download. - Flatpak — cleanest licensing (Qt comes from
org.kde.Platform) but the daemon needs the host process table and the memory route needs a host-side cross-process read, which no container grants. Only viable once the GUI talks to a host daemon over D-Bus.