Skip to content

Automating OpenJoystickDriver

xsyetopz edited this page Oct 15, 2026 · 4 revisions

Automating OpenJoystickDriver

Use ojd in scripts and other programs to read controller state, change profiles, and drive a virtual gamepad.

Note: This page applies to OpenJoystickDriver 0.6.0-alpha.1 and later. Version 0.5.0-beta.4 and earlier do not have all of the features on this page.

Choose the Interface

ojd is the interface for scripts and other programs. Run it as a subprocess, read its --json output, and check its exit code. For the global options, output rules, and exit codes, see Command line.

The Swift package OpenJoystickDriverKit has an ApplicationServiceClient, but the service accepts a connection only from a process with the same code signature as the app. A program you build yourself cannot use it with a released app. Use ojd instead.

Write a Script

  1. Start the service and wait until it accepts requests: ojd service start.
  2. Add --no-input to each command, so that a command fails instead of asking a question.
  3. Add --json to each command whose output you read.
  4. Check the exit code of each command. Code 69 means that the service is not running.

Commands that delete or reset data ask first on a terminal. In a script, they need --force. Run them first with --dry-run to see what they change.

ojd service start --timeout 30
ojd --no-input --json controller list | jq -r '.items[] | [.id, .name, .unit // ""] | @tsv'

Read JSON Output

Keys and values in --json output are stable identifiers, and OJD never translates them. A command that streams, such as ojd controller watch or ojd virtual feed, prints one JSON object per line.

Each object has apiVersion, which is openjoystickdriver/v1alpha1, and kind, the singular UpperCamelCase name of what the command prints: ojd controller show prints Controller, and ojd status prints SystemStatus. The list commands controller list, permission list, setting list, binding list, profile list, and record list print a kind that ends in List, such as ControllerList, with the entries in items and an empty metadata. An item has no apiVersion or kind of its own. binding list gives each item a profile, the profile it came from. profile list and record list report the profiles that cannot be loaded and the records that were skipped as warnings on standard error, one line each. A command that changes or removes something and has no object to print, such as ojd profile delete or ojd controller rumble, prints a Status: status is Success, and details holds the command's data. A command that fails prints no JSON: it exits with a non-zero code and writes the error on standard error. Values that OJD defines in output, such as notFound or bluetoothLE, are lowerCamelCase. The status of a Status and the type of a watch line keep the Kubernetes spelling.

cli-output.schema.json describes the output of each command. Each command has one entry in $defs, named after its command path in lowerCamelCase: the entry for ojd controller show is controllerShow. Entries that start with shared are shapes that more than one command uses. The schema rejects keys that its release does not print, and a later release can add keys. Validate output against the schema from the same release.

Do not parse the human-readable output. It is translated, and it can change in each release.

Name a Controller

Commands take a CONTROLLER operand. Use one of these forms:

  • A unit ID, such as U- and 16 characters. It stays the same across reconnects and service restarts while the controller uses the same USB port. It changes when you connect the controller to a different port. Scripts can store it. A controller with no location ID has no unit ID.
  • VVVV:PPPP, the hexadecimal vendor and product ID. It names a controller model. When two connected controllers have the same model, the command fails and lists them.
  • An ID from ojd controller list, such as E- and 16 characters. It lasts only until the controller disconnects, so do not store it.

ojd controller list --json gives the unit ID of each controller in unit.

Watch Controllers

ojd controller watch --all --json reports every controller on one stream, so a program does not have to reconnect when a controller comes and goes. Each line is one JSON object with a type and an object, as in a Kubernetes watch. The line has no apiVersion or kind of its own. object is a Controller with its own apiVersion and kind, and its id is the ID from ojd controller list:

  • ADDED: a controller connected. object has the fields of an ojd controller list entry, including unit. The first lines report the controllers already connected.
  • MODIFIED: the controller's input changed. object has the same fields and input, the whole input state. With --output, output has the values of the controller's virtual gamepad.
  • DELETED: the controller disconnected. object is the controller's last state.
ojd --no-input controller watch --all --json | jq -c 'select(.type != "MODIFIED")'

The service is polled every 16 ms for input, and every 250 ms for connected controllers.

Read the Endpoint

