Skip to content

Latest commit

 

History

History
592 lines (501 loc) · 45.8 KB

File metadata and controls

592 lines (501 loc) · 45.8 KB

HTTP /control API Reference

/control and /events are compiled only into USE_CONTROL builds (default on — see Building). They live in src/control.c (JSON parsing/serialization + apply logic) and src/events.c (notification plumbing), wired into the HTTP server in src/mp4/httpd.c. No JSON library is used — both request parsing and response building are hand-rolled targeted scanning, consistent with the rest of the codebase's minimal-dependency philosophy.

Authentication and access control

/control, /events, and the HTTP media endpoints (/stream.mp4, /stream.mjpeg, /snapshot.jpg, including their ?chn=N forms) share one access-control gate (http_check_auth/http_check_token in httpd.c). A request is allowed if any one of the following holds; otherwise it gets 401/403:

  1. Loopback bypass — the peer address is 127.0.0.0/8. This is what lets an on-device WebUI always reach the streamer without a password; it replaces prudynt's separate "web UI auth key" mechanism.
  2. A valid token — either the random per-boot token (g_ctl_token, generated at every timpsd start and published to http.token_file, default /run/timps.token, mode 0640, for local privileged readers like the thingino WebUI) or the optional persistent http.token config secret (for remote automation; this one is never written to the token file). Sent as an X-Timps-Token: <token> header (preferred) or ?token=<token> query parameter — the query form exists because <img>/<video src>/EventSource cannot set custom headers, at the cost of the token potentially ending up in proxy/access logs (accepted as fine on a LAN). The token never unlocks RTSP.
  3. HTTP Basic or Digest credentials — http.user/http.pass (falling back to rtsp.user/rtsp.pass if unset). The 401 challenge offers Digest first (RFC 7616 qop="auth", plus legacy RFC 2069 no-qop support, with a tracked nonce ring — see Streaming Protocols) then Basic.

Empty credentials (the shipped default) — media is open, control is not

When no credentials are configured (both http.user and rtsp.user empty, the shipped default), gate #3 has nothing to check, so the generic auth gate passes every request. This is deliberately not symmetric across endpoints:

  • Media endpoints (/stream.mp4, /stream.mjpeg, /snapshot.jpg, incl. ?chn=N) — reachable by anyone on the network, with no authentication. The RTSP video/audio stream behaves the same way (RTSP auth is off while rtsp.user is empty). This is by design: an unconfigured camera streams on the LAN out of the box.
  • /control and /events — carry an extra loopback-only gate: when no credentials are set, a non-loopback request is refused with 403 (if (!c->local && !tok_ok && !user[0]) in httpd.c). So config and event state can never be read or changed from off-device unless you either configure credentials or present a valid token — even though the media is open.

To require authentication for the media too, set rtsp.user/rtsp.pass and/or http.user/http.pass. See the SECURITY block in timps.conf.example and Configuration Reference.

CORS: the three media endpoints send Access-Control-Allow-Origin: * when credentials are configured, a valid token is presented, or the peer is loopback (safe because their auth never relies on ambient browser credentials; on an open camera without a token there is no CORS header and no Private Network Access grant, so a random web page cannot read the video); /control and /events instead reflect the request's Origin: header (with Vary: Origin, allow-listing the X-Timps-Token header, no Access-Control-Allow-Credentials) so a WebUI served from a different port can call /control directly. An OPTIONS preflight is answered 204 No Content before any auth check runs.

Since v1.9.28 a POST that authenticated with Basic/Digest must come from the camera's own host: when it carries an Origin whose host differs from the Host header's host it is refused with 403 bad origin (log: refused cross-origin POST <path>). Browsers re-send cached HTTP credentials on a cross-site form post, so without this any web page could drive /control on a camera its viewer had logged into. Token and loopback requests are exempt (neither is an ambient credential), a request with no Origin header (curl, scripts, NVRs) passes, and ports are not compared, so the WebUI on :443 reaching :8880 is fine.

GET /control — status + capabilities

Returns the entire current in-memory configuration and read-only status as one JSON document. Because it's a config snapshot, this is also how a client discovers, in one shot, which live-editable settings this specific build/platform actually supports.

Top-level shape

{
  "version": "v1.8.5-51-g5b2105f",
  "caps": { "image": [...], "audio": [...], "osd": [...], "restart": [...],
            "rtsp_max_clients": 8, "http_max_clients": 8,
            "events_max_clients": 8,
            "motion": {...}, "privacy": {...}, "rotation": [...],
            "record": {...}, "backchannel": {...}, "play": {...},
            "webrtc": {...}, "timelapse": {...} },
  "image": { ... }, "audio": { ... }, "sensor": { ... },
  "video": { "0": { ... }, "1": { ... } },
  "osd": { "enabled": 1 }, "osd0": { "0": {...}, ... }, "osd1": { ... },
  "privacy": { "0": { "0": {...}, ... }, "1": { ... } },
  "daynight": { ... }, "motion": { ... }, "encoder": { "0": {...}, "1": {...} },
  "record": { ... }, "timelapse": { ... },
  "srt": { "available": 0 }, "tls": { "available": 0 }
}

