A macOS background daemon that turns a TikTok Touch Ring J09 (zhuhai_jieli,
USB VID 0x248A / PID 0x8251) into a gesture remote: swipe for arrow
keys or scrolling, tap for Enter, double-tap to switch modes, and two
physical buttons for Dictation and the app switcher — plus a small
per-app override system for tweaking behavior in specific apps.
The ring already works out of the box as a Bluetooth mouse, but swipe
gestures don't move the cursor and most of its buttons are invisible to
standard macOS input handling. This project reads the ring's raw BLE HID
reports directly (via hidapi) and turns them into real keyboard/mouse/
scroll events using CGEvent (pyobjc/Quartz).
Works with any J09 unit — nothing here is tied to a specific ring's serial number, only to the model's fixed vendor/product ID and firmware.
| Gesture | Default action |
|---|---|
| Swipe up/down/left/right (arrows mode, default) | Arrow key press |
| Swipe up/down (scroll mode, toggle with double-tap) | Continuous scroll |
| Swipe left/right (scroll mode) | Cmd+Shift+] / Cmd+Shift+[ (previous/next tab) |
| Single tap | Enter |
| Double tap | Toggle arrows/scroll mode |
| Triple tap | Escape |
| Button O | Toggle Dictation |
| Button II | Open/close/step through the Cmd+Tab app switcher |
Per-app overrides included as examples (see profiles.py):
- Chrome/Safari: Button O toggles the mic instead of Dictation when a Google Meet tab is active.
- Slack: swipe-up in scroll mode opens Quick Switcher (Cmd+K) instead of scrolling.
- Cursor: reduced scroll speed, plus an extra focus fix after tab-switching (see Notes on quirks below).
Button I on the ring produces no BLE HID report at all on this hardware — it can't be mapped to anything.
- macOS (developed/tested on Sonoma; uses
CGEvent/Quartz, which is macOS-only) - Python 3.9+
- Homebrew
- A TikTok Touch Ring J09
brew install hidapi
python3 -m venv venv
source venv/bin/activate
pip install hid pyobjc-framework-Quartz pyobjc-framework-CocoaThat's it for dependencies — ring_daemon.py auto-detects hidapi's install
location via brew --prefix hidapi at startup, so no manual
DYLD_LIBRARY_PATH setup is needed.
Pair the J09 normally first: System Settings → Bluetooth → click it in the device list. macOS will show it as a standard Bluetooth mouse — that's expected, the daemon reads its raw HID reports directly rather than doing its own separate Bluetooth connection.
Two separate permissions are required, both under System Settings → Privacy
& Security. Grant them to whatever app actually runs the Python process —
Terminal.app, iTerm2, VS Code's integrated terminal, etc. (whichever
terminal you use to launch ring_daemon.py):
- Input Monitoring — needed so the
hidpackage can read raw HID reports from the ring. Without this, the daemon connects but every gesture silently does nothing (reports never arrive). - Accessibility — needed so
CGEventcan synthesize the resulting keyboard/scroll/mouse events. Without this, gestures are detected (you'll see log lines) but nothing happens on screen.
macOS should prompt for both automatically the first time each is needed. If a prompt doesn't appear (or you accidentally dismiss it), add your terminal app manually: open the relevant settings pane, click +, and select the terminal app from Applications. You'll need to restart the terminal app (fully quit and reopen, not just close the window) after granting either permission for it to take effect.
Button O simulates Control+Option+Command+D. Set that as your actual
Dictation shortcut in System Settings → Keyboard → Dictation, or edit
DICTATION_MODIFIERS/DICTATION_KEYCODE in actions.py to match whatever
shortcut you already use.
source venv/bin/activate
python ring_daemon.pyIt auto-reconnects if the ring goes out of range or gets forgotten/re-paired in Bluetooth settings — no need to restart the script.
Set RING_DEBUG=1 for verbose per-report logging, useful when tuning
gesture thresholds or diagnosing a new app's quirks:
RING_DEBUG=1 python ring_daemon.pyprofiles.py is the file to edit for new bindings. Every gesture action
goes through ActionRegistry, which checks APP_PROFILES[<frontmost app's bundle id>] before falling back to DEFAULT_ACTIONS. To add a new
per-app override:
APP_PROFILES = {
"com.apple.Terminal": {
"arrow_left": lambda: my_custom_action(),
},
...
}Find any app's bundle id with:
osascript -e 'id of app "App Name"'Gesture timing/threshold tunables (how far a swipe must travel to fire, how
fast a double/triple tap must be, etc.) live at the top of gesture.py.
| File | Responsibility |
|---|---|
ring_daemon.py |
Entry point; bootstraps the hidapi library path |
daemon.py |
Main loop: connect, read reports, dispatch, auto-reconnect |
hid_protocol.py |
BLE HID wire format (report parsing), device discovery |
gesture.py |
Touch/tap/swipe interpretation (GestureState) |
buttons.py |
Button press handling (ButtonState) |
app_switcher.py |
Cmd+Tab app-switcher state (AppSwitcher) |
profiles.py |
Per-app action overrides (ActionRegistry) — start here to customize |
actions.py |
Concrete action implementations (dictation, tab-switch, Meet toggle) |
system_events.py |
Low-level CGEvent synthesis (key presses, scroll, mouse move) |
mac_system.py |
Frontmost app/window queries, mouse-focus fix |
keycodes.py |
Raw macOS virtual keycode constants |
logging_utils.py |
Debug flag + timestamped logging |
The ring's BLE GATT vendor characteristics never emit data (the swipe
protocol is likely gated behind a handshake only the official app sends).
The actual swipe/touch data instead shows up as a standard HID digitizer
report (0x0d usage page) that macOS's own HID stack already receives —
readable directly via hidapi/IOHIDManager, no BLE GATT access needed:
- Digitizer report (report ID
0x05, 11 bytes):[0]=0x05 [1]=state(3=touch,0=release) [2]=0x00 [3:5]=X u16LE [5:7]=Y u16LE [7:11]=constant - Consumer report (report ID
0x02, 4 bytes):[0]=0x02 [1]=0x00 [2]=0x00 [3]=button bitmask (1=Button O, 8=Button II)
The ring only reports on movement — a stationary touch produces no further reports, and a truly still hold produces none at all — which is why gesture recognition here is built entirely on deltas and tap timing rather than polling a live position.
- Connecting: macOS's own Bluetooth HID stack usually claims the ring
before a script can grab a fresh BLE connection to it.
daemon.pyreads through the same OS-level HID report stream macOS already receives (viahidapi/IOHIDManager) instead of fighting for a separate BLE connection, so this isn't an issue in normal use. - Scroll routing after switching apps/tabs:
CGEventCreateScrollWheelEventis routed by mouse cursor position (hit-testing), not keyboard focus, and some apps also need an actual keyboard event before they'll route scroll correctly again after a tab switch.mac_system.pyhandles the cursor part (moves the mouse into the frontmost window before scrolling); the keyboard part is a harmless "focus wake" key sent after every tab-switch action (seeactions.switch_tab_with_focus_wake). - The two-button chord and same-button double-press were both tried as a third button binding and are simply not detectable on this hardware — the ring's own debounce swallows or merges presses that come close together.