A program that runs all the time can read controller events from the endpoint, a local socket, instead of running ojd controller watch. The endpoint is off by default, and it serves only the programs that you grant:

  1. Turn on the endpoint: ojd access enable.
  2. Grant your program: ojd access grant /Applications/Reader.app. To find the ID of a program that the endpoint refused, run ojd access list.
  3. Read the socket path: ojd --json access status | jq -r .socketPath. It is in your user's temporary folder, and it exists only while the endpoint is on.

The program sends and receives JSON objects, one per line, each at most 64 KiB:

Each line except a watch line has apiVersion, which is openjoystickdriver/v1alpha1, and a kind:

  1. The endpoint sends {"apiVersion":"openjoystickdriver/v1alpha1","kind":"Challenge","nonce":"…"} first. A granted program can ignore it; a token client signs it.
  2. Within 5 seconds, the program sends {"apiVersion":"openjoystickdriver/v1alpha1","kind":"Hello","scopes":["read"]}.
  3. The endpoint answers a Welcome, with version and the granted scopes, or a Status with status Failure, code, and message, and closes the connection. A line with another apiVersion gets E1010, a line without apiVersion or kind, or with one that is null or not a string, gets E1012, and a kind that the endpoint does not accept at that point gets E1011.
  4. The program sends {"apiVersion":"openjoystickdriver/v1alpha1","kind":"Subscription","stream":"controllers"}. Add "output":true for the values of the virtual gamepad.
  5. The endpoint sends the same ADDED, MODIFIED, and DELETED watch lines as ojd controller watch --all --json.
import json
import socket
import subprocess

status = json.loads(subprocess.check_output(["ojd", "--json", "access", "status"]))
with socket.socket(socket.AF_UNIX) as endpoint:
    endpoint.connect(status["socketPath"])
    lines = endpoint.makefile("rw")
    lines.readline()  # the challenge
    lines.write('{"apiVersion":"openjoystickdriver/v1alpha1","kind":"Hello","scopes":["read"]}\n')
    lines.flush()
    print(lines.readline(), end="")
    lines.write('{"apiVersion":"openjoystickdriver/v1alpha1","kind":"Subscription","stream":"controllers"}\n')
    lines.flush()
    for line in lines:
        event = json.loads(line)
        if event["type"] != "MODIFIED":
            print(event["type"], event["object"]["id"])

python3 is signed by Apple, so to run this example, grant /usr/bin/python3. Every Python script you run then gets the access. Grant a signed app of your own for regular use.

Use a Token

A program that cannot be granted by its signature, such as a script, can use a token instead. The program does not send the token. It proves that it has the token by signing the challenge:

  1. Create the token: ojd access grant --token reader. The command prints the token once, and the service keeps only its SHA-256 hash.
  2. Join four lines, each ending with a newline: OpenJoystickDriver endpoint hello 1, the challenge's nonce, the page's origin, and the WebSocket's port. On the socket, the origin and port lines are empty.
  3. Compute the HMAC-SHA256 of those lines, keyed with the SHA-256 of the token, and write it as lowercase hex.
  4. Send the token's name and the HMAC in the Hello: {"apiVersion":"openjoystickdriver/v1alpha1","kind":"Hello","scopes":["read"],"tokenName":"reader","proof":"…"}.
import hashlib
import hmac
import json

TOKEN = "ojd_…"

nonce = json.loads(lines.readline())["nonce"]
message = "".join(line + "\n" for line in ["OpenJoystickDriver endpoint hello 1", nonce, "", ""])
key = hashlib.sha256(TOKEN.encode()).digest()
proof = hmac.new(key, message.encode(), hashlib.sha256).hexdigest()
hello = {
    "apiVersion": "openjoystickdriver/v1alpha1",
    "kind": "Hello",
    "scopes": ["read"],
    "tokenName": "reader",
    "proof": proof,
}
lines.write(json.dumps(hello) + "\n")

This replaces the readline and the first write in the example above.

Any program that reads the token can use it, so store it like a password. Remove it with ojd access revoke token:reader.

AccessGrants.json, in the OpenJoystickDriver Application Support folder, is secret too. The SHA-256 hashes in it are the HMAC keys, so a program that reads the file can sign challenges as any token in it. The service writes the file so that only your user can read it. Keep it out of support reports, exports, backups, and anything else that you share, and revoke every token if it leaks.

