Writing a Frontend
Build your own OCTAVE screen, remote or script in any language: start the service, connect, discover, read, subscribe, control. The QML UI is one client of this API; yours can be another.
What You Get
Every manager the bundled UI uses: settings, media and now playing, volume, OBD and the live value of every PID and IMU reading, the black box, the shift light, phone mirroring control, dashboards. Each one's properties, methods and signals are listed with their types in the generated Service API Reference (docs/api/); what they mean is in Signals & Slots API. tst_conformance guarantees the bundled UI uses nothing outside that list, so whatever it can do, your frontend can too.
Four working clients come with the repo: sdk/web/index.html (plain JavaScript, about 250 lines), sdk/python/octave_client (stdlib only), sdk/go (standard library only, generated from docs/api/protocol.json and the object files: one Go type per service object, with a method per API method) and sdk/typescript (an npm package for browsers and Node; its envelope is generated from docs/api/protocol.json and its object types from the object files, so a wrong call fails to compile and a deprecated member shows as such in the editor). Read one of them next to this page.
Five whole frontends written to prove the API (TypeScript + Svelte, Python + Textual, Go + Gio, Rust + egui, C + SDL2, together reaching every feature of the bundled UI) are kept on the frontends-experiment branch as worked examples; they are not part of main.
1. Get OCTAVE and a Simulated Car
You don't need to build OCTAVE, or own the car. Download a release for your machine from GitHub Releases (one where octave --help lists --sim) and start it with no window and a simulated car:
./OCTAVE-*-linux-x86_64.AppImage --headless --sim # Linux (chmod +x it first)
/Applications/OCTAVE.app/Contents/MacOS/octave --headless --sim # macOS, from the .dmg
"C:\Program Files\OCTAVE\OCTAVE.exe" --headless --sim # Windows, after the installer
That serves every object on 127.0.0.1:9876 with the real OBD stack connected to a simulated ELM327 adapter (every PID supported, a VIN, trouble codes), IMU readings that move with it, and three short test tracks to play. It runs on a throwaway config that is deleted when it exits, so it never touches an OCTAVE you use; --config DIR keeps one in DIR instead. Drive the car from your frontend or a script with the sim object, for example {"op":"call","object":"sim","method":"setThrottle","args":[60]}; its methods (pedals, gears, scenarios, link faults, the IMU, a test video, pairing codes) are listed in docs/api/README.md, "Simulator". sim is experimental and exists for development: sim.active is false on a real head unit, where every control does nothing. Leave out --headless to see the same car in the bundled UI next to yours.
On a real head unit the service is off by default. Any of these turns it on, listening on 127.0.0.1:9876:
octave --service # the normal UI, plus the service
octave --service 9900 # another port
octave --headless # no UI at all: your frontend is the screen
Or set "serviceEnabled": true in settingsConfigure.json (Settings Reference) to have it at every start. For a phone or a second screen on the car's network add --service-bind 0.0.0.0; read Versioning and Security first.
2. Connect
Two transports on the same port carry the same JSON messages:
- TCP, one JSON object per line, for any language with sockets. Requests carry
opand an optionalid, which the reply echoes so you can send several at once. - WebSocket (
ws://host:9876/), one object per text frame, for browsers. A browser may only connect from the service's own page, i.e. served by the app athttp://host:9876/and opened by IP address. To host your own page there, start the app with--web-root DIR(your built frontend: the directory holding itsindex.html; or theserviceWebRootsetting) and openhttp://host:9876/; see the service protocol for what it serves and refuses. Non-browser WebSocket clients send noOriginand are not restricted.
# a whole session by hand
$ printf '{"op":"hello","id":1}\n{"op":"get","object":"volumeController","property":"percent","id":2}\n' | nc -q1 127.0.0.1 9876
{"id":1,"ok":true,"result":{"app":{"build":"release","headless":true,"version":"0.9.5"},"auth":"trusted","loopbackOpen":true,"objects":["settingsManager",...],"protocolVersion":3,"scopes":["full"]}}
{"id":2,"ok":true,"result":32}
Every reply has ok; a failure has "ok": false and an error string. Anything with an event key is not a reply but a subscription event, and can arrive between replies.
3. Discover
hello: the protocol version (check it), the app version, whether it is headless, and the object names.describe {object}: that object's properties (type, writable, notify signal, enum keys), methods (parameter types, return type) and signals.describewith no object returns all of them (about 110 KB). It is the same data as docs/api/<object>.json, so you can code against the files and check them at run time.- docs/api/parameters.json (or
get parameterCatalog parameters): every OBD PID and IMU reading with its title, native unit, range and decimals. - docs/api/protocol.json: the protocol itself as JSON Schema, for a client in a language without an SDK: each op's arguments and reply, the reply envelope and error codes, every event's fields, the binary record, the HTTP routes, the pairing steps. It is checked against the server by a test, so it is current.
describewith no object lists the ops the server has asops, so a client can tell at run time what it may send. To develop a client with no OCTAVE build at all,python tools/api/fake_server.pyis a fake service that speaks the spec with canned data, pairing and faults on demand (docs/api/README.md, "For SDK authors").
4. Read and Subscribe
{"op":"get","object":"nowPlaying","property":"title","id":3}
{"op":"subscribe","object":"nowPlaying","properties":["title","artist","playing","positionMs"],"throttleMs":250,"id":4}
← {"id":4,"ok":true,"result":{"sub":1}}
← {"event":"property","sub":1,"object":"nowPlaying","name":"title","value":"Dream On","t":12345} # the current value at once, then each change
- Order: the
subscribereply, with yoursubid, always arrives before any event of that subscription, on both TCP and WebSocket, even with requests pipelined. Your client still has to handle the reply before it reads the next message: a JavaScript client that resolves a promise inonmessageand registers thesubin a later.thencan see the first event before the registration. - Properties: a
propertyevent with the value at once and on every change.throttleMscaps it at one event per property per interval, carrying the latest value. - Signals:
"signals": ["trackChanged"]givessignalevents with the arguments. - Live OBD and IMU values: subscribe to
paramStorewith the ids you show asproperties. You getparamsevents,{"values": {"RPM": 2210, "SPEED": 41}}, in the native unit. OCTAVE only polls the car for what someone shows: callobdManager.setParameterDemand("my-frontend", ["RPM","SPEED"])for the ids on your screen, and an empty list when they leave it. If your connection closes (or the client is killed), the app releases every consumer that connection registered throughsetParameterDemand,setBackgroundDemandandberryIMU.setDemand, so pick a consumer name of your own: one the QML UI also uses would be released with yours. - Lists (no object serves one at present, protocol 3; the op stays for list models to come):
getgives a summary,rowsthe rows, and a subscription sendsmodelevents (reset, insert, update, remove) to replay in order.
A connection's subscriptions end with it. If you stop reading, the oldest events are dropped and a {"event":"dropped","count":N} says how many.
5. Control
{"op":"call","object":"nowPlaying","method":"next","id":5}
{"op":"call","object":"volumeController","method":"applyVolume","args":[40],"id":6}
{"op":"set","object":"volumeController","property":"muted","value":true,"id":7}
{"op":"call","object":"settingsManager","method":"save_show_clock","args":[false],"id":8}
Most state is read-only and changes through a method, the same one the touch screen calls: settings through their save_* slots, media through nowPlaying, volume through volumeController. Arguments are converted to the declared types (an enum by name or number), and overloads are told apart by argument count. Methods run on the app's GUI thread, so a slow one holds the bundled UI just as a tap would.
Start from a Template
The quickest start is a copy of one of the folders in templates/: each is a complete frontend under 200 lines that connects, pairs when it is not on the head unit (asking only for the media permission), shows RPM and speed live, plays and pauses, and shows the cover, with its own README and no path into the rest of the repo.
- templates/hello-car-js/: plain JavaScript in a browser, served by the app itself.
octave.jsis the whole protocol in 60 lines;app.jsthe page. - templates/hello-car-python/: a terminal program on the Python client (sdk/python/octave_client, copied into the folder).
- templates/hello-car-go/: the same in Go on sdk/go as a module dependency.
octave --headless --sim --web-root templates/hello-car-js # the download from step 1 (or ./build/octave): the simulated car, no window; open http://127.0.0.1:9876/
python templates/hello-car-python/hello_car.py # or, against the same app, the Python one...
cd templates/hello-car-go && go run . # ...or the Go one
The simulated car idles until something drives it: {"op":"call","object":"sim","method":"setThrottle","args":[40]} over the service, or a scenario (docs/api/README.md, "Simulator"). Each template has a check (test/, or go test) that runs it against --headless --sim on a free port; ctest runs them as template_js, template_python and template_go, so a template that stops working fails the build.
Your First Frontend in 60 Lines
Everything above, in one page on the TypeScript SDK (sdk/typescript): it connects to the app that serves it, pairs when the browser is not on the head unit, shows two live readings, the track with transport, and the cover. The whole thing is sdk/typescript/examples/first-frontend/: index.html (a status line, a pair form, two gauges, a now-playing row) and this app.js, no framework and no bundler.
cd sdk/typescript && npm install && examples/first-frontend/build.sh # builds the SDK, copies it to lib/
octave --headless --sim --web-root sdk/typescript/examples/first-frontend # a simulated car; open http://127.0.0.1:9876/
// A first OCTAVE frontend with the TypeScript SDK, in 60 lines: connect, pair
// when this browser is not on the head unit, two live gauges, a now-playing
// line with transport, the cover. Build the SDK, then serve this folder from
// the app (see README.md).
import { OctaveClient, obj } from './lib/index.js'
const $ = (id) => document.getElementById(id)
const client = new OctaveClient({ token: localStorage.getItem('token') ?? undefined }) // ws://<this page's host>/
const obd = obj(client, 'obdManager')
const now = obj(client, 'nowPlaying')
client.on('status', (s) => { $('status').textContent = s === 'online' ? `OCTAVE ${client.hello?.app.version}` : s })
client.on('hello', (h) => {
// Protocol 2: over the network we must pair once; on the head unit nothing to do.
if (h.tokenRejected) localStorage.removeItem('token')
$('pair').style.display = h.auth === 'untrusted' ? 'block' : 'none'
if (h.auth !== 'untrusted') start()
})
$('pair').onsubmit = async (e) => {
e.preventDefault()
try {
localStorage.setItem('token', await client.pair($('code').value, 'First frontend'))
client.close(); client.connect() // a fresh, trusted connection re-issues everything
} catch (err) { $('pairError').textContent = err.message }
}
let started = false
async function start() {
if (started) return
started = true
// Live readings: the app polls the car only for what someone asks for.
await obd.call('setParameterDemand', 'first-frontend', ['SPEED', 'RPM'])
client.params(['SPEED', 'RPM'], (v) => { for (const id in v) $(id).textContent = v[id] === null ? '–' : Math.round(v[id]) }, 100)
// Now playing: the properties arrive at once, then on every change.
now.subscribe({ properties: ['hasTrack', 'title', 'artist', 'playing', 'artRevision'], throttleMs: 250 }, (e) => {
if (e.event !== 'property') return
if (e.name === 'title' || e.name === 'hasTrack') $('title').textContent = e.name === 'hasTrack' && !e.value ? 'Nothing playing' : e.value || $('title').textContent
if (e.name === 'artist') $('artist').textContent = e.value
if (e.name === 'playing') $('play').textContent = e.value ? '⏸' : '▶'
if (e.name === 'artRevision') cover(e.value)
})
}
async function cover(rev) {
// Over the network /art needs a short-lived ticket; on the head unit it is harmless.
const t = client.hello?.loopbackOpen && location.hostname === '127.0.0.1' ? '' : `&ticket=${(await client.mediaTicket()).ticket}`
$('cover').src = rev > 0 ? `/art?rev=${rev}${t}` : ''
}
$('play').onclick = () => now.call('togglePlay')
$('next').onclick = () => now.call('next')
$('prev').onclick = () => now.call('previous')
client.connect()
What each part leans on:
- Connect.
new OctaveClient()defaults to the page's own host, so the page works wherever the app serves it;connect()reconnects with backoff and re-issues the subscriptions on each new link. A token from an earlier pairing goes withhello. - Pair when untrusted. On the head unit
hellosays"auth": "trusted"and the form stays hidden. Over the network it saysuntrusted: the page asks for the 6-digit code (Settings, Service, Pair a device),client.pair(code, name)returns the token to keep, and a fresh connection carries it. A token the app rejects (tokenRejected) is dropped. - Live readings.
obdManager.setParameterDemand("first-frontend", ["SPEED", "RPM"])makes the app poll those two PIDs (it polls only what someone shows);client.params([...], cb, 100)delivers them in native units, at most ten times a second. - Now playing. One subscription to
nowPlaying's properties: the current values arrive at once, then every change. The buttons calltogglePlay,next,previousthrough the typed facade (obj(client, 'nowPlaying')): a method that does not exist does not compile. - The cover.
GET /art?rev=NwithnowPlaying.artRevisionas the cache key; over the network the URL also carries a short-lived ticket frommediaTicket, never the token.
Run with --headless --sim --service-bind 0.0.0.0 and opened by the machine's LAN address, the page shows the pair form; a code from sim.pairingCode() over loopback pairs it, and then it reads the simulated car and the cover like a page on the head unit.
Recipes
| To | Use |
|---|---|
| Show what is playing, with controls | nowPlaying: title / artist / album / playing / positionMs / durationMs / source; togglePlay, next, previous, seek(ms). It covers the phone (while mirroring) and the local player alike. |
| Volume and mute | volumeController: percent, muted, applyVolume(0..100). Never call the media manager's own setters: VolumeController routes to every output and applies the curve. |
| Gauges in the user's units | parameterCatalog.displayParameters (titles, units and ranges after the user's °F / km/h choice) and parameterCatalog.toDisplay(id, value); settingsManager.gaugeTempUnit / gaugeSpeedUnit. |
| Connect to the car | obdManager: connectionState (enum ConnectionState), connectionLog, the connect methods; Bluetooth adapters through obdAdapterFinder. |
| Past drives | tripRecorder (the black box): trips (the list), requestDetail(id) and requestSeries(...) (four overloads, by argument count; the results arrive as signals), exportTrip; recording / currentTripId for the drive in progress. |
| Dashboards | dashboardManager: dashboards (the list), loadDashboard, saveDashboard (no id = a new one from the label; on failure lastError says why), importDashboard / importDashboardFromText, exportDashboard. Files follow docs/api/dashboard-spec.schema.json; the widget types a cell can use, with their defaults and editable props, are dashboardManager.widgetCatalog (docs/api/widget-catalog.json). |
| Anything else | Look it up in the API reference, then grep the QML for how the bundled UI uses it. |
Being the Only UI
octave --headless runs every manager and the service with no QML engine and no window (the offscreen Qt platform), using about a fifth less memory than the full app. Music, OBD polling, the black box, the knob, gestures and the IMU all keep working; your frontend is the screen. It needs the service, so it exits if the service can't start. hello reports "headless": true.
octave --headless --sim (step 1, any build) adds the simulated car behind the real OBDManager and simulated IMU motion, so OBD values, the G-force and the compass move with no adapter and no QML UI. Drive it with the sim object on the same port, for example {"op":"call","object":"sim","method":"setThrottle","args":[80]} or {"op":"call","object":"sim","method":"scenario","args":["hot"]}.
The SDK's launch_headless() starts one with a throwaway config, the easiest way to test a frontend or a script: sdk/python/tests/test_client.py does exactly that.
Installing Your Frontend
A frontend ships as a folder (or a .zip of one) with an octave-frontend.json at its top:
{
"id": "com.example.dash", // lower case, digits, . _ -: its folder name on the head unit
"name": "Example Dash",
"version": "1.2.0",
"description": "Big gauges for the track",
"protocol": [2, 3], // the protocol versions it speaks, min and max
"scopes": ["read", "media"], // the permissions it asks for (docs/api/README.md, "Permissions")
"web": {"root": "dist"} // a folder with index.html, served at GET /
// or
"exec": {"linux": ["bin/dash", "--fullscreen"],
"windows": ["dash.exe"],
"macos": ["bin/dash"]} // a program and its arguments per platform
}
Exactly one of web and exec. Paths are relative to the folder and can't leave it. An exec program is either inside the folder (bin/dash) or a bare name found on PATH (python3, with your script as an argument); {dir} in an argument becomes the folder. scopes defaults to ["read"]. Unknown keys are ignored, so a manifest written for a newer OCTAVE still installs on this one if its protocol range fits.
frontendManager (reference) installs one: installFromFolder(path) or installFromZip(path) copies it to <config>/frontends/<id>/ after checking the manifest, that this OCTAVE speaks one of its protocol versions, that it has a program for this platform, and that it holds no links, nothing outside its folder (a zip entry like ../x refuses the whole zip), at most 10 000 files and 256 MB. Nothing in it runs at install. Installing the same id again replaces it. setActive(id) makes it the screen from the next start ("" goes back to the built-in QML UI), remove(id) deletes it. These need the system permission. On the head unit the same is on Settings > Device > Frontends: install from a folder or a zip, each frontend with what its scopes let it change, Use and Remove.
When it is the screen
- A web frontend is served at
GET /in place of the reference client (only--web-rootcomes before it), and being active turns the service on. Open it in the head unit's browser or from a phone; in a browser on the head unit itself the page holds exactly its manifest'sscopes(OCTAVE tells a browser from a program by theOrigin/Sec-Fetch-Siteheaders browsers send; programs on the head unit keep full trust), and in a browser elsewhere it pairs as usual and holds what the pairing gave it. A token the page presents withhellocounts as usual. - An exec frontend is the screen: a plain
octavestart runs headless (no QML UI) and starts your program in its folder withOCTAVE_HOST(127.0.0.1),OCTAVE_PORT,OCTAVE_TOKEN,OCTAVE_PROTOCOL,OCTAVE_SCOPESandOCTAVE_FRONTEND_DIRin its environment. Connect and send{"op":"hello","token":"$OCTAVE_TOKEN"}first: the token holds exactly thescopesof your manifest, and for that run OCTAVE trusts no local connection without a token, so a frontend can't do more than it asked for by leaving the token out. The token is new each start, kept in memory only, and revoked when your program ends. Your program's output goes to OCTAVE's log, up to 200 lines and 64 KB a second with each line cut at 1000 characters (the rest is counted in one "lines dropped" note a second): log what helps, not every frame. The scopes bound what the program can do over the socket, not what it can do as a process: it runs as OCTAVE's user, with the config folder on disk and the token in its environment (inherited by anything it starts), so the user installs only frontends they trust. - Exit code 0 means the user quit: OCTAVE quits too, unless it comes within 3 s of starting (a frontend that quits at once on every start would leave a blank head unit), which counts as a crash. Any other end (a crash, another code) starts it again after 1 s, then 2, 4 ... up to 30 s; a run longer than a minute forgives the crashes before it. After 5 crashes in a row, each within a minute of starting, OCTAVE writes <config>/frontends/USE_QML with the reason and starts again on the QML UI.
- Getting the QML UI back:
octave --qmlfor one run; or create the file <config>/frontends/USE_QML (any text) on the head unit's disk, which keeps the QML UI until it is deleted (frontendManager.clearForcedQml(), or "Try it again" on Settings > Device > Frontends) or a screen is chosen again withsetActive. An explicitoctave --headlessnever starts an installed frontend: the caller is the frontend then. - While an exec frontend is the screen, every other program on the head unit needs a token too: scripts, the SDK examples, a second frontend. The pairing code is shown on the QML UI's Settings > Device > Service, which isn't up then: run
octave --pair-codeon the head unit instead (as the same user, with the same--configif the running one has one). It asks the running OCTAVE for a fresh code over a local channel only that user can open, prints{"code": "123456", "secondsLeft": 120}and exits; it exits 1 if no OCTAVE with the service on is running for that config. Devices paired earlier keep working, andoctave --qml(orUSE_QML) still gives you the Settings page for one start. Exec frontends run on Linux (the Pi), Windows and macOS; not on Android, iOS or in the browser build.
From a template to the screen. templates/hello-car-js and templates/hello-car-python each carry an octave-frontend.json asking for the media permission only, so either folder installs as it is: Settings > Device > Frontends > Install from folder, then Use (or frontendManager.installFromFolder(path) and setActive(id) over the service). From the next start the JavaScript one is served at /, where a browser on the head unit holds read and media only, and the Python one runs as the screen with a token of the same two, reading OCTAVE_HOST, OCTAVE_PORT and OCTAVE_TOKEN from its environment. Change the id, name and version when you make one your own. ctest templates_install runs both this way against the simulated car (tests/templates/check_install.py), so the path can't rot.
Versioning and Security
- Versioning.
protocolVersionis 3. Accept the versions you know and refuse an unknown later one; it changes only on a breaking change (something removed, renamed or retyped; for 2, pairing; for 3, thedownloadManagerobject removed). Additions don't change it, so ignore fields, objects and events you don't know. The rule, the history and what each version broke are in docs/api/README.md. - Pairing. On the head unit itself nothing is needed (unless
serviceLoopbackOpenis off). Over the network your client pairs once: the user opens Settings > Service > Pair a device, you send{"op":"pair","code":"123456","name":"My dash"}and keep the token; later connections start with{"op":"hello","token":"..."}(a revoked or unknown token gets"auth": "untrusted", "tokenRejected": true: pair again). Until then every op buthelloandpairanswers"code": "unauthorised". For<img>and downloads ask{"op":"mediaTicket"}and use/art?ticket=...; never put the token in a URL. A revoked device's connections are closed. Details: docs/api/README.md, "Pairing". - Permissions. Ask only for what your frontend changes:
{"op":"pair",...,"scopes":["media"]}. Every paired device can read; changing a member needs itsscope(listed on every method and writable property indescribeand the API reference:media,vehicle,phone,settings,files,system,full), otherwise it answers"code": "forbidden". Withoutscopesyou getfull. The user sees each device's access in Settings and can make it read only. Details: docs/api/README.md, "Permissions". - The network. Bind beyond loopback (
--service-bind) only on a network you control: pairing keeps strangers out, but traffic is not encrypted. - Browsers are held to the app's own page, opened by IP address (the Origin rule), so a website open on the same machine can't drive the car.
- Experimental objects and members (marked
"stability": "experimental", such assim) aren't covered by the version and may change in any release. - Dev-only actions (
navigate,click,screenshot,sim_*) exist only in developer builds with--dev/--profile, aren't versioned, and drive the bundled UI rather than the managers. Don't build on them.
Changing settings
Each setting has a save_* slot that validates it and notifies (settingsManager.save_theme_setting("NeonMatrix")). To change several at once call settingsManager.apply_settings({"backgroundGrid": "Normal", "backgroundBlurRadius": 40}): every key goes through its own slot, values must already have the right JSON type, and the reply lists applied keys and refused ones with the reason. The Now Playing presets are settingsManager.nowPlayingPresets; apply one with applyNowPlayingPreset("Cinema"). serviceEnabled and serviceWebRoot can't be set over the service at all: they are edited in settingsConfigure.json.
Choosing the music folder
With no native folder picker, browse with mediaManager.browse_music_folders(path): "" gives the browse roots ({roots: [{name, path}]}: your Music folder (not the whole home folder), /media, /mnt, /run/media, /storage, the current music folder), a path inside them {path, parent, dirs, audioFiles, truncated} (names only, no hidden entries, at most 2000), anything outside them {error}, symlinks resolved first. Then settingsManager.save_media_folder(path).
The user's theme
Subscribe to themeCatalog.activeTheme: the palette the user has chosen, already resolved (an album-art palette, a saved custom theme, or a built-in), as {base, baseAlt, accent, text: {primary, secondary}, states, sliders, bottombar, mediaroom, mainmenu, obd} with #RRGGBB colours; activeThemeName names it. Every built-in palette is in themeCatalog.themes (picker order in lightThemeNames / darkThemeNames), custom ones in settingsManager.customThemeData; choose one with settingsManager.save_theme_setting(name). Keys: Theme Object Structure.
Waveform bars
Call audioAnalyzer.setDemand("my-frontend", true) while your bars are on screen (and false when they leave; a closed connection releases it), then subscribe to audioAnalyzer.levels: a list of numBars (96) numbers from 0 to 8, for local tracks and phone audio alike, changing at most 10–20 times a second; pass throttleMs to thin it further. Nothing is analysed while nobody asks.
Album art
nowPlaying.artUrl is a file on the head unit; fetch the picture itself from GET http://host:9876/art (local and phone art alike, typed by its bytes: JPEG, PNG, GIF, WebP or BMP; 404 when there is none). Subscribe to nowPlaying.artRevision, which goes up by one on every new cover, and use it as your cache key: <img src="/art?rev=12"> from a --web-root page, or refetch when it changes. For a list, /art?song=<file name> (URL-encoded, a song in the library, as the playlist APIs name it) gives that song's cover and /art?queue=<id> a phone queue item's (phoneMirrorManager.mediaQueue ids). The request only names a song or an item; the app looks the file up itself and serves nothing outside its own art folders.
Video: the phone mirror and Android Auto
Send {"op":"stream","object":"phoneMirrorManager","name":"video"} (or androidAutoManager / frames for JPEG); both need the phone permission when paired. The reply gives your stream id; then binary records arrive on the same connection: H.264 access units in Annex-B, every keyframe carrying its SPS and PPS, with a streamConfig event (codec string, width, height) whenever those change. A browser decodes them with WebCodecs: decoder.configure({codec}), then one EncodedVideoChunk per record. Touch goes back through phoneMirrorManager.injectTouch(pointerId, action, relX, relY), with 0 to 1 coordinates over the picture. The record layout, the join and drop rules and a phone-free test source (sim.setVideo(true) under --sim) are in docs/api/README.md, "Streams".
Exports
An export writes to the head unit's Downloads folder; a remote client then fetches it over HTTP. Call tripRecorder.exportTrip(id) and, after its exportFinished, download GET /export/trip (the CSV; ?format=json for the JSON); dashboardManager.exportDashboard(id) then GET /export/dashboard; diagnosticsManager.exportLogs() then GET /export/logs (the list of files) and /export/logs?file=octave-cpp.log. Only the last export of each kind is served, never a path you name.
Head-unit only, by design
A few things act on the head unit's own desktop and have no service API: copying a dashboard to the clipboard (copyDashboardToClipboard, importDashboardFromClipboard: use importDashboardFromText and /export/dashboard instead), opening the log folder in a file manager (openLogFolder: use /export/logs), and the window's size and state. A remote client has its own clipboard and window.
What Is Not Available Yet
- Encryption: the service speaks plain TCP and WebSocket. Pairing keeps strangers out of the car; it does not hide the traffic.
- A sandbox for installed frontends: an exec frontend is held to its
scopeson the service, but it runs as OCTAVE's own user and can read the config folder. Install only frontends you trust. - Installed
execfrontends on Android, iOS and the browser build. - The Go SDK as a published module: until it is tagged, a Go project points a
replaceline at sdk/go (the Go template's README says how). - The phone's screen to a client without the
phonepermission, and Bluetooth adapter pairing anywhere but desktop Linux.