LensLink
LanguageEN

Browser control panel

The plugin serves the Live screen's camera controls at http://localhost:9980, in a browser window you can park beside OBS. Nobody has to touch the phone.

Opening it#

Open http://localhost:9980 on the computer running OBS. The panel is on by default; the switch is Tools → LensLink Settings → Browser control panel.

Loopback only. The panel binds to 127.0.0.1, so nothing on the network can reach it — deliberate, since it can start cameras and has no authentication.

Panel port (default 9980) is a plugin-wide setting if something else already has that port.

What is on it#

Same controls as the app's Live screen, laid out the same way:

It mirrors changes made on the phone, pausing while you drag so it never fights your hand. A LensLink Screen source has no camera controls, and the panel says so rather than showing dead sliders.

The HTTP API#

The panel is one client of a small HTTP API on the same port; the Stream Deck plugin or a script can be another. Every endpoint except /api/sources and /api/diagnostics takes an optional ?src=<id>, with an id from /api/sources. Without it the plugin uses the first registered source, the one /api/sources lists first, which can be a LensLink Screen source. With more than one source, pass src.

ResponseWhen
404 {"error":"unknown source"}src names no source: it was removed, or OBS restarted. The request does nothing, rather than reading or controlling a different phone.
400 {"error":"bad src"}src is empty, not a whole number, or given twice.
503 no sourcesThere are no LensLink sources at all.

Ids count up as sources are created and start over when OBS restarts, so read them from /api/sources rather than storing one. The panel itself re-picks a source when its id stops working.

Reading state#

RequestReturns
GET /api/sourcesEvery source: id, name, connected, standby, armed (in standby and accepting start_stream), screen.
GET /api/statusstatus text (in OBS's language), its tone (idle, wait, ready, live or error, for coloring it without reading the text), plus screen, standby, armed, connected, autoStart and the lip-sync sync stage.
GET /api/diagnosticsThe whole diagnostics report as plain text — versions, hardware, settings and per-source state. Same content as the button in Tools → LensLink Settings.
GET /api/stateThe phone's last camera-state snapshot (keys below), or {} before the phone has sent one. The same cache the source properties read, which is how the three surfaces stay in step.

State keys#

The snapshot is the app's own JSON, passed through as is. Keys an older app doesn't know are simply absent, and a few flags (greenScreen, greenScreenDepth, micEnabled, hdr) appear only when true.

KeysMeaning
paused, pauseReasonWhether video is held, and who held it: "user" (an operator pause) or "camera" (iOS interrupted capture).
zoom, maxZoomCurrent zoom factor and its upper limit.
exposureBiasAE bias in EV.
exposureMode, isoMode, shutterMode, iso, minISO, maxISO, shutterSeconds, minShutterSeconds, maxShutterSeconds, supportsManualExposure"auto" or "manual" overall (manual when either is), then for ISO and shutter each, the current ISO and shutter, and the ranges the camera accepts. The longest shutter is capped at one frame, so maxShutterSeconds follows the frame rate.
focusMode, lensPosition, faceFocus, supportsFaceFocus"auto" or "locked", the focus position (0 to 1), and face-driven focus.
whiteBalanceMode, whiteBalanceTemperature, whiteBalanceTint, supportsWhiteBalanceLock"auto" or "locked", the locked temperature in kelvin, and the tint a calibration measured (0 otherwise).
naturalBlurWhether auto exposure keeps the shutter at half the frame interval or faster, as set by natural_blur. Absent from apps that predate it.
flashlight, flashlightLevel, hasFlashlightFlashlight state, its brightness (0.01 to 1), and whether this camera has one.
camera, lens, lenses, lensFactors, cropZoom"front" or "back", the current lens's label, and the labels of every lens on the phone, such as "Main (Wide)" or "Ultra Wide (0.5×)". Labels stay English; they are the values selectLens takes. lensFactors gives each lens's magnification relative to Main, in the same order; cropZoom, when present, is the zoom of Main's full-detail sensor crop.
stabilization"off", "standard" or "cinematic", as set by stabilization.
resolution, fps, codec, resolutions, frameRates, codecsThe current format, and the choices set_format accepts for the current lens.
quality"balanced" or "maximum", as set by set_quality.
hdr, color"hdr": true for HLG, "color": "log" for Apple Log; both absent for standard color.
supportsGreenScreen, greenScreen, greenScreenDepth, greenScreenMaxDistanceGreen screen support, whether it is on, whether depth assist is running, and the subject-distance cutoff in meters (-1 while it is on Auto).
micEnabled, mic, micsPresent only while the phone mic is the source's audio: the selected mic's id, and every choice as [{"id": "...", "name": "..."}]. Send an id back with the mic command; name is for display.

Sending commands#

POST /api/control takes one JSON command per request (up to 512 bytes) and passes it to the phone as is. Unknown commands are ignored by design, so a newer panel and an older app degrade rather than break.

POST /api/control
{ "cmd": "zoom",          "value": 2.0 }
{ "cmd": "exposure_bias", "value": -0.5 }
{ "cmd": "exposure",      "mode": "manual", "iso": 400, "shutterSeconds": 0.004 }
{ "cmd": "exposure",      "mode": "auto" }
{ "cmd": "exposure",      "shutterMode": "manual", "shutterSeconds": 0.01 }
{ "cmd": "focus",         "mode": "locked", "lensPosition": 0.42 }
{ "cmd": "focus",         "mode": "auto" }
{ "cmd": "focus",         "faces": true }
{ "cmd": "white_balance", "mode": "locked", "temperature": 5600 }
{ "cmd": "white_balance", "mode": "auto" }
{ "cmd": "white_balance", "mode": "calibrate" }
{ "cmd": "white_balance", "mode": "calibrate", "x": 0.31, "y": 0.62 }
{ "cmd": "lock",          "target": "whiteBalance", "on": true }
{ "cmd": "stabilization", "mode": "cinematic" }
{ "cmd": "natural_blur",  "on": true }
{ "cmd": "flashlight",    "on": true, "level": 0.6 }
{ "cmd": "flip" }
{ "cmd": "selectLens",    "label": "Ultra Wide (0.5×)" }
{ "cmd": "start_stream" }
{ "cmd": "stop_stream" }
{ "cmd": "pause_stream" }
{ "cmd": "resume_stream" }
{ "cmd": "set_format",    "resolution": "1080p", "fps": 60, "codec": "hevc" }
{ "cmd": "set_quality",   "quality": "maximum" }
{ "cmd": "mic",           "id": "builtin:2" }
{ "cmd": "green_screen",  "on": true, "maxDistance": 2.5 }
RequestEffect
POST /api/autostart{"on":true|false} — the same property the auto-start checkbox edits. An empty body is rejected, not read as false.
POST /api/recalibrateNo body. Starts a fresh lip-sync measurement.
POST /api/still, then GET /api/stillA still of the next frame as a small BMP, for picking a calibration point. The POST asks for it; the GET returns 204 until it is ready, then the image once.

start_stream works only while the phone has remote start armed; check armed in /api/status first. stop_stream always works.

Examples#

# Start the camera on the only connected phone (it must be armed)
curl -X POST http://localhost:9980/api/control -d '{"cmd":"start_stream"}'

# Zoom the source with id 2 to 3x
curl -X POST 'http://localhost:9980/api/control?src=2' -d '{"cmd":"zoom","value":3.0}'

# What is connected right now?
curl -s http://localhost:9980/api/sources

Turning it off#

Clear Browser control panel in Tools → LensLink Settings. The port closes immediately; everything else is unaffected.