Use the WebSocket

A web page, such as a stream overlay, can read the endpoint through a WebSocket:

  1. Turn on the WebSocket: ojd access web enable. It listens on 127.0.0.1. The first time, the system picks a free port, and the service saves it, so the port stays the same. Choose a port with --port. ojd access status shows the port. The WebSocket is separate from ojd access enable, which turns on only the socket.
  2. Grant a token for the page's origin, with the port from ojd access status: ojd access grant --token overlay --origin http://127.0.0.1:PORT.
  3. Put the page in the Overlays folder that ojd access status shows. The WebSocket's port serves the folder at http://127.0.0.1:PORT/, and a folder serves its index.html.
  4. The page connects to ws://127.0.0.1:PORT/endpoint and exchanges the same messages, one JSON object per text message. It signs the challenge with its token, its origin, and the port.
const token = "ojd_…";
const apiVersion = "openjoystickdriver/v1alpha1";
const encoder = new TextEncoder();

async function proof(nonce) {
  const secret = await crypto.subtle.digest("SHA-256", encoder.encode(token));
  const key = await crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-256" }, false, [
    "sign",
  ]);
  const lines = ["OpenJoystickDriver endpoint hello 1", nonce, location.origin, location.port];
  const code = await crypto.subtle.sign("HMAC", key, encoder.encode(lines.map((line) => line + "\n").join("")));
  return Array.from(new Uint8Array(code), (byte) => byte.toString(16).padStart(2, "0")).join("");
}

// The page is served from the WebSocket's port, so its origin and port are the ones to sign.
const endpoint = new WebSocket(`ws://${location.host}/endpoint`);
endpoint.onmessage = async (message) => {
  const event = JSON.parse(message.data);
  switch (event.kind) {
    case "Challenge":
      endpoint.send(
        JSON.stringify({
          apiVersion,
          kind: "Hello",
          scopes: ["read"],
          tokenName: "overlay",
          proof: await proof(event.nonce),
        }),
      );
      break;
    case "Welcome":
      endpoint.send(JSON.stringify({ apiVersion, kind: "Subscription", stream: "controllers" }));
      break;
    default:
      // A watch line has a type and an object, and no kind. A Status is an error.
      if (event.type === "MODIFIED") {
        console.log(event.object);
      }
      break;
  }
};

The WebSocket accepts a page only from an origin that a token is granted for, and the token must be granted for that origin. A page from another origin, such as http://127.0.0.1:8080, signs its own origin and the WebSocket's port. It refuses pages opened from a file, whose origin is null. Use 127.0.0.1 in the address, not localhost, which the WebSocket refuses. When another program already uses the port, ojd access web enable fails. When the service starts and finds the port in use, ojd access status shows the WebSocket as on but not listening. In both cases, choose another port with ojd access web enable --port PORT, and grant tokens for the new origin.

An error line is a Status with status Failure and a code from E1001 to E1012. Look up each code, its cause, and its fix on Error codes. With E1010, E1011 and E1012, details.supportedAPIVersions lists the apiVersion values that the endpoint accepts. The endpoint serves at most 8 socket connections and 8 WebSocket connections at the same time. When a program reads too slowly, the endpoint keeps only the newest MODIFIED line of each controller. When 256 lines wait, it sends E1007 and closes the connection.

endpoint.schema.json describes each line, and the SDK endpoint reference documents each message, the errors, the grants, and the versions. To drive a virtual gamepad through the endpoint, see Drive a Virtual Gamepad Through the Endpoint.

Use the WebSocket From an App

A sandboxed app cannot connect to the socket: macOS refuses the connection with EPERM. It can use the WebSocket instead:

  1. Give the app the com.apple.security.network.client entitlement.
  2. Turn on the WebSocket with ojd access web enable, and create a token without --origin: ojd access grant --token myapp.
  3. Connect to ws://127.0.0.1:PORT/endpoint without an Origin header.
  4. Sign the challenge with the token, an empty origin line, and the port.

Any local program that has such a token can use it, also programs of other users on the same Mac, so store it like a password. On the WebSocket, a token without origins works only from a client that sends no Origin, and web browsers always send one, so web pages cannot use it.

Drive a Virtual Gamepad Through the Endpoint