The caps object

caps exists so a WebUI can grey out controls this exact build/SoC cannot actually apply, instead of hardcoding a feature matrix client-side:

caps.* field Meaning
caps.image Array of image.* leaf keys the HAL wires live on this platform (from src/isp_caps.h's per-SoC macros — e.g. hue only appears on T23/T31/T40/T41/C100). Unlisted image.* keys are still accepted/persisted, just have no live effect.
caps.audio Array of audio.* leaf keys applied live (volume, gain, mute always; alc_gain only where AUDIO_HAS_ALC_GAIN; spk_volume/spk_gain/aec only when a speaker pipeline — USE_PLAY or USE_BACKCHANNEL — is compiled in). Deliberately excludes high_pass/agc/agc_target_dbfs/agc_compression_db/ns even though they're numeric-looking live candidates: libimp runs those on its own vendor record thread and frees state unlocked, so a live toggle would race that thread — they are restart-only by design.
caps.osd The per-item OSD leaf keys /control accepts and applies live (text x y font_size color transparency outline outline_color). Per-item enabled is deliberately not in this list — see the Configuration Reference note on why enabling a boot-disabled item is restart-only.
caps.restart What the WebUI should label restart-required: the whole video and sensor sections, plus every restart-only key of the otherwise-live audio.* and osd.* sections by full name ("audio.codec", "audio.agc", "osd.enabled", "osd.font_path", …, generated from the F_RESTART flag in config.c). caps.video_live below and video<N>.rtsp_path are the per-key exceptions carved out of the video entry — they apply live although their section is named here. Before v1.9.20 the list was ["video", "sensor", "osd.enabled"].
caps.video_live Array of video<N>.* leaf keys (e.g. bitrate, min_qp, max_qp, i_bias_lvl, plus the full classic-SoC set on T10–T30/T23 — see Rate Control Parameters) this build's SoC can apply to a running encoder channel, from src/enc_caps.h. Empty on the host simulator and on channels with no live encoder. A listed key can still fall back to restart at runtime (channel not running, a classic H.265 stream, or the IMP call itself was rejected) — the POST reply's deferred/deferred_keys is the per-request truth; this array is only the platform's static ceiling.
caps.rtsp_max_clients Concurrent RTSP sessions this build accepts before refusing further ones. Compile-time (RTSP_MAX_CLIENTS, default 8, -D overridable per board — low-RAM boards are built with -DRTSP_MAX_CLIENTS=4), so two cameras reporting the same version can differ here.
caps.http_max_clients Same for concurrent HTTP connections (HTTP_MAX_CLIENTS, default 8, -D overridable). Past it the listener answers 503 with body busy. Note that /stream.mp4, /stream.mjpeg and each /events subscriber hold a connection for their whole lifetime, so a single WebUI tab can occupy several.
caps.events_max_clients Concurrent /events (SSE) subscribers before 503 busy. Unlike the two above this is a config key (events.max_clients, default 8), so it is per-camera, not per-build.
caps.motion {"available":0|1, "max_cells":N} — whether this build/SDK has the IMP_IVS move API, and the compile-time cell budget (IMP_IVS_MOVE_MAX_ROI_CNT, 52 on most SDKs, 4 on the old T10/T20 3.9.0 SDK).
caps.privacy {"available":0|1, "max_regions":N} — available reflects whether an OSD group actually exists on any stream (it only does if OSD or a privacy region was enabled at boot), not a hardcoded 1.
caps.roi / caps.fcrop Since v1.10.0. {"tier":"effective|experimental|unsupported|sim", ...} for encoder ROI (regions, slots, align, qp_abs, qp_delta, streams[]) and ISP FrontCrop (live, min, even, sensor:[w,h], state). See Platform & SDK Support.
caps.rotation (Only present in USE_ROTATE builds.) The ascending array of rotation values this SoC's build can actually apply, e.g. [0], [0,90,270], or [0,90,180,270] on T40/T41. See Platform & SDK Support.
caps.record / caps.timelapse {"available":0|1} per USE_RECORD/USE_TIMELAPSE.
caps.backchannel {"available":<bc_available()>,"talk_ws":0|1|2} — available is whether the backchannel was actually configured at boot (restart-only master switch — see Audio). talk_ws is the browser-microphone WebSocket (/talk, USE_BC_WS) on the same backchannel, reported as a second flag rather than a second caps entry because a camera can perfectly well have the RTSP backchannel without it. It is the resolved verdict, not the raw audio.talk_ws value: 0 = this port would not serve /talk at all right now, 1 = served, TLS required, 2 = served, plain ws:// accepted. audio.talk_ws=1 on a plaintext port therefore reports 0 (every request would 426), so a WebUI can gate its talk button on this one number. Nothing here says which scheme to dial: 1 always means wss://; for 2 take the scheme from the same tls field the media/control URLs use.
caps.webrtc (Only present in USE_WEBRTC builds — the key's absence is how a client tells "no WHEP endpoint here" from "compiled in but turned off", which an {"available":0} alone could not say.) {"available":0|1, "enabled":0|1|2}. available folds compile-time and runtime state like caps.backchannel's does: the shared DTLS context only exists once webrtc_start() built it. enabled is the resolved signalling requirement, not the raw webrtc.enabled, with the same 0/1/2 meaning as caps.backchannel.talk_ws: 0 = /webrtc/whep would refuse a POST right now, 1 = served but TLS is required for it, 2 = served and a plaintext POST is accepted. webrtc.enabled=1 on a port with no TLS configured therefore reports 2, because the endpoint only 426s a downgrade, never a deployment choice. Added 1.9.17; before it a client had to GET the POST-only endpoint and read its 404/503/426/405. "chn_select":1 (added 1.9.23) says POST /webrtc/whep?chn=N picks the stream; an older daemon ignores ?chn= and sends webrtc.channel.
caps.framesource {"keepalive":0|1, "cfg":-1|0|1} — keepalive is general.fs_keepalive as resolved at start (1 = every video FrameSource is held enabled until the daemon stops; auto = on only for OpenIMP builds on T23/T41), cfg the raw config value. Read-only; the key is file-only and restart-bound. See Architecture.
caps.daynight {"available":0|1, "ir_idle":0|1} — available per USE_DAYNIGHT; ir_idle = 1 when daynight.ir_idle_off could act on this start (it needs the FrameSource keepalive). The runtime state is daynight.ir_idle in the status object (dark = the illuminator is off because nobody watches).
caps.play {"available":0|1, "sounds":[...]} — the play queue, with sounds live-enumerated from /usr/share/sounds (.wav/.ulaw always; .opus only when USE_PLAY_OPUS was actually compiled in, capped at 96 entries to bound the JSON response size).

Build-feature discovery — *.available

The fleet does not run one binary. The thingino firmware package and the standalone build.sh binary are compiled with different USE_* sets (the firmware package links mbedTLS; the standalone build typically does not), and version — a git describe string — is identical across them. So "does this camera speak HTTPS?" is not answerable from the version, the config file, or anything else a client can read remotely.

Every optional feature therefore reports an available flag, and a client should branch on that, never on a version comparison:

Where Feature Emitted when off
caps.record / caps.timelapse / caps.play / caps.backchannel / caps.motion USE_RECORD / USE_TIMELAPSE / USE_PLAY / USE_BACKCHANNEL / IVS move API {"available":0}
caps.rotation / caps.webrtc USE_ROTATE / USE_WEBRTC key absent entirely
srt (top level) USE_SRT {"available":0}
tls (top level) USE_TLS {"available":0}

srt and tls sit at the top level rather than under caps because, when the feature is compiled in, they also carry the runtime settings needed to dial it:

"srt": {"available":1,"enabled":1,"port":9000,"channel":0,"mode":"listener",
        "connected":1,"stats_age_s":3,"rtt_ms":12.4,"bw_mbps":48.10,
        "rate_mbps":2.85,"retrans":0,"loss":0,"drop":0}
"tls": {"available":1,"https":1,"rtsps":1,"rtsps_port":322}

srt.mode is "listener" or "caller" (see Streaming Protocols), and the rtt_ms/bw_mbps/rate_mbps/retrans/loss/drop numbers come from the last 10-second srt_bstats tick, so a test harness can see link health without SSH. -1, and stats_age_s:-1, mean no connection has lasted long enough to produce a sample yet.

When the build lacks the feature, only {"available":0} is emitted. That is deliberate for tls in particular: http.https/rtsp.tls may well be 1 in timps.conf on a non-TLS build — that mismatch is exactly what logs RTSPS requested but built without USE_TLS at startup, where no HTTP client ever sees it — but no listener was opened, so echoing the requested flags would invite a client to dial a port nothing is bound to. "available":0 means ignore any TLS configuration you may have seen elsewhere; this binary cannot serve it.

The encoder object — read-only encoder telemetry

GET /control also carries a top-level "encoder" object with one entry per video channel that currently has a live encoder: {"0":{...}, "1":{...}}. This is a read-only diagnostics addition — there is no matching /control POST surface, and no new config keys. Each entry comes straight from IMP_Encoder_Query (available on all 9 platforms):

"encoder": {
  "0": {"registered":1,"left_pics":0,"left_stream_bytes":0,
        "left_stream_frames":0,"cur_packs":1,"work_done":1,
        "ave_bitrate":3012.4}
}
Field Meaning
registered Whether the channel is registered to its encode group.
left_pics Images still queued to encode.
left_stream_bytes / left_stream_frames Bytes/frames still sitting in the stream buffer, unread.
cur_packs Stream packets making up the current frame.
work_done 0 = still running, 1 = not running.
ave_bitrate T31 only, and only once at least one frame has flowed: the running average bitrate from IMP_Encoder_GetChnAveBitrate (a T31-exclusive call that needs the just-fetched stream buffer, so it's computed and cached by the encode thread itself rather than queried directly from the /control handler, which would otherwise steal packets from the streaming loop).

A channel whose query fails — a disabled stream, the T23 SW-rotate path (which has no bound encoder channel/group at all), or the host simulation backend — is omitted from the object entirely rather than reported with misleading zeros.

The encoder.<n>.rc object — what the encoder actually holds

Each encoder.<n> entry can carry an additional "rc" sub-object, read live via IMP_Encoder_GetChnAttrRcMode — a second source of truth next to the video<N>.* block, which only reports what was written. The two exist so a written value can be diffed against what the encoder held after the fact; see Rate Control Parameters for why that gap has mattered in practice (two SDK-header-derived assumptions about T23 rate control turned out wrong until this readback existed).

"encoder": {
  "0": {"registered":1, "left_pics":0, "...": "...",
        "rc": {"rc_mode":"vbr", "bitrate":2000, "max_bitrate":2000,
               "min_qp":20, "max_qp":45, "quality_lvl":7,
               "change_pos":80, "i_bias_lvl":0}}
}

Fields reuse the video<N>.* names where they mean the same thing. Only fields the current mode/API actually carries are present — a cbr channel never shows quality_lvl, a classic-SoC channel never shows ip_delta/pb_delta/rc_options/max_picture_size/max_psnr. On the new-generation API (T31/C100/T40/T41) those last five are the attributes timps itself never writes (left at the vendor SDK default); this readback is the first place their values are visible at all — as raw SDK numbers, units unverified. If hal_enc_rc_read() fails (no live channel, unqueryable state), the whole rc key is omitted, same as the parent encoder.<n> entry's own omission rule above.

Request/response examples

Get full status:

curl http://127.0.0.1:8880/control

Change a live setting (image brightness) and read it back:

curl -X POST http://127.0.0.1:8880/control -d '{"image":{"brightness":140}}'
curl http://127.0.0.1:8880/control | jq .image.brightness
# -> 140 (applied immediately via IMP_ISP_Tuning_SetBrightness; persisted to timps.conf)

Change a restart-only setting (encoder bitrate) — it persists and is echoed back, but the running encoder keeps its current bitrate until the next restart:

curl -X POST http://127.0.0.1:8880/control -d '{"video":{"0":{"bitrate":3500}}}'
curl http://127.0.0.1:8880/control | jq .video."0".bitrate
# -> 3500 (in the config; the live stream is unaffected until restart)

Using a token instead of Basic auth (from a browser context that can't send Authorization, e.g. <img>):

curl "http://127.0.0.1:8880/snapshot.jpg?token=$(cat /run/timps.token)" -o snap.jpg

Scoped sub-endpoints — ?fields=1, ?stats=1, ?dn_history=1, ?clients=1

Four GET /control query flags return a different, much smaller document instead of the full snapshot. They share the same auth/CORS gate as the plain GET, and each gets its own small heap buffer rather than the snapshot's CONTROL_JSON_CAP, so a frequent poll never pays for the whole document. They are matched by substring (strstr), the same convention as chn=/token= elsewhere in src/mp4/httpd.c — there is no full query parser.

Flag Returns Added
?fields=1 The inventory of every F_CTRL-flagged (i.e. POST-able) config field, grouped by section. Walked from the same tables POST /control applies, so it cannot drift from what the POST really accepts; scripts/timps-qa.sh section 8 diffs its own coverage list against it. 1.8.1
?stats=1 The slow-path complement of the /events stats push. 1.9.18
?dn_history=1 The day/night decision series out of the in-RAM ring. 1.9.15
?clients=1 The connected streaming clients with protocol, stream, rate, total bytes and User-Agent. 1.9.24

?stats=1

Deliberately not a subset of the snapshot: it carries only what the SSE stats event cannot — the config-level gop/profile/rc_mode per stream and the IMP_Encoder_Query backlog — because fps/kbps/dims/codec/subs/drop already arrive pushed. The contract is "what the stats push can't carry", not "encoder".

{"video":{"0":{"gop":50,"profile":2,"rc_mode":"cbr"},
          "1":{"gop":50,"profile":0,"rc_mode":"cbr"}},
 "encoder":{"0":{"left_pics":0,"left_stream_bytes":0,
                 "left_stream_frames":0,"ave_bitrate":1180.0}}}

video lists every stream slot. encoder omits channels whose query fails (disabled stream, SW-rotate path, host sim) rather than reporting zeros, and ave_bitrate appears only where the SoC supplies it — the same rules the full snapshot's encoder object follows. The shape mirrors the corresponding sub-objects of the snapshot, so a client can read either source with one code path.

?clients=1

One entry per streaming consumer: RTSP session (rtsp/udp, rtsp/tcp, rtsps), fMP4 (fmp4), MJPEG (mjpeg), SSE (events), WebRTC (webrtc) and SRT (srt). One-shot requests such as /control or /snapshot.jpg are not listed.

{"clients":[
 {"ip":"192.0.2.17","port":32834,"proto":"rtsp/tcp","chn":0,
  "since_s":41,"kbps":1600,"bytes":8200000,"lat_ms":18,"drops":0,"agent":"FFmpeg Frigate/0.17.2-3d4dd3a"},
 {"ip":"192.0.2.103","port":46712,"proto":"rtsp/udp","chn":1,
  "since_s":36,"kbps":214,"bytes":962000,"lat_ms":12,"agent":"LibVLC/3.0.20 (LIVE555 Streaming Media v2016.11.28)"}]}
  • chn is the source stream (0 main, 1 sub); -1 where none applies (events, the plain MJPEG snapshot stream).
  • kbps is what timps sent to that client (RTP incl. the 4-byte interleave header on TCP, fMP4/MJPEG body bytes, SRTP, TS packets), averaged since the previous read but over at least 1 s, so several pollers cannot shrink the window to noise. The first read averages since the connection started.
  • bytes is the total sent to that client since it connected, counted the same way as kbps (64-bit, no wrap).
  • lat_ms (since 1.9.26) is the camera's share of the latency, per client: from the sensor capture of a video frame (the IMP pack timestamp, compared on the IMP clock) to the moment timps sends it to this client, averaged over the last frames (1/8 weight per frame). It covers ISP, encoder and the queue to this client, not sensor exposure, so a slow client shows more than the others on the same stream. RTSP, WebRTC, MJPEG and SRT take the time before the send call, fMP4 after it (so a blocking TCP write counts there). -1 for events and before the first frame. Where no capture timestamp exists (SW-rotate on T23, the simulator) only the queue part is counted.
  • agent is the request's User-Agent, truncated to 159 characters, "" when the client sent none. Since v1.9.28 control bytes and non-ASCII are replaced by ? (the value is logged verbatim, and a client could otherwise put terminal escapes into a log viewer). SRT has no such header and is always "". WebRTC takes it from the WHEP POST.
  • ip/port is the TCP peer (the RTSP control connection for rtsp/udp); for webrtc and srt it is the UDP media peer.
  • The table holds 40 entries; a client beyond that streams normally but is not listed.

?dn_history=1

Paged out of the ring daynight.history_s sizes (default 0 = off, no ring, no allocation — see Configuration Reference).

  • ?last=N backfills from the newest N samples (page load, or a resync after a lapped cursor); ?since=S tails from a cursor. last wins if both are given.
  • ?max=N caps rows per response at DN_HISTORY_MAX_ROWS (600). last is not capped by it — it only picks the start, so a client with more to catch up on just follows next again until it equals head.
{"t_now":41230,"wall_now":1757900000,"period_s":10,"retain_s":14400,
 "cap":1440,"head":4123,"oldest":2683,"since":4083,"next":4123,"lapped":0,
 "day_gain":180,"night_gain":600,
 "samples":[[41220,512,33000,84,42,0], ...]}

Each sample row is an array, not an object ([t, gain, exposure, luma, bright, mode]): at 600 rows a per-row key set would roughly triple the body for no information. head/oldest/next/lapped let a client tell "nothing new" from "I fell out of the retained window" — on lapped it refetches with last rather than splicing across a hole.

t_now is the daemon's monotonic second, which the samples are stamped with; wall_now is the wall clock at the same instant. The camera boots without NTP and steps its clock mid-session, so a client re-derives wall times from that pair on every response — which moves the whole series together instead of tearing it.

Errors on either endpoint: 500 if the document did not fit its buffer, 503 on an allocation failure.

POST /control — apply settings

Takes a nested JSON body; every recognized setting is:

  1. flattened to its config-file key (image.brightness, osd0.0.text, video0.bitrate, ...),
  2. applied to the in-memory config (config_apply_kv),
  3. change-detected (before/after comparison; a no-op re-POST is skipped so a client that re-sends the same value every few seconds can't hammer the ISP or rewrite flash — with one deliberate exception: image.running_mode always re-drives the ISP even when unchanged, because it's a hardware-sync command whose actual latched state can drift from the config model — see Day/Night),
  4. applied live via hub_control() → the HAL (when a live-apply path exists for that key),
  5. pushed to any other open /events subscribers as a config event,
  6. and finally, all changed keys from the whole request are written back to the config file in one batched, atomic config_write_keys() call.

JSON shape

Nested per-section objects, matching the config-file section prefixes:

{
  "image": {"brightness":140,"contrast":128,"hue":128,"hflip":0,"running_mode":1},
  "audio": {"volume":90,"gain":30,"mute":false,
            "codec":"aac","samplerate":16000,"channels":1,"bitrate":32},
  "speaker": {"play":"chime_1.wav"},
  "osd":   {"enabled":1},
  "osd0":  {"0":{"enabled":1,"text":"%Y-%m-%d %H:%M:%S","x":10,"y":10,
                 "font_size":32,"color":"0xFFFFFFFF",
                 "outline":1,"outline_color":"0xFF000000"},
            "3":{"enabled":0}},
  "osd1":  {"0":{"text":"sub cam"}},
  "video": {"0":{"bitrate":3500},"1":{"bitrate":600}},
  "privacy": {"0":{"0":{"enabled":1,"x":0,"y":0,"w":200,"h":100,"color":"0xFF000000"}}},
  "roi": {"0":{"0":{"enabled":1,"x":640,"y":320,"w":320,"h":240,"qp":-6,"qp_mode":1}}},
  "sensor": {"model":"gc2053","i2c_addr":55,"fps":25,"width":1920,"height":1080},
  "daynight": {"mode":"sun","sun_latitude":52.52,"sun_longitude":13.40},
  "motion": {"enabled":1,"sensitivity":128,"cols":5,"rows":5},
  "record": {"active":1},
  "timelapse": {"interval_s":120}
}

Every field is optional; unknown keys are ignored — and named back in the reply's ignored array, so a body mixing a valid key with a typo does not read as a clean success; the legacy flat form ({"brightness":140,"running_mode":1} or {"force_mode":"night"|"day"}) still works and maps onto image.*.

A field's config-file alias is accepted here too, exactly as it is by the config-file parser — video0.mode for rc_mode, record.segment for segment_s, osd0.0.stroke for outline, daynight.total_gain_day_threshold for day_gain, and so on (the aliases are listed per key in the Configuration Reference). This was name-only until e79b5a9, which silently dropped the shipped WebUI's photosensing sliders: they post the pre-rename total_gain_day_threshold/total_gain_night_threshold spellings, got a 200 back, and nothing was saved. The canonical name still wins if a body carries both, and what is applied, persisted and echoed in applied is always the canonical spelling. The ignored list uses the same alias-aware test, so an alias is never reported as ignored while in fact being applied.

Section-by-section behavior

See Configuration Reference for the authoritative per-key live/restart table; this is the request-shape summary:

JSON section Maps to Live-apply behavior
image image.* Every key accepted and live-applied where the SoC supports it (caps.image).
audio audio.* volume/gain/alc_gain/mute/spk_volume/spk_gain live; spk_enabled/aec take effect at the next speaker (AO) open, talk_ws at the next /talk request; the rest (codec/samplerate/channels/bitrate/high_pass/agc/ns/force_stereo/backchannel*) persist-only.
speaker not persisted {"play":"<file>"} enqueues a system sound on the play FIFO (validated against /usr/share/sounds, no / or ..); {"stop":1} stops it. Transient action, USE_PLAY only — see Audio.
daynight daynight.* + {"probe":1} enabled/mode/time_night_start/time_day_start/the threshold, probe, heartbeat, boot and sun-offset numerics plus interval_ms/diagnose_thresholds are all live (the detection thread polls g_cfg directly rather than being pushed through a HAL call); mode is validated against auto/schedule (legacy sensor/time/sun still accepted) before being applied. Eight further numerics — probe_jump_pct, probe_settle_s, ref_delay_s, ir_ratio_night, ir_ratio_day, ir_min_headroom, boot_settle_s, transition_s — are fixed internal constants as of the 2026-08-22 config consolidation: still readable in the status object for diagnostics, but no longer POST-able. switch_cmd/isp_path/trace_path/irprobe_cmd are deliberately not POST-able (exec'd command / paths the daemon writes as root, config-file only). probe is a command, like record.clip: it arms one silent IR probe for the next tick and is rejected (not silently ignored) on a camera with no daynight.irprobe_cmd configured or whose silent probe has retired itself for the session — see Day/Night.
osd (legacy shared form) osd.enabled/monitor_stream/font_path/supersample/hinting + osdN.* mirrored onto every stream These osd.* globals are looked for only in the JSON span before the first nested item object, so an item's own keys (e.g. an item's enabled) are never mistaken for them. enabled/font_path/supersample/hinting are restart-required (listed in caps.restart, reported in deferred_keys); monitor_stream applies on the next OSD refresh. Since v1.9.28 the {placeholder} source file for OSD text is fixed (/tmp/timps_osd.vars) and has no config key at all — not osd.vars_file anymore, which used to exist but let a settable path point at timps.conf itself (a POSTed osd.vars_file now lands in ignored).
osd0/osd1 (canonical per-stream form) osd<S>.<N>.* Applied live via imp_osd_apply() for items that already had a region at startup, except type (text vs. logo): the live re-render dispatch is fixed at region-creation time, so changing an existing item's type persists but needs a restart to actually change what's drawn. logo/logo_w/logo_h/font_path (per-item override) are persist-only and not GET-readable.
video video<N>.* Persist-only by default (the encoder/FrameSource is never reconfigured live) except rtsp_path (always live) and the caps.video_live subset (2026-08-21 — bitrate/QP/rc knobs, per SoC; see Rate Control Parameters), which reaches the running encoder channel through the calls listed there. Geometry/codec/identity keys (width/height/fps/codec/profile/buffers) and anything caps.video_live doesn't list stay restart-bound.
privacy privacy<S>.<N>.* Live (create/show/hide/move) as long as an OSD group exists on that stream.
sensor sensor.* Persist-only; applied at the next ISP init.
motion motion.enabled/sensitivity/cols/rows/monitor_stream All live — the HAL stops and recreates the whole IVS grid on any of these (a single request's several motion keys are batched into one rebuild via hub_control_commit(), not one rebuild per key). hold_ms/skip_frames are also POST-able (persist + echo) but only feed the grid/hold logic at the next such rebuild or a restart, not immediately. cooldown_ms/on_motion are deliberately not POST-able (config-file only).
record record.* + {"active":1|0} + {"clip":"...","seconds":N} Config keys apply on the recorder's next loop pass (no restart); active is an immediate manual start/stop override; clip/seconds triggers an independent one-shot on-demand fMP4 capture, not persisted. The POST returns once the clip is written; since v1.9.28 the capture no longer holds up other POSTs meanwhile, and a second clip while one is running is rejected. An accepted {"active":1} is not a promise that recording starts: if record.min_free_mb cannot be reached even by deleting every existing recording, the recorder refuses to open a segment, and the GET status object's recording stays 0 with write_errors incremented and last_error reading min_free_mb=<N> unreachable (max <M>MB) — see Recording & Timelapse.
timelapse timelapse.* Applied on the timelapse thread's next loop pass, no restart.

Response body and status codes

Every POST /control answers application/json with the same body shape, whatever the status:

{"ok":true,"accepted":2,"changed":1,"rejected":0,"not_persisted":0,
 "deferred":0,"deferred_keys":[],"ignored":[],
 "applied":{"image.brightness":"255"}}
Field Meaning
ok true only when at least one known field was applied.
accepted Known fields applied, including no-op rewrites of the value a field already held — re-posting the current value is a success, not a silent failure. Clamped writes count here too: clamping is the documented contract, not an error. Also counts commands that were carried out (record.clip, daynight.probe), which never go through the settings path at all.
changed The subset that actually differed and was persisted.
rejected Known fields whose value was refused (null, undefined, or an empty string on a non-string field), plus commands that were understood and failed (record.clip to an unwritable path, or — since v1.9.28 — while another clip is still being written).
not_persisted At most accepted: settings applied live but not written to /etc/timps.conf, because the request changed more keys than the 48-slot persist list holds, or because the config write failed (this one or an earlier one whose keys are still pending; every later POST retries them). Those values are live now and gone after the next reboot; a caller changing many keys at once should split the request or re-GET to confirm what survived.
deferred / deferred_keys (2026-08-21) Of changed, the keys that were persisted but did not reach the running pipeline this request: video<N>.*/sensor.* graded per request (rtsp_path never, it is live), and since v1.9.20 every restart-only audio.*/osd.* key named in caps.restart — deferred is always the exact count, deferred_keys lists them (subject to deferred_truncated, same overflow contract as applied/truncated). A key absent from deferred_keys after a successful changed count on a video/sensor field DID apply live — see caps.video_live and Rate Control Parameters. The remaining sections (image, motion, privacy, record, timelapse, daynight, OSD items) are graded by the section-by-section table above instead; deferred never lists them.
ignored (2026-08-22) Field names the request carried that this build did not apply — a typo, a key from another section, a key gated out of this binary, or one with no /control write path (motion.on_motion, video<N>.imp_chn, …). Fully prefixed ("video1.quality_level"). It changes no count and nothing about what was applied: {"quality_lvl":7,"quality_level":5} still applies the first key and still answers 200 accepted:1 — it now also says the second one went nowhere, instead of leaving that request looking like a clean success. (A body carrying only unknown keys was always visible as the 422 below; the mixed body, which is what a real client produces, was not.) Reports unknown fields inside sections this build understands — an unknown top-level section, an out-of-range stream/item index and an object-valued member are not fields and are not listed, so an empty array is not a promise that every name in the body was understood.
ignored_truncated Present (true) only if the ignored list is short — more names than the 512-byte buffer holds, or a name too long to carry.
applied Per-key echo of the effective value wherever it differs from what was posted — i.e. after clamping. This is how a caller that posted 999 learns it got 255, without re-GETting the document.
truncated Present (true) only if more keys changed than the 512-byte echo holds; fall back to a GET.
reason Present only on the error answers below — the machine-readable discriminator, so a client never has to infer the case from the status line.
Status reason Meaning What the client should do
200 OK — At least one known field was applied (or one command carried out). A partial request — some fields applied, others rejected or unknown — is a 200; check rejected and ignored. Nothing. Read applied for clamped values.
400 Bad Request not_json The body was not one balanced JSON object (garbage, empty, unbalanced braces, an unterminated string, trailing data). Fix the caller — this is a client bug.
400 Bad Request body_truncated Fewer body bytes arrived than Content-Length announced (peer closed, or the 5 s body deadline). Nothing was applied. Resend.
405 Method Not Allowed — Anything but GET, HEAD, POST (and the OPTIONS preflight). Use POST.
500 Internal Server Error reply_too_large The reply did not fit its buffer. The change itself was applied. Re-GET /control; report it.
422 Unprocessable Content unknown_fields It parsed, but carried no field this build knows: a typo, the wrong section, or a key gated out of this binary. Nothing was applied; ignored names the keys. Check spelling — and check the *.available flags above, because the key may simply not exist in this build. Retrying the identical body will never succeed.
409 Conflict values_rejected It parsed and every field in it was known, but every one of them was refused: bad values, or a command that failed. Nothing was applied. The key names were right; re-send with valid values.
413 Payload Too Large — Content-Length negative, or larger than the request buffer. Split the request.
503 Service Unavailable oom The daemon could not allocate to service the request. Retry later; not a client error.
503 Service Unavailable shutting_down Since v1.9.28: the daemon is shutting down and no longer accepts POSTs. Not a client error; the next boot will accept it.
403 Forbidden — (plain-text body bad origin, not JSON) Since v1.9.28: the POST was authenticated by Basic/Digest and its Origin host differs from the Host header's host. Browsers re-send cached HTTP credentials on cross-site form posts, so this is the CSRF guard. Token and loopback requests, and clients that send no Origin at all (curl, scripts), are not affected; ports are not compared. Post from a page served by the camera itself, or authenticate with the token.

422 and 409 were one code until now, and they are opposite instructions: 422 says your key names are wrong for this binary, 409 says your key names were right and your values were not. A client that retried the first unchanged would loop forever; a client that went hunting for a missing build feature on the second would be chasing nothing.

Compatibility note. 422 deliberately kept the unknown-field meaning rather than the (semantically tidier) value-rejection one, because that is what the installed base already asserts: thingino's timps-selftest.sh probes an unknown key and fails the camera on anything but 422, and the WebUI's timps-api.js prints its "no setting in this request is known to this timps build" message on a 422 with rejected == 0. Moving that case would have turned every fielded selftest red. The value-rejection case moved instead; the only casualty is the rejected > 0 branch of that same WebUI message, which degrades to a generic "HTTP 409" line until the WebUI is updated. Clients keying off res.ok or on 2xx are unaffected — both cases were, and remain, non-2xx.

GET /events — Server-Sent Events push stream

An alternative to polling GET /control: a long-lived text/event-stream connection that pushes JSON the moment relevant state changes. Same access-control rules as /control (loopback/token/Basic or Digest), same CORS handling. events.enabled=0 makes the endpoint answer 404; events.max_clients (default 8) caps concurrent subscribers below the general HTTP client limit — beyond it the endpoint answers 503 with body busy (a HEAD request does not count against this limit and never enters the streaming loop).

curl -N http://127.0.0.1:8880/events                                    # everything
curl -N "http://127.0.0.1:8880/events?stream=motion,stats&token=$(cat /run/timps.token)"

?stream=motion,daynight,stats,config selects a subset of event types (default: all four). Browsers use the query-string token form because EventSource cannot set custom headers.

Wire format

On connect: retry: 3000 (tells EventSource to reconnect after 3s if dropped), then a : connected comment line. Every event frame is event: <type>\ndata: <json>\n\n, capped at 1280 bytes — an oversized payload is dropped entirely (never truncated, so as not to poison the stream framing for the client's parser) and logged as a warning. A : ping comment line is sent roughly every 12 seconds of otherwise-quiet connection, both to detect a dead client (a failed write ends the connection) and to keep intermediate proxies from timing it out.

Each connection deduplicates independently against what it last sent — producers (the IVS result thread, the day/night sampler, /control writes) wake subscribers through a shared condition variable, so push latency is just the producer's own sampling rate, never HTTP polling.

Event types

event: Pushed when data: payload Delivery semantics
motion A grid transition occurred, or enabled/geometry/sensitivity changed Identical shape to /control's "motion" object (grid + active[] + last_ms) Lossless, queue-driven: every real transition is captured in a bounded 32-entry snapshot ring with a per-connection cursor, so two transitions between two samples are never collapsed into one (which plain level-sampling would do, since IVS clears retRoi on the very next processed frame). A cursor that falls too far behind is jumped forward to the oldest retained snapshot rather than blocking the producer.
daynight Mode flipped, or brightness moved ≥1%, or gain moved ≥5% relative (or ≥8 absolute near zero) Identical shape to /control's "daynight" object Level-sampled with a per-connection dedup threshold matching the producer's own event-worthy-change filter in daynight.c, so brightness/gain jitter every sample doesn't spam the stream.
stats Every events.stats_ms (default 2000ms; 0 disables) {"uptime_s":N,"clients":N,"video":[{"chn":0,"subs":N,"fps":F,"kbps":F,"width":N,"height":N,"codec":"h264","drop_frames":N,"drop_bytes":N},...]} Periodic tick. video[] only lists streams enabled at boot (g_cfg_boot), so the reported geometry/codec always matches what the fps/kbps numbers were actually measured on.
config Another client's /control POST changed a setting {"key":"<key>","value":"<value>"}, or {"resync":true} once if this connection fell behind a bounded coalescing table and may have missed an update A small fixed 24-slot table (sized so one bulk image-tuning POST fits in a single push) coalesces rapid repeated changes to the same key into one entry; a genuinely new key evicts the globally-oldest slot when full and flags lapped subscribers to re-GET /control instead of silently missing the update.

motion and daynight also emit their full current state once on connect, before any change occurs — a subscriber never has to prime itself with a GET /control first. (The preview page's stats card relies on exactly this: it subscribes to ?stream=daynight,motion for those two summaries and polls only ?stats=1 for the fields no push carries.)

The thingino WebUI's preview overlay subscribes to ?stream=motion and falls back to 4Hz /control polling if /events is unavailable.