/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.
/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:
- 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. - A valid token — either the random per-boot token
(
g_ctl_token, generated at everytimpsdstart and published tohttp.token_file, default/run/timps.token, mode 0640, for local privileged readers like the thingino WebUI) or the optional persistenthttp.tokenconfig secret (for remote automation; this one is never written to the token file). Sent as anX-Timps-Token: <token>header (preferred) or?token=<token>query parameter — the query form exists because<img>/<video src>/EventSourcecannot 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. - HTTP Basic or Digest credentials —
http.user/http.pass(falling back tortsp.user/rtsp.passif unset). The 401 challenge offers Digest first (RFC 7616qop="auth", plus legacy RFC 2069 no-qop support, with a tracked nonce ring — see Streaming Protocols) then Basic.
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 whilertsp.useris empty). This is by design: an unconfigured camera streams on the LAN out of the box. /controland/events— carry an extra loopback-only gate: when no credentials are set, a non-loopback request is refused with403(if (!c->local && !tok_ok && !user[0])inhttpd.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.
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.
{
"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 }
}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). |
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.
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.
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.
Get full status:
curl http://127.0.0.1:8880/controlChange 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.jpgFour 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 |
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.
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)"}]}chnis the source stream (0main,1sub);-1where none applies (events, the plain MJPEG snapshot stream).kbpsis 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.bytesis the total sent to that client since it connected, counted the same way askbps(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).-1foreventsand before the first frame. Where no capture timestamp exists (SW-rotate on T23, the simulator) only the queue part is counted.agentis the request'sUser-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 WHEPPOST.ip/portis the TCP peer (the RTSP control connection forrtsp/udp); forwebrtcandsrtit is the UDP media peer.- The table holds 40 entries; a client beyond that streams normally but is not listed.
Paged out of the ring daynight.history_s sizes (default 0 = off, no ring, no
allocation — see Configuration Reference).
?last=Nbackfills from the newest N samples (page load, or a resync after a lapped cursor);?since=Stails from a cursor.lastwins if both are given.?max=Ncaps rows per response atDN_HISTORY_MAX_ROWS(600).lastis not capped by it — it only picks the start, so a client with more to catch up on just followsnextagain until it equalshead.
{"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.
Takes a nested JSON body; every recognized setting is:
- flattened to its config-file key (
image.brightness,osd0.0.text,video0.bitrate, ...), - applied to the in-memory config (
config_apply_kv), - 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_modealways 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), - applied live via
hub_control()→ the HAL (when a live-apply path exists for that key), - pushed to any other open
/eventssubscribers as aconfigevent, - and finally, all changed keys from the whole request are written back
to the config file in one batched, atomic
config_write_keys()call.
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.
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. |
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.
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.
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: |
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.