A program with the control scope can drive a virtual gamepad through the endpoint, as ojd virtual feed does:

  1. Grant the scope: ojd access grant --token driver --scope control. Add --scope read to grant both. A token that has --origin cannot have control, because a web page cannot use it.
  2. Send a Hello with "scopes":["control"].
  3. After the Welcome, send {"apiVersion":"openjoystickdriver/v1alpha1","kind":"FeedRequest","as":"Generic"}. The persona ids are Generic and SdlCompatible.
  4. The endpoint answers {"apiVersion":"openjoystickdriver/v1alpha1","kind":"FeedSession","as":"Generic"} when the virtual gamepad exists.
  5. Send one frame per line, each with apiVersion and "kind":"Frame". A frame has the keys of an ojd virtual feed line, and the service plays frames with the same rules.
  6. The endpoint sends each rumble command that a game sends to the virtual gamepad as one RumbleCommand line, the same as ojd virtual feed prints. Its type is setRumble or stopRumble. The virtualFeed entry of the output schema describes it.

Closing the connection removes the virtual gamepad at once and drops the frames that have not played. The endpoint does not report when a frame has played, so stay connected for the sum of the play times of the frames, and a short margin, because the service starts a frame a moment after it arrives. Each frame plays for its holdMilliseconds, and at least 8 ms. While 256 frames wait, the endpoint stops reading lines. When the client sends no frame line for 2 seconds after its last frame has played, the endpoint closes the connection with E1009 and removes the virtual gamepad, so a stalled client does not hold a button. The 2 seconds start when the queue is empty and the last frame's time has elapsed, so frames that wait or play, and a long hold, never cause the close. Rumble lines that the endpoint sends do not count. To keep the gamepad after the last frame has played, send another line within 2 seconds. The endpoint runs at most 4 virtual gamepads at the same time, and answers another feed with E1008. When the virtual gamepad does not start, or the service stops it, the endpoint sends E1009.

The reference client presses the south button for 100 ms, prints the rumble commands that the endpoint sends, and stays connected until the release has played. When the endpoint sends an error or closes the connection earlier, it exits with the error. With E1010, E1011, or E1012, it also reports the supported apiVersion values. It uses only the Python standard library. Set OJD_TOKEN to the token that ojd access grant printed, and run python3 reference_client.py feed. To subscribe instead, run python3 reference_client.py watch. Games and latency with the endpoint are not verified.

Change Profiles

A profile is a JSON file. profile.schema.json describes it, and the Profile file reference explains its fields.

  • ojd profile get PROFILE KEY prints one value. ojd profile set PROFILE KEY VALUE changes one value. KEY is a path of member names and array indexes, joined by dots.
  • ojd profile export PROFILE prints the whole file. ojd profile import FILE adds it again, or replaces the profile with the same ID. Use - as FILE to read standard input.
  • ojd profile validate FILE checks a file against the schema and against the rules that span fields. It does not need the service.
  • ojd profile activate PROFILE and ojd profile deactivate PROFILE apply a profile and stop it.
ojd profile get Racing stickMappings.0.tuning.innerDeadzone
ojd profile set Racing stickMappings.0.tuning.innerDeadzone 0.15
ojd profile export Racing --output racing.json
ojd profile validate racing.json

To limit a profile to one controller, set device.unit to the controller's unit ID. To choose the virtual gamepad for one controller, use ojd virtual set PROFILE CONTROLLER --unit.

Drive a Virtual Gamepad

ojd virtual feed --as PROFILE publishes a virtual gamepad and reads one JSON object per line from standard input. Each line gives the state of the whole gamepad. A control that the line does not name is neutral. The virtual gamepad profiles are sdl-compatible and generic.

{
  echo '{"buttons":["South"]}'
  sleep 1
  echo '{"axes":{"LeftStickX":-1,"RightTrigger":0.5}}'
  sleep 1
} | ojd virtual feed --as generic

This example holds the South button for 1 second, then moves the left stick fully left and pulls the right trigger halfway for 1 second. When the input ends, the virtual gamepad is removed.

To tap a button without waiting in the script, write the press with holdMilliseconds and the release after it:

