Skip to content

Repository files navigation

ring

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.

What it does

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.

Requirements

  • macOS (developed/tested on Sonoma; uses CGEvent/Quartz, which is macOS-only)
  • Python 3.9+
  • Homebrew
  • A TikTok Touch Ring J09

Setup

brew install hidapi

python3 -m venv venv
source venv/bin/activate
pip install hid pyobjc-framework-Quartz pyobjc-framework-Cocoa

That'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 ring

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.

macOS permissions

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):

  1. Input Monitoring — needed so the hid package can read raw HID reports from the ring. Without this, the daemon connects but every gesture silently does nothing (reports never arrive).
  2. Accessibility — needed so CGEvent can 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.

Dictation shortcut

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.

Running

source venv/bin/activate
python ring_daemon.py

It 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.py

Customizing

profiles.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.

Project layout

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

How the protocol was reverse engineered

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.

Notes on quirks worth knowing

  • Connecting: macOS's own Bluetooth HID stack usually claims the ring before a script can grab a fresh BLE connection to it. daemon.py reads through the same OS-level HID report stream macOS already receives (via hidapi/IOHIDManager) instead of fighting for a separate BLE connection, so this isn't an issue in normal use.
  • Scroll routing after switching apps/tabs: CGEventCreateScrollWheelEvent is 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.py handles 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 (see actions.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.

License

MIT

About

Turn the TikTok Touch Ring J09 into a full gesture/cursor control daemon for macOS (swipes, taps, buttons -> keyboard/scroll/app-switching), reverse-engineered from its BLE HID reports.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages