Repository navigation
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.
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.
- Start the service and wait until it accepts requests:
ojd service start. - Add
--no-inputto each command, so that a command fails instead of asking a question. - Add
--jsonto each command whose output you read. - 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'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.
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 asE-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.
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.objecthas the fields of anojd controller listentry, includingunit. The first lines report the controllers already connected. -
MODIFIED: the controller's input changed.objecthas the same fields andinput, the whole input state. With--output,outputhas the values of the controller's virtual gamepad. -
DELETED: the controller disconnected.objectis 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.
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:
- Turn on the endpoint:
ojd access enable. - Grant your program:
ojd access grant /Applications/Reader.app. To find the ID of a program that the endpoint refused, runojd access list. - 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:
- The endpoint sends
{"apiVersion":"openjoystickdriver/v1alpha1","kind":"Challenge","nonce":"…"}first. A granted program can ignore it; a token client signs it. - Within 5 seconds, the program sends
{"apiVersion":"openjoystickdriver/v1alpha1","kind":"Hello","scopes":["read"]}. - The endpoint answers a
Welcome, withversionand the grantedscopes, or aStatuswithstatusFailure,code, andmessage, and closes the connection. A line with anotherapiVersiongetsE1010, a line withoutapiVersionorkind, or with one that isnullor not a string, getsE1012, and akindthat the endpoint does not accept at that point getsE1011. - The program sends
{"apiVersion":"openjoystickdriver/v1alpha1","kind":"Subscription","stream":"controllers"}. Add"output":truefor the values of the virtual gamepad. - The endpoint sends the same
ADDED,MODIFIED, andDELETEDwatch lines asojd 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.
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:
- Create the token:
ojd access grant --token reader. The command prints the token once, and the service keeps only its SHA-256 hash. - Join four lines, each ending with a newline:
OpenJoystickDriver endpoint hello 1, the challenge'snonce, the page's origin, and the WebSocket's port. On the socket, the origin and port lines are empty. - Compute the HMAC-SHA256 of those lines, keyed with the SHA-256 of the token, and write it as lowercase hex.
- 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.
A web page, such as a stream overlay, can read the endpoint through a WebSocket:
- Turn on the WebSocket:
ojd access web enable. It listens on127.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 statusshows the port. The WebSocket is separate fromojd access enable, which turns on only the socket. - 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. - Put the page in the
Overlaysfolder thatojd access statusshows. The WebSocket's port serves the folder athttp://127.0.0.1:PORT/, and a folder serves itsindex.html. - The page connects to
ws://127.0.0.1:PORT/endpointand 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.
A sandboxed app cannot connect to the socket: macOS refuses the connection with EPERM. It can use the WebSocket instead:
- Give the app the
com.apple.security.network.cliententitlement. - Turn on the WebSocket with
ojd access web enable, and create a token without--origin:ojd access grant --token myapp. - Connect to
ws://127.0.0.1:PORT/endpointwithout anOriginheader. - 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.
A program with the control scope can drive a virtual gamepad through the endpoint, as ojd virtual feed does:
- Grant the scope:
ojd access grant --token driver --scope control. Add--scope readto grant both. A token that has--origincannot havecontrol, because a web page cannot use it. - Send a
Hellowith"scopes":["control"]. - After the
Welcome, send{"apiVersion":"openjoystickdriver/v1alpha1","kind":"FeedRequest","as":"Generic"}. The persona ids areGenericandSdlCompatible. - The endpoint answers
{"apiVersion":"openjoystickdriver/v1alpha1","kind":"FeedSession","as":"Generic"}when the virtual gamepad exists. - Send one frame per line, each with
apiVersionand"kind":"Frame". A frame has the keys of anojd virtual feedline, and the service plays frames with the same rules. - The endpoint sends each rumble command that a game sends to the virtual gamepad as one
RumbleCommandline, the same asojd virtual feedprints. ItstypeissetRumbleorstopRumble. ThevirtualFeedentry 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.
A profile is a JSON file. profile.schema.json describes it, and the Profile file reference explains its fields.
-
ojd profile get PROFILE KEYprints one value.ojd profile set PROFILE KEY VALUEchanges one value.KEYis a path of member names and array indexes, joined by dots. -
ojd profile export PROFILEprints the whole file.ojd profile import FILEadds it again, or replaces the profile with the same ID. Use-asFILEto read standard input. -
ojd profile validate FILEchecks a file against the schema and against the rules that span fields. It does not need the service. -
ojd profile activate PROFILEandojd profile deactivate PROFILEapply 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.jsonTo 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.
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 genericThis 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-
buttonsanddpadlist the controls that are pressed. They take the names thatbutton:anddpad:binding sources use, such asSouthandup. -
axesmaps axis names to values. Sticks, such asLeftStickX, go from -1 to 1, with Y up. The triggers,LeftTriggerandRightTrigger, 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
holdMillisecondsreplaces the last waiting line when that one has noholdMillisecondsand the samebuttonsanddpad. 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
virtualFeedentry 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 feedat 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.
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 |
-
generichas 16 buttons and all 16 are in use. Its layout is the published contract for the product ID1209:4A4F, so a new button would need a new product ID. -
sdl-compatiblefollows 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-compatiblestay clear. The D-pad is the hat.
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 listID 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 typeVVVV:PPPPin 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%20Checkruns the shortcut.
None of these actions is verified live in the Shortcuts app yet.
- Home
- Start here
- Controllers
- Games
- Remapping
- Your Mac
- Command line
- Help
- Advanced