printf '%s\n' '{"buttons":["South"],"holdMilliseconds":50}' '{}' | ojd virtual feed --as generic
  • buttons and dpad list the controls that are pressed. They take the names that button: and dpad: binding sources use, such as South and up.
  • axes maps axis names to values. Sticks, such as LeftStickX, go from -1 to 1, with Y up. The triggers, LeftTrigger and RightTrigger, go from 0 to 1.
  • The service plays the lines in order. Each line lasts at least 8 ms, and then until the next line plays. Add holdMilliseconds, from 0 to 60000, to keep a line longer.
  • When lines arrive faster than every 8 ms, they wait in a queue of at most 256 lines, so a full queue is about 2 seconds behind. A line without holdMilliseconds replaces the last waiting line when that one has no holdMilliseconds and the same buttons and dpad. A press or release is never dropped.
  • Each rumble command that a game sends to the virtual gamepad prints on standard output as one JSON object per line. The virtualFeed entry of the output schema describes it.
  • The service removes the virtual gamepad when the input ends and every line has played, when you press Control-C, or when it gets no update for 2 seconds. The command sends updates while it waits for input.
  • A line that is not valid stops the command with exit code 64. A line is not valid when it has an unknown key or lists a button or D-pad direction twice.
  • The service runs at most 4 feeds from ojd virtual feed at the same time. Endpoint clients have 4 more of their own, so they cannot take these.

Games and latency with ojd virtual feed are not verified.

What the virtual controller carries

The virtual controller is a standard gamepad. Each profile has a fixed set of buttons, so some controls on a physical controller have no output. A control that is not carried does nothing on the virtual controller, whether it is passed through or assigned to a gamepad output.

Control generic sdl-compatible
South, East, West, North Carried Carried
Left and right shoulder Carried Carried
Back, Start Carried Carried
Left and right stick click Carried Carried
Guide Carried Carried, in its own report
D-pad Carried as a hat and as 4 buttons Carried as a hat
Sticks and triggers Carried Carried
Capture Carried Not carried
Touchpad click, microphone Not carried Not carried
Paddles, grips, and auxiliary buttons Not carried Not carried
Stick touch, trackpad touch, left trackpad click Not carried Not carried
Right trackpad click Right stick click on the classic Steam Controller, otherwise not carried Right stick click on the classic Steam Controller, otherwise not carried
  • generic has 16 buttons and all 16 are in use. Its layout is the published contract for the product ID 1209:4A4F, so a new button would need a new product ID.
  • sdl-compatible follows the Xbox One S Bluetooth report. That 16-byte report has no field for Capture, and SDL reads no other button from it.
  • The right pad of the classic Steam Controller is its right stick, and the controller has no separate right stick click, so its pad click is the right stick click. The Steam Deck and the Steam Triton have a right stick click of their own, so their right pad click is a different control and is not carried.
  • The D-pad buttons of sdl-compatible stay clear. The D-pad is the hat.

Use Shortcuts

On macOS 13 or later, the Shortcuts app lists these OpenJoystickDriver actions:

Action Takes Returns
Get Controllers Nothing The connected controllers, each with a name and a model
Get Battery Level A controller The battery charge in percent
Activate Profile A remapping profile The profile, now active
Deactivate Profile A remapping profile The profile, now inactive
Get OpenJoystickDriver Version Nothing The version of the installed app

The actions run in the app, so the app must be installed. They do not use ojd.

When you pick a controller, the list shows each connected controller, and then one entry for each connected model. A shortcut stores the controller in the same forms as CONTROLLER in Name a Controller:

  • A single controller is stored by its unit ID, or by its ojd controller list ID when it has no unit ID. The unit ID changes when you connect the controller to a different USB port, and the list ID changes each time the controller connects.
  • A model entry is stored as VVVV:PPPP. It finds the connected controller of that model on any port. When two connected controllers have the same model, the action fails and lists them. You can also type VVVV:PPPP in the search field.

Get Battery Level fails when the controller reports no battery level. When the controller reports a range, such as 30-39%, the action returns the lowest value of the range.

To run a shortcut from another program, use the shortcuts command or a shortcuts:// link:

  • Raycast: the Shortcuts extension runs a shortcut by name, or a Script Command can run shortcuts run "Low Battery Check".
  • Stream Deck: an Open action with the link shortcuts://run-shortcut?name=Low%20Battery%20Check runs the shortcut.

None of these actions is verified live in the Shortcuts app yet.

Further Reading

Clone this wiki locally