# Configuration Reference timps reads a single flat `key = value` file (default `/etc/timps.conf`, overridable with `-c `) — see `timps.conf.example` / `scripts/camera.conf` for worked examples. There is no INI-style `[section]` syntax; the "section" is simply the dotted prefix of the key (`image.`, `audio.`, `video0.`, ...). This page documents **every** user-facing key recognized by `src/config.c`, grouped by section, with type, default, valid range, and — importantly — whether the key can be changed **live** via `POST /control` (see [HTTP /control API Reference](HTTP-Control-API.md)) or is **persist-only** (the value is stored/echoed but only takes effect the next time `timpsd` restarts). ## How to read the "Live?" column | Value | Meaning | | --- | --- | | **Live** | A `/control` POST applies the value to the running pipeline immediately (subject to per-SoC hardware support — see [Platform & SDK Support](Platform-SDK-Support.md)) and persists it to the config file. | | **Live (next cycle)** | Not applied via a direct HAL call, but the owning thread (recorder, timelapse, day/night) re-reads the live in-memory config on every loop iteration, so the change takes effect within a few hundred ms to a few seconds without a restart. | | **Restart-only** | The key is only read once at startup (or at ISP/encoder init). A `/control` POST for one of these still updates the in-memory config and persists it to the file (when the section accepts POSTs at all — see below), but the *running* daemon keeps behaving as it did at boot; the new value takes effect on the next `timpsd` restart. | | **File-only** | The key has **no `/control` POST path at all** — it can only be changed by editing the config file (or via an offline tool that calls `config_write_keys()`) and restarting. Some of these are still included in `GET /control`'s read-only dump; others are not readable back at all (noted individually). | This classification is derived directly from `src/control.c`'s `control_apply_json()` (which sections/keys it accepts) and `src/config.c`'s descriptor tables (which keys `GET /control`/change-detection can read back) — not guessed from names or comments. Two important caveats: - **`image.*` live-apply is per-SoC**: every `image.*` key is *accepted* live regardless of platform, but the HAL only issues the underlying `IMP_ISP_Tuning_*` call when that platform's SDK actually exposes it (see the per-key notes below and `caps.image` in `GET /control`). On an unsupported platform the value is stored/echoed but has no visible effect. - **`videoN.rtsp_path` is a documented exception**: `GET /control`'s `caps.restart` array lists the whole `video` section as restart-required, but `config.h` explicitly carves it out (and since v1.9.20 the POST reply no longer lists it under `deferred_keys`): RTSP `DESCRIBE` matches the request path against the **live** config on every request, not the boot snapshot, so a `/control` edit to `rtsp_path` actually changes which URL serves that stream immediately, with no restart. Every other `video.N.*` key (codec/dimensions/fps/bitrate/...) genuinely needs a restart because the encoder/FrameSource pipeline is never reconfigured live. --- ## `sensor.*` — camera sensor Restart-only; the sensor is probed and configured once at ISP init (`config_sensor_finalize()`). Not reachable via `/control` at all (no `"sensor"` handling in `control_apply_json` beyond being listed for completeness — actually it **is** accepted for persistence, see below). Note: `config_sensor_finalize()` auto-detects unset fields from `/proc/jz/sensor/sensor0/*` and a config value that disagrees with the loaded kernel sensor driver is **overridden** (with a warning) rather than trusted, because a mismatching name/i2c address would crash the ISP driver. | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `sensor.model` | string | *(unset → auto-detected, fallback `gc2053`)* | — | Restart-only | Sensor driver name; auto-filled from `/proc/jz/sensor/sensor0/name` if present. | | `sensor.i2c_addr` (alias `i2c_address`) | int | *(unset → auto-detected, fallback `0x37`)* | 0–0x7F | Restart-only | Sensor I2C address. | | `sensor.fps` | int | *(unset → driver `max_fps`, capped at 30 or the fastest enabled `video.fps` if higher; else video0.fps, else 25)* | 0–120 | Restart-only | Sensor capture frame rate, applied once at ISP init. The auto value is capped at 30, or at the fastest enabled `video.fps` if that is higher; set it explicitly to go higher. The log line `sensor fps: requested N, driver holds n/d` shows what the driver accepted. Not the same as `video.fps`. | | `sensor.width` | int | *(unset → auto/video0.width, fallback 1920)* | 0–8192 | Restart-only | Sensor native width. | | `sensor.height` | int | *(unset → auto/video0.height, fallback 1080)* | 0–8192 | Restart-only | Sensor native height. | | `sensor.i2c_adapter` (alias `i2c_adapter_id`) | int | -1 (unset → 0) | -1–15 | File-only | **T40/T41 only.** I2C adapter the sensor is wired to. | | `sensor.mclk` | int | -1 (unset → 0) | -1–2 | File-only | **T40/T41 only.** Sensor clock source, MCLK0/1/2. | | `sensor.video_interface` | int | -1 (unset → 0) | -1–2 | File-only | **T40/T41 only.** 0 = MIPI CSI0, 1 = MIPI CSI1 (T40 only, ignored with a WARN on T41), 2 = DVP. | | `sensor.default_boot` | int | -1 (unset → 0) | -1–15 | File-only | **T40/T41 only.** Sensor driver boot setting index. | | `sensor.rst_gpio` | int | -1 (none) | -1–255 | File-only | **T40/T41 only.** Sensor reset GPIO number. | | `sensor.pwdn_gpio` | int | -1 (none) | -1–255 | File-only | **T40/T41 only.** Sensor power-down GPIO number. | `sensor.*` **is** accepted by `POST /control` (`{"sensor":{...}}`) for persistence — it is listed in `caps.restart` alongside `video`, and a changed key comes back in the POST reply's `deferred_keys` — but has no live-apply path; the value is written to the config file and takes effect at the next ISP init. The six board-wiring keys (`i2c_adapter` … `pwdn_gpio`) are the exception: not POST-able, edit `/etc/timps.conf` and restart. Use them when `IMP_ISP_AddSensor` fails because the board wires the sensor off I2C adapter 0 / MCLK0 / CSI0 (e.g. Eufy S350: `i2c_adapter = 1`, `mclk = 1`, `default_boot = 4`, `rst_gpio = 91`). An invalid value is logged and dropped; other SoCs ignore them with a warning. ## `image.*` — ISP tuning **All keys live-apply**, gated per-SoC (see `caps.image` in `GET /control` and the ISP-capability column below; the underlying macros live in `src/isp_caps.h`). A value the platform can't apply is still stored and echoed back — it just has no visible effect until/unless a firmware update adds support. `image.running_mode` is additionally special: re-posting the *same* value still re-drives the ISP (skipping the flash write) because it is treated as a hardware-sync command, not just stored state — see [Day/Night](Day-Night.md) for why, including the `fs_kick_chn0()` latch-kick this triggers. | Key | Type | Default | Range | Live? | ISP capability gate | Description | | --- | --- | --- | --- | --- | --- | --- | | `image.brightness` | int | 128 | 0–255 | Live | all SoCs | ISP brightness. | | `image.contrast` | int | 128 | 0–255 | Live | all SoCs | ISP contrast. | | `image.saturation` | int | 128 | 0–255 | Live | all SoCs | ISP saturation. | | `image.sharpness` | int | 128 | 0–255 | Live | all SoCs | ISP sharpness. | | `image.hue` | int | 128 | 0–255 | Live | `ISP_HAS_HUE`: T23/T31/T40/T41/C100 | Hue (`SetBcshHue`). | | `image.hflip` | bool | 0 | 0/1 | Live | all SoCs (via `SetHVFLIP` on T40/T41) | Horizontal flip. Global (all streams). Use with `vflip` for a 180° flip on SoCs without a rotation path. | | `image.vflip` | bool | 0 | 0/1 | Live | all SoCs | Vertical flip. Global (all streams). | | `image.running_mode` | int | 0 | 0 = day, 1 = night | Live | all SoCs | ISP day/night running mode. Normally driven by the [Day/Night](Day-Night.md) thread's switch script, not set directly by a user. | | `image.anti_flicker` | int | 1 (2 up to 1.9.33) | 0 = off, 1 = 50 Hz, 2 = 60 Hz | Live | all SoCs | Anti-flicker (banding) compensation. Set it to your mains frequency, see below. | | `image.ae_compensation` | int | 128 | 0–255 | Live | `ISP_HAS_AECOMP`: all except T21 and T40; T41 only with OpenIMP (since 1.9.33) | AE (auto-exposure) compensation/EV bias. | | `image.max_again` | int | 160 | 0–255 | Live | `ISP_HAS_GAINS`: all except T40/T41 | Max analog gain ceiling. | | `image.max_dgain` | int | 80 | 0–255 | Live | `ISP_HAS_GAINS` | Max digital gain ceiling. | | `image.sinter_strength` | int | 128 | 0–255 | Live | `ISP_HAS_NR`: all except T40; T41 only with OpenIMP (since 1.9.33) | Spatial noise reduction strength. | | `image.temper_strength` | int | 128 | 0–255 | Live | `ISP_HAS_NR`: all except T40; T41 only with OpenIMP (since v1.10.0) | Temporal noise reduction strength; on T41 visible only at high gain. | | `image.dpc_strength` | int | 128 | 0–255 | Live | `ISP_HAS_DPC`: T23/T31/C100; OpenIMP also T10/T20/T21 and T41 (since v1.11.0) | Defective pixel correction strength. | | `image.defog_strength` | int | 128 | 0–255 | Live | `ISP_HAS_DEFOG`: T23/T31/C100; OpenIMP also T20 and T41 (since v1.11.0) | Defog strength. | | `image.drc_strength` | int | 128 | 0–255 | Live | `ISP_HAS_DRC` (WDR): T21/T23/T31/C100; OpenIMP also T20 and T41 (since v1.11.0) | Dynamic range compression (WDR) strength. | | `image.highlight_depress` | int | 0 | 0–10 | Live | `ISP_HAS_HILIGHT`: all except T40/T41 | Highlight suppression. | | `image.colorfx` | int | 0 | 0–9 | Live | Colour effect (0 none, 1 B/W, 2 sepia on T20, 3 negative, 9 vivid). Only with an OpenIMP build (`USE_OPENIMP`); not in `caps.image` otherwise. | | `image.scene` | int | 0 | 0–14 | Live | Scene preset (SDK `IMPISPSceneMode`). Only with an OpenIMP build (`USE_OPENIMP`). | | `image.backlight_compensation` | int | 0 | 0–10 | Live | `ISP_HAS_BACKLIGHT`: T23/T31/C100 | Backlight compensation. | | `image.core_wb_mode` | int | 0 | 0–9 (0–8 on T10/T20/T30) | Live | `ISP_HAS_WB`: all except T40/T41 | White balance mode: 0 auto, 1 manual, 2 daylight, 3 cloudy, 4 incandescent, 5 fluorescent, 6 twilight, 7 shade, 8 warm fluorescent, 9 custom. | | `image.wb_rgain` | int | 0 | 0–255 (T10/T20/T30), 0–4095 (T21/T23/T31), 0–65535 elsewhere | Live | `ISP_HAS_WB` | Manual WB red gain (used when `core_wb_mode` selects manual). The scale differs per SoC: 128 = 1.0 on T10/T20/T30, 256 = 1.0 on T21/T23/T31. The per-SoC clamp is new in v1.10.0 (up to 1.9.33: 0–65535 everywhere, T10/T20/T30 wrapped values above 255). | | `image.wb_bgain` | int | 0 | same as `wb_rgain` | Live | `ISP_HAS_WB` | Manual WB blue gain. Since v1.9.21, `GET /control` reports the gains in effect as read-only `image.wb_live` `{"rgain","bgain"}`, a start value for manual mode that matches auto. | ### Picking `image.anti_flicker` Set it to the mains frequency where the camera hangs: * `1` (50 Hz, the default since v1.10.0): Europe, most of Asia and Africa, Australia. * `2` (60 Hz): most of the Americas, South Korea, Taiwan, the Philippines, Saudi Arabia, western Japan (eastern Japan is 50 Hz). Lamps flicker at twice the mains frequency (100 or 120 Hz). Anti-flicker quantises the AE's exposure time to whole half-periods (10 ms at 50 Hz, 8.33 ms at 60 Hz), so every exposure collects the same light whatever the phase. The wrong value steps the exposure in the wrong unit and shows as horizontal bands under LED or fluorescent light, rolling at the beat between the light and the frame rate (5 Hz at 15 fps on 50 Hz mains). A T23 sc2336p on 50 Hz mains measured 1.15 % RMS row ripple at `2` and 0.09 % at `1`. Up to 1.9.33 the default was `2` (60 Hz, the vendor SDK convention that prudynt and raptor also follow), and thingino's shipped `/etc/timps.conf` writes the key explicitly. An upgraded camera whose config was ever saved therefore stays on `2`. A fresh flash, a factory reset, a config that was never saved (it still comes from the image) or one without the key gets `1`. In a 60 Hz region, set `2` after such an upgrade or reset: ```sh curl -s -X POST http://127.0.0.1:8880/control -d '{"image":{"anti_flicker":2}}' ``` It applies live and persists to `/etc/timps.conf`. Anti-flicker puts a **floor** under the exposure. Use `0` (off) for a very bright close-up scene whose exposure visibly pulses: there the AE needs an exposure below the first node, and it oscillates between honouring the node (overexposed) and breaking it. At `sensor.fps = 30` the GC2053 T31 driver publishes a 25 fps line model at probe time, so the ISP scales the nodes by 1.2× and neither `1` nor `2` lands on a true mains period; at `sensor.fps = 25` the model is consistent and both settings are correct. ### FrontCrop: `image.fcrop_*` (since v1.10.0) | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `fcrop_enable` | bool | 0 | 0/1 | **Live** where `caps.fcrop.live`; restart on T23 (OpenIMP) without the driver's `fcrop_upscale_pct` parameter | ISP FrontCrop ("zoom"): only the window below reaches the streams; each stream scales it to its own size. | | `fcrop_x` | int | 0 | 0–8192 | as above | Left edge, sensor-sized picture pixels (unrotated, as shown with `image.hflip`/`vflip`; OpenIMP T21/T23/T31 mirror x and y for the flips, T10/T20 only y). | | `fcrop_y` | int | 0 | 0–8192 | as above | Top edge. | | `fcrop_w` | int | 0 | 0–8192 | as above | Width; at least 64. | | `fcrop_h` | int | 0 | 0–8192 | as above | Height; at least 64. | | `fcrop_upscale_pct` | int | -1 | -1–`caps.fcrop.upscale_max` (100) | **Live** | **Since v1.11.0**, T23/T31 OpenIMP with the driver parameter only. Writes the open-tx-isp `fcrop_upscale_pct` so the scaler may upscale the window by up to this many percent per axis (100 = a window down to half the stream size). `-1` = leave the driver's value. Never above 100: T31 at 150 hard-hangs. Elsewhere a POST answers `unsupported` and a file value is ignored with one WARN. | x/y/w/h follow the FrameSource crop convention (left, top); the SDK struct is ordered top, left and the HAL maps it. timps evens the values, moves the window inside the sensor and refuses anything under 64×64; the SDK then refuses a window no stream can show (`-EINVAL`: `caps.fcrop.state` = `rejected`, one WARN; any other error: `failed`). Keep the stream aspect ratio or the picture is stretched. Device-tested (by the OpenIMP maintainer) on T10, T20 and T21 with OpenIMP — on T20 only the downscaled streams show the window. T23 and T31 with OpenIMP are effective and live when the driver has the open-tx-isp `fcrop_upscale_pct` parameter (checked at start; open-tx-isp b15ef235 has it). Without it T31 is experimental and T23 experimental and start-only: a POST persists and applies at the next `/etc/init.d/S95timps restart`; it needs open-tx-isp 3cf9bdac or newer (older drivers hung the pipeline on a crop change). A window smaller than a stream that is not scaled down is refused unless `fcrop_upscale_pct` allows it; lowering that key with such a window active returns to the full sensor. Vendor T23 refuses the keys. See [Platform & SDK Support](Platform-SDK-Support.md#roi-and-frontcrop-tiers) for all SoCs. Privacy masks, OSD and ROI regions stay in stream-frame coordinates: the scene moves under them. ## `audio.*` — capture, encode, speaker defaults Split between live-applicable knobs (real-time DSP/gain calls) and persist-only attribute keys (codec/format/pipeline-init settings that `IMP_AI_SetPubAttr`/encoder init need at bring-up time and cannot change on a running capture channel). The Restart-only keys are listed in `GET /control`'s `caps.restart` as `audio.`, and since v1.9.20 a changed one comes back in the POST reply's `deferred_keys`. | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `audio.volume` | int | 80 | 0–100 | **Live** | Mic capture volume (`IMP_AI_SetVol`). | | `audio.gain` | int | 25 | 0–31 | **Live** | Mic capture gain (`IMP_AI_SetGain`). | | `audio.alc_gain` | int | 0 | 0–7 | **Live** on T21/T31/C100 (`AUDIO_HAS_ALC_GAIN`); accepted/persisted elsewhere with no effect | Analog PGA gain level (`IMP_AI_SetAlcGain`). | | `audio.mute` | bool | 0 | 0/1 | **Live** | Live mic mute: captured frames are dropped before the encoder/hub (works on every SoC — it's a publish-gate in timps, not an SDK call). | | `audio.spk_volume` | int | 80 | 0–100 | **Live** (only if `USE_PLAY` or `USE_BACKCHANNEL` compiled in) | Speaker (`IMP_AO`) output volume — applies to whichever producer (backchannel/play queue) currently holds the AO device, and persists as the default for the next one. | | `audio.spk_gain` | int | 25 | 0–100 (effective 0–31: `IMP_AO_SetGain`'s range, −39 dB … +6 dB in 1.5 dB steps; higher values are clamped to 31) | **Live** (same gate as `spk_volume`) | Speaker output gain. | | `audio.enabled` | bool | 1 | 0/1 | Restart-only | Master audio capture enable. | | `audio.codec` | enum | `aac` | `aac`\|`pcmu`\|`pcma`\|`opus`\|`none` (aliases `g711u`/`ulaw`→pcmu, `g711a`/`alaw`→pcma, `off`→none) | Restart-only | Audio codec. AAC needs `USE_FAAC`; `opus` needs `USE_STREAM_OPUS` (RTSP-only, RFC 7587 — see [Audio](Audio.md)/[Streaming Protocols](Streaming-Protocols.md)) and is only an accepted token on such a build. Falls back to AAC at parse time for any unrecognized token. | | `audio.codec2` | enum | `pcmu` on `USE_WEBRTC` builds, `none` otherwise | `pcmu`\|`off`/`none` | Restart-only | Optional **second**, independent G.711u encode of the same captured PCM, published on its own hub source. It exists for WebRTC/WHEP, which carries G.711 and nothing else: without it a camera on the default `audio.codec = aac` gets a video-only WHEP session. RTSP, the fMP4 preview and recordings keep the AAC primary untouched. Ignored (and not encoded) when `audio.codec` is already `pcmu`/`pcma`. Needs an 8 kHz or 16 kHz capture (16 kHz is filtered and halved to G.711's 8 kHz clock); any other `audio.samplerate` logs a warning and disables the second encode. | | `audio.samplerate` | int | 16000 | 8000–96000 | Restart-only | Capture sample rate in Hz. G.711 is pinned to 8000 Hz regardless of this value (see [Audio](Audio.md)). | | `audio.channels` | int | 1 | 1–2 | Restart-only | 1 = mono (native). 2 = "simulated stereo" — the mono mic duplicated to L=R, AAC only. | | `audio.bitrate` | int | 32 | 8–320 (kbps) | Restart-only | AAC encode bitrate. | | `audio.high_pass` | bool | 1 | 0/1 | Restart-only | High-pass filter. **Not live**: libimp runs HPF/AGC/NS on its own record thread and frees state unlocked — a live toggle would race the vendor thread (use-after-free risk), so this is restart-required by design, not merely unimplemented. | | `audio.agc` | bool | 0 | 0/1 | Restart-only | Automatic gain control on/off (same race-avoidance rationale as `high_pass`). | | `audio.agc_target_dbfs` | int | 10 | 0–31 | Restart-only | AGC target level (dBFS). | | `audio.agc_compression_db` | int | 0 | 0–90 | Restart-only | AGC compression gain (dB). | | `audio.ns` | int | 0 | 0–3 | Restart-only | Noise suppression level (0 = off). | | `audio.force_stereo` | bool | 0 | 0/1 | Restart-only | Separate companion field to `channels=2` (not an alias — both are settable independently). | | `audio.spk_enabled` | bool | 1 | 0/1 | **Live** (at the next AO open; `USE_PLAY`/`USE_BACKCHANNEL` builds) | Master speaker-output enable. Read by `speaker.c` every time it is about to open the `IMP_AO` device, so `0` silences the *next* backchannel/play session without a restart; a session already holding the speaker keeps it until it ends. | | `audio.backchannel` | bool | 0 | 0/1 | Restart-only | Enable the ONVIF two-way audio backchannel (`USE_BACKCHANNEL` build). | | `audio.backchannel_codec` | enum | `pcmu` (0) | `pcmu`(0)\|`pcma`(1)\|`aac`(2) | Restart-only | Advertised/accepted backchannel codec. | | `audio.backchannel_rate` | int | 16000 | 8000–48000 | Restart-only | Speaker sample rate fed by the backchannel decoder. | | `audio.talk_ws` | tri-state | 0 | 0\|1\|2 (`true`/`on`/`yes` → 1) | **Live** (next `/talk` request; the backchannel it rides on is still restart-only) | Serve the browser push-to-talk WebSocket at `/talk` (`USE_BC_WS` builds, and only when the backchannel itself came up at boot). `httpd.c` reads the value on every `/talk` upgrade request, so a `/control` POST changes the answer for the next connection immediately — including switching from `1` to `2`, which drops the TLS requirement; treat it with the same care as a hand edit. `0` = not served; `1` = served over TLS only, a plaintext port answers `426`; `2` = also served over plain `ws://`, a deliberate hand edit since the microphone audio and its token then cross the network in the clear. `GET /control` reports the resolved verdict as `caps.backchannel.talk_ws` (`1` on a plaintext port resolves to `0`). See [Audio](Audio.md#browser-push-to-talk-talk-use_bc_ws). | | `audio.aec` | bool | 0 | 0/1 | **Not live** — persists now, engages at the next AO open (only if `USE_PLAY` or `USE_BACKCHANNEL` compiled in) | Opt-in Acoustic Echo Cancellation (`IMP_AI_EnableAec`) for the backchannel — subtracts the speaker output from the mic capture. Engages only once both AI capture and AO output are actually live, applied at the next AO open (same timing contract as `spk_volume`/`spk_gain`). Off by default since AEC quality/latency varies per SoC/mic/speaker pairing. | See [Audio](Audio.md) for the backchannel/play-queue feature details. ## `jpeg.*` — dedicated JPEG/MJPEG channel Entirely **file-only**: there is no `"jpeg"` object in the `/control` POST schema, so these keys can only be changed by editing the config file and restarting. (Note this is distinct from the per-stream *piggyback* JPEG encoder configured under `video.N.jpeg*`, below — see [Streaming Protocols](Streaming-Protocols.md) for how the two JPEG sources relate.) | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `jpeg.enabled` | bool | 0 | 0/1 | File-only | Enable the dedicated JPEG encoder channel (independent FrameSource). | | `jpeg.width` | int | 640 | 64–4096 | File-only | JPEG channel width. | | `jpeg.height` | int | 360 | 64–4096 | File-only | JPEG channel height. | | `jpeg.quality` | int | 75 | 1–100 | File-only | JPEG quality. | | `jpeg.fps` | int | 5 | 1–120 | File-only | Max snapshot/MJPEG publish rate for this channel. | | `jpeg.imp_chn` | int | 2 | 0–7 | File-only | IMP encoder channel number (internal). (since v1.9.28) a value that collides with an enabled video stream's channel is moved to the lowest free channel at load, with an `[ERR]` line. | | `jpeg.snapshot_path` | string | `""` | — | File-only | Optional path to periodically write the latest JPEG to disk (`""` = disabled). | ## `rtsp.*` — RTSP server File-only (no `"rtsp"` object accepted by `/control`). See [Streaming Protocols](Streaming-Protocols.md) for the RTSP server's runtime behavior. | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `rtsp.enabled` | bool | 1 | 0/1 | File-only | Enable the RTSP server. | | `rtsp.port` | int | 554 | 1–65535 | File-only | RTSP listener port. | | `rtsp.mtu` | int | 1200 | 548–1472 | File-only | Max RTP packet size (header+payload) for UDP transport; 1200 leaves headroom for WireGuard/OpenVPN/PPPoE/IPv6 tunnel overhead — raise to ~1400 on LAN-only setups. | | `rtsp.user` (alias `username`) | string | `""` | — | File-only | RTSP username. Empty = RTSP auth disabled. | | `rtsp.pass` (alias `password`) | string | `""` | — | File-only | RTSP password. | | `rtsp.tls` (alias `tls_enabled`) | bool | 0 | 0/1 | File-only | Also run an RTSPS (TLS) listener (`USE_TLS` builds). | | `rtsp.tls_port` | int | 322 | 1–65535 | File-only | RTSPS port (RFC 7826 Annex C well-known port). | ## `srt.*` — SRT output File-only. `USE_SRT` builds only (see [Streaming Protocols](Streaming-Protocols.md)). | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `srt.enabled` | bool | 0 | 0/1 | File-only | Enable the SRT output. | | `srt.mode` | string | `listener` | `listener`\|`caller` | File-only | `listener` waits for receivers to dial in; `caller` makes the camera dial `srt.host`:`srt.port` itself and reconnect forever with a doubling 1→30 s backoff (for cameras behind NAT). An unrecognized value warns and falls back to `listener`. | | `srt.host` | string | `""` | — | File-only | Caller mode only: the receiver to dial. `srt.mode=caller` with this empty is a startup error and SRT stays off (it is not silently downgraded to a listener). Ignored in listener mode. | | `srt.port` | int | 9000 | 1–65535 | File-only | Listener: local bind port. Caller: the remote port dialled on `srt.host`. | | `srt.channel` | int | 0 | — | File-only | Video stream served over SRT. | | `srt.latency_ms` (alias `latency`) | int | 120 | — | File-only | SRT latency (ms). | | `srt.streamid` | string | `""` | — | File-only | Listener: required `STREAMID` for a connecting client (`""` = not enforced). Caller: the `STREAMID` timps presents to the receiver. | | `srt.passphrase` | string | `""` | — | File-only | AES passphrase for SRT encryption (10–79 chars if set; an invalid passphrase stops SRT — the listener never binds, the caller stops dialling — rather than running unencrypted). `srt.streamid` is plaintext, so without a passphrase a listener serves video to anyone regardless of `rtsp.user`/`http.user`; (since v1.9.28) that combination logs `SRT port N has NO access control - set srt.passphrase (10-79 chars)` at start. | ## `http.*` — HTTP server (fMP4/MJPEG/snapshot/control/events) File-only. See [HTTP /control API Reference](HTTP-Control-API.md) and [Streaming Protocols](Streaming-Protocols.md). | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `http.enabled` | bool | 1 | 0/1 | File-only | Enable the HTTP server. | | `http.port` | int | 8880 | 1–65535 | File-only | HTTP listener port. | | `http.preview_chn` | int | 1 | — | File-only | Default video stream for the built-in preview page. | | `http.adaptive_drop` | bool | 1 | 0/1 | File-only | Per-client adaptive frame dropping on `/stream.mp4` when a slow client's own queue backs up (freeze-and-resume-at-keyframe instead of forwarding a corrupt headless GOP). | | `http.user` (alias `username`) | string | `""` | — | File-only | HTTP Basic/Digest username. Falls back to `rtsp.user` if empty. | | `http.pass` (alias `password`) | string | `""` | — | File-only | HTTP password. Falls back to `rtsp.pass` if empty. | | `http.token` | string | `""` | — | File-only | Optional persistent remote secret token for `/control`/`/events`/media endpoints (never written to the token file). | | `http.token_file` | string | `/run/timps.token` | — | File-only | Where the random per-boot token is published (mode 0640); `""` disables publishing. | | `http.https` (alias `tls`) | tri-state | 0 | 0/1/2 | File-only | TLS on the HTTP port (`USE_TLS` builds). **0** = plaintext only. **1** = *both* schemes on the same port — each connection is classified by its first byte (`0x16` = TLS handshake record, any HTTP method starts with a letter), so `http://` and `https://` both work; this is wider than the pre-v1.9.11 meaning of `1`, which was TLS-only. **2** = TLS only: a plaintext request gets `426 Upgrade Required` and is closed (the old meaning of `1`). Either on-value still fails **closed** — a cert/key that cannot be loaded means the listener is not bound at all, never a silent downgrade to plaintext. | | `http.tls_cert` (alias `cert`) | string | `/etc/ssl/certs/timps.crt` | — | File-only | Certificate, PEM or DER (shared with RTSPS). On thingino, `S95timps` symlinks this to the WebUI's `/etc/ssl/certs/uhttpd.crt` when that exists, so both ports present the same cert; otherwise it generates a self-signed pair here. | | `http.tls_key` (alias `key`) | string | `/etc/ssl/private/timps.key` | — | File-only | Private key matching `http.tls_cert` (shared with RTSPS). | > **Security — empty credentials expose the media (by design).** While both > `http.user` and `rtsp.user` are empty (the shipped default), the media > endpoints — the RTSP video/audio stream, `/snapshot.jpg`, `/stream.mjpeg` > and `/stream.mp4` — are reachable by **anyone on the network with no > authentication**. `/control` and `/events` are the exception: they stay > **loopback-only** (non-local requests get `403`) even with empty > credentials, so config/state is never exposed off-device without a token or > configured credentials. Set `rtsp.user`/`rtsp.pass` and/or > `http.user`/`http.pass` to require auth for the media too. See > [HTTP /control API Reference § Empty credentials](HTTP-Control-API.md#empty-credentials-the-shipped-default--media-is-open-control-is-not). ## `webrtc.*` — WebRTC/WHEP endpoint File-only, and the whole section only exists on `USE_WEBRTC` builds (which imply `USE_TLS` + `USE_CONTROL`). `GET /control` reports the resolved state as `caps.webrtc`. The top-level `README.md` § "WebRTC / WHEP" carries the media contract and its deliberate limitations (LAN/VPN only, H.264 + G.711 only, no NACK/FEC/congestion control, 4 concurrent sessions). | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `webrtc.enabled` | tri-state | 2 | 0/1/2 (`true`/`on`/`yes` → 1) | File-only | Serve `POST /webrtc/whep`. **0** = the endpoint answers `503`. **1** = served, but a *plaintext* POST on a port that has TLS configured is refused with `426 Upgrade Required` (same rule as `audio.talk_ws=1`) — the offer carries the fingerprint the peer is held to and the answer carries our ICE password, so a rewritable exchange hands over the whole session. **2** = served over either scheme. The default is `2` rather than `1` because most cameras have no `http`→`https` redirect, so `1` would silently `426` the feature; a port with no TLS configured at all behaves the same either way. | | `webrtc.port` | int | 0 | 0–65535 | File-only | Bottom of the UDP media port range; `0` = one OS-picked ephemeral port per session. Every session binds its **own** socket, so a fixed value is a range start, not a single port. | | `webrtc.port_max` | int | 0 | 0–65535 | File-only | Top of that range. `0` = `webrtc.port + WEBRTC_MAX_SESSIONS - 1` (one port per session slot, so no extra configuration is needed). A range narrower than the 4 slots just answers `503` once every port in it is taken. | | `webrtc.channel` | int | 0 | 0–(`MS_MAX_VSTREAM`-1) | File-only | Which video stream to send when the WHEP `POST` carries no `?chn=N` (a client picks the stream per session with it). It must be H.264 — there is no H.265 or transcoding over WebRTC. Its `video.profile` decides which browsers can play the result: Chromium accepts any profile, Firefox only Baseline (`0`). | Audio over WHEP needs G.711 on the wire: either `audio.codec` is already `pcmu`/`pcma`, or `audio.codec2 = pcmu` (the `USE_WEBRTC` default) arms the second encode. With neither, the answer rejects the audio m-section (`m=audio 0 …`) and the session is video-only. ## `events.*` — Server-Sent-Events push stream File-only startup settings (`USE_CONTROL` builds). | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `events.enabled` | bool | 1 | 0/1 | File-only | `0` makes `/events` answer 404. | | `events.stats_ms` | int | 2000 | — | File-only | Period of the `stats` SSE event; `0` disables it. | | `events.max_clients` | int | 8 | — | File-only | Max concurrent `/events` connections; beyond this, `/events` answers 503. | ## `osd.*` — on-screen display (global settings) The master switch and the shared OSD infrastructure settings. Per-item overlay fields are documented separately below (`osd..*`). | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `osd.enabled` | bool | 1 | 0/1 | Restart-only | Master OSD on/off switch, global across all streams. Settable via `/control` (`{"osd":{"enabled":...}}`) and persists, but the OSD groups are only ever built once at startup (`imp_osd_setup`), so the effect needs a restart. | | `osd.monitor_stream` | int | 0 | — | **Live** | Which stream's measured fps/bitrate feeds the `{fps}` and `{bitrate}` placeholders — for every OSD layer, on every stream. Use the numbered `{fpsN}`/`{bitrateN}` forms instead when a layer should show its own stream's figures. Settable via `/control` (`{"osd":{"monitor_stream":...}}`); read directly off `g_cfg` on every OSD text refresh, so a POST applies on the next render, no restart needed. | | `osd.font_path` | string | `/usr/share/fonts/default.ttf` | — | Restart-only | Default TTF font for text items without a per-item `font_path` override. Settable via `/control`, same restart-required class as `osd.enabled`. | | `osd.supersample` | int | 2 | 1–4 | Restart-only | TTF rasterizer anti-aliasing quality (samples per axis per pixel); cost scales ~quadratically, 2 is visually close to 4 at typical OSD sizes for roughly a quarter of the CPU cost. Settable via `/control`, same restart-required class as `osd.enabled`. | | `osd.hinting` | bool | 1 | 0/1 | Restart-only (only if `USE_OSD_HINTING` compiled in) | Opt-in lightweight geometric autohint for the TTF rasterizer. The rasterizer (`msttf.c`) does not execute the font's embedded TrueType hint bytecode (a real hint interpreter is real interpreter-writing work with a real correctness/security surface for an on-device, unsandboxed daemon); at small sizes (e.g. the substream OSD's default 12px) that shows up as visibly uneven stroke widths between glyphs. Enabling this snaps long, near-vertical/near-horizontal outline edges (typical letter stems/serifs) to the pixel grid before rasterizing, which measurably reduces that unevenness — it is a coarse heuristic, not real hinting, and does not preserve the font's authored hint intent. On by default in the runtime config; set it to `0` for byte-for-byte unhinted OSD bitmaps. Verified against the shipped UbuntuMono Regular (`/usr/share/fonts/default.ttf`); untested against other TTF files if a user swaps `osd.font_path`. Also gated at COMPILE time by `USE_OSD_HINTING` (`BR2_PACKAGE_TIMPS_OSD_HINTING` in the buildroot package, off by default — measured ~2.1KB smaller `.text` on T31/GCC 16.1.0/-Os when left off): on a build without it, setting this key is accepted but has no effect. Settable via `/control`, same restart-required class as `osd.enabled`. | ### Custom placeholders (show any value you want in the OSD) Any `{name}` token in an OSD `text` template that isn't one of the built-in placeholders (`hostname`, `ip`, `mac`, `fps`, `fpsN`, `bitrate`, `bitrateN`, `uptime`, `net`/`tx`, `cpu`, `mem`, `clients`) is looked up in a fixed file instead - a plain `key = value` text file, one entry per line. This lets an external script drive OSD content with no timps code changes at all: temperature readings, a doorbell state, whatever. Since v1.9.28 the path is **`/tmp/timps_osd.vars`, fixed in `src/hal/osd_vars.c` (`OSD_VARS_FILE`) and not configurable** - not even file-only via `timps.conf`. It used to be (`osd.vars_file`), but any settable path could point it at `/etc/timps.conf` itself and render its credentials into the video through a `{placeholder}`. 1. Pick a name and use it in the OSD text, e.g. `osd0.4.text = Temp: {room_temp}C`. 2. From any script/cron job with filesystem access, write matching lines to `/tmp/timps_osd.vars`: ``` room_temp = 21.5 doorbell = idle ``` 3. timps re-reads the file on every OSD refresh tick (~1x/s) - no signal or restart needed. There's no locking, so writing in-place (e.g. `echo ... > file`, which truncates then writes) has a small window where a concurrent read can land mid-write: either an empty file (blank placeholder for that tick) or a partially-written value line (a truncated-but-not-garbage value for that tick, e.g. `21` instead of `21.5`) - never a crash or memory issue, since reads are line-bounded, just a possible one-tick display glitch that self-corrects on the next read. Avoid it entirely with an atomic replace: write the new content to a temp file in the same directory, then `rename()`/`mv` it onto the target path - `rename()` is atomic on Linux, so timps always sees either the complete old file or the complete new one, never a partial write. Limits: each resolved value is capped at 127 bytes, and the whole expanded `text` string (all placeholders combined, before `strftime()` substitution) at 511 bytes - long enough for typical single-line status text, not a replacement for a full custom rendering pipeline. Unmatched names (typo, or `/tmp/timps_osd.vars` doesn't exist yet) resolve to an empty string rather than an error, so a missing file just shows blank text for that placeholder. ## `osd..*` — per-stream OSD overlay items Canonical form: `osd..` where `S` is the video stream index (0/1) and `N` is the item slot (0–7, `MS_MAX_OSD`). A legacy shared form `osd.` (no stream index) is still parsed for backward compatibility and mirrors the value onto **every** stream's item `N`. Default layout: item 0 = timestamp (top-left), item 1 = `{hostname}` (top-center), item 2 = `{uptime}` (top-right), item 3 = thingino logo (bottom-right); items 4–7 are unused placeholders (disabled). | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `enabled` | bool | item 0–3: `1`, items 4–7: `0` | 0/1 | **Restart-only** | Enabling an item that started disabled has no IMP region to attach to — only takes effect on restart. Disabling a running item also only persists (no live hide). | | `type` | enum | `text` (item 3: `logo`) | `text`\|`logo` | **Restart-only** | Overlay type. Settable via `/control` and persists, but `imp_osd_apply()`'s live re-render dispatch is fixed at region-creation time (`rg->is_text`), so switching an existing item between text and logo needs a restart to actually change what's drawn (same restart-required reasoning as `enabled`). | | `text` | string(128) | per-item (see layout above) | — | **Live** (if the item had a region at startup) | Text template: literal text, `{placeholder}` tokens (`{hostname} {ip} {mac} {fps} {fpsN} {bitrate} {bitrateN} {uptime} {net} {cpu} {mem} {clients}`), and `strftime()` codes. Posting `""` clears it (a deliberate exception to the usual "empty value refused" rule — see [HTTP /control API Reference](HTTP-Control-API.md#response-body-and-status-codes)); `null`/missing stays refused, since some clients post `null` for fields they merely didn't intend to touch. | | `logo` (alias `logo_path`) | string(128) | `/usr/share/images/thingino_100x30.bgra` (item 3) | — | File-only | Raw BGRA logo file path. | | `logo_w` (alias `logo_width`) | int | 100 (item 3) | 0–4096 | File-only | Logo width in px. | | `logo_h` (alias `logo_height`) | int | 30 (item 3) | 0–4096 | File-only | Logo height in px. | | `x` | int | per-item | -8192–8192 (since v1.9.28) | **Live** | X position: `0` = centered, `>0` = pixels from the left edge, `<0` = pixels from the right edge. | | `y` | int | per-item | -8192–8192 (since v1.9.28) | **Live** | Y position: same convention, top/bottom. | | `font_size` | int | 32 (stream 0) / 12 (other streams) | 8–128 | **Live** | Absolute pixel font size (no per-stream auto-scaling). | | `color` (alias `font_color`) | hex (0xAARRGGBB) | `0xFFFFFFFF` | — | **Live** | Text fill color. | | `transparency` | int | 255 | 0–255 | **Live** | Group alpha. | | `outline` (alias `stroke`) | int | 1 | 0–64 | **Live** | Text outline/stroke width in px (`0` = off). On by default so overlays stay legible on light backgrounds. | | `outline_color` (alias `stroke_color`) | hex (0xAARRGGBB) | `0xFF000000` | — | **Live** | Outline/stroke color. | | `font_path` | string(128) | `""` (inherits `osd.font_path`) | — | File-only | Optional per-item TTF override. | ## `privacy..*` — privacy cover masks `S` = video stream index (0/1), `N` = region slot (0–3, `MS_MAX_PRIVACY`). Implemented as IMP OSD cover regions; live-appliable as long as an OSD group exists on that stream (i.e. OSD or a privacy region was enabled at boot — `GET /control`'s `caps.privacy.available` reflects this). | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `enabled` | bool | 0 | 0/1 | **Live**\* | Show/hide this cover region. | | `x` | int | 0 | -8192–8192 (since v1.9.28) | **Live**\* | Rectangle X origin, pixels, in the stream's frame. | | `y` | int | 0 | -8192–8192 (since v1.9.28) | **Live**\* | Rectangle Y origin. | | `w` (alias `width`) | int | 0 | 0–8192 (since v1.9.28) | **Live**\* | Rectangle width. | | `h` (alias `height`) | int | 0 | 0–8192 (since v1.9.28) | **Live**\* | Rectangle height. | | `color` (alias `fill_color`) | hex (0xAARRGGBB) | `0xFF000000` (opaque black) | — | **Live**\* | Fill color. | \* Live only if an OSD group already exists for that stream at boot; otherwise the value persists but has no visible effect until restart. ## `roi..*` — encoder ROI (since v1.10.0) `S` = video stream (0/1), `N` = region slot (0–3, `MS_MAX_ROI`; the SDK has 8, timps uses 4). Per-region QP for H.264 via `IMP_Encoder_SetChnROI`. POST as `{"roi":{"0":{"1":{"enabled":1,"x":640,"y":320,"w":320,"h":240,"qp":-6}}}}`. | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `enabled` | bool | 0 | 0/1 | **Live** | Region on/off. | | `x`, `y` | int | 0 | 0–8192 | **Live** | Top-left corner, pixels of the encoded stream. | | `w`, `h` | int | 0 | 0–8192 | **Live** | Size; rounded outwards to 16×16 macroblocks. | | `qp` | int | -5 | -51–51 | **Live** | Delta -26..25 or absolute 0..51, clamped per `qp_mode`. | | `qp_mode` | int | 1 | 0/1 | **Live** | `1` delta to the frame QP, `0` absolute QP. | The encode thread applies a change before its next frame; an idle stream gets its regions when it starts. The SDK rectangle holds two inclusive corners, not a width/height, and libimp clips nothing — timps converts and clamps. Only unrotated H.264 streams take regions (`caps.roi.streams`); on an H.265 or rotated stream a file value logs one WARN at start, while a POST is accepted and persisted without any effect or log line. Device-tested (by the OpenIMP maintainer, not timps) on T10, T20 and T21 with OpenIMP (T10: QP51 region -25 % bitrate; T21: on by default, `OPENIMP_T21_ROI=0` in the environment turns it off and timps reports experimental). Vendor T10/T20/T21 are experimental (T21: no effect seen). T23 refuses the keys (the encoder only stores the region); T31 ROI exists in OpenIMP as `SetChnRoiAttr` but is not wired yet. Since v1.11.0 T40/T41 with OpenIMP take regions as QP deltas when the libimp exports `OpenIMP_Cap_EncRoi` (experimental; see [Platform & SDK Support](Platform-SDK-Support.md#roi-and-frontcrop-tiers)). Everything else refuses the keys. `caps.roi.tier` reports it. ## `motion.*` — motion detection See [Motion Detection](Motion-Detection.md) for the full grid model. `enabled`/`sensitivity`/`cols`/`rows`/`monitor_stream` are POST-able and **Live**; `hold_ms`/`skip_frames` are POST-able and **Live** too — they feed the IVS grid/hold logic only at create/resync time, so `ing_control()` routes them through the same deferred grid re-sync as the geometry keys, which runs once at the end of the POST. `cooldown_ms`/`on_motion` stay config-file-only by design (see below). | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `motion.enabled` | bool | 0 | 0/1 | **Live** | Enable/disable IVS motion detection (stops/recreates the whole IVS grid). | | `motion.monitor_stream` | int (channel) | 1 | valid stream index | **Live** | Which video stream's frame the grid is computed over. Default is the sub stream (far less CPU than 1080p); stream 0 is used when `video1` is not enabled. | | `motion.sensitivity` | int | 128 | 0–255 | **Live** | UI-facing sensitivity; mapped to the SDK's native 0–4 range (`v*4/255`) applied uniformly to every cell. A change that maps to the same SDK level as before is treated as unchanged (no IVS rebuild). | | `motion.cols` | int | 5 (or 2/1 on SDKs with a smaller ROI budget) | ≥1, `cols*rows` clamped to `MOTION_CELL_LIMIT` | **Live** | Grid columns. Setting one axis clamps against the *current* value of the other, never the reverse, so re-applying the same pair is idempotent. | | `motion.rows` | int | 5 (or 2/1) | ≥1, same clamp | **Live** | Grid rows. | | `motion.cooldown_ms` | int | 5000 | 250–`INT_MAX` (floor enforced) | File-only | Minimum gap between `on_motion` hook executions. | | `motion.hold_ms` | int | 800 | 0–60000 | **Live** | How long a cell reports "active" after its last hit, so an async `/events`/`/control` reader reliably observes single-frame motion instead of racing IVS's own immediate clear. `0` = no hold. Settable via `/control`; applied through the grid re-sync batched at the end of the request. | | `motion.skip_frames` | int | 5 | 1–100 | **Live** | `IMP_IVS_MoveParam.skipFrameCnt` — analyze every Nth frame. Higher = cheaper/higher latency. Settable via `/control`; applied through the grid re-sync batched at the end of the request. | | `motion.on_motion` | string(128) | `""` | — | File-only, **not GET-readable either** | Program launched by `posix_spawn()` on motion (no shell, no arguments, no `PATH` search — use an absolute path). `""` = disabled. Receives `MOTION_COLS`/`MOTION_ROWS`/`MOTION_CELLS`/`MOTION_TIME` via the environment — see [Motion Detection](Motion-Detection.md). | | `motion.roi_x`/`roi_y`/`roi_w`/`roi_h` | int | 0 | — | File-only, **deprecated** | Legacy single-ROI keys, still parsed for old configs but **ignored** — the grid replaced them. Setting a non-zero value logs a one-time warning. | ## `record.*` — local SD recording See [Recording & Timelapse](Recording-Timelapse.md). All settable keys are re-read live by the recorder thread on every loop pass, so changes apply within its ~300 ms poll cycle without a restart. | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `record.enabled` | bool | 0 | 0/1 | **Live (next cycle)** | Master enable; also gates on-boot start. | | `record.channel` | int (channel) | 0 | valid stream index | **Live (next cycle)** | Video stream to record. | | `record.mode` | enum | `1` (motion) | `continuous`(0)\|`motion`(1)\|raw number | **Live (next cycle)** | Continuous vs. motion-triggered recording. | | `record.dir` | string(128) | `/mnt/mmcblk0p1` | — | **Live (next cycle)** | SD base directory. Since v1.9.28 refused if it (or its nearest existing parent) is on the root filesystem — typically the mount point with no card — instead of recording into flash; `/tmp` is allowed. | | `record.name` | string(96) | `%Y%m%d/%H/%Y%m%dT%H%M%S` | `strftime` template | **Live (next cycle)** | Path template under `//records/`. | | `record.segment_s` (alias `segment`) | int | 60 | 0–86400 | **Live (next cycle)** | Max segment length in seconds; rotation only happens at a video keyframe. `0` = single file, no rotation. | | `record.pre_roll_s` (alias `pre_roll`) | int | 3 | 0–60 | **Live (next cycle)** | Motion mode: seconds of buffered video kept before the trigger (ring buffer). | | `record.post_roll_s` (alias `post_roll`) | int | 10 | 1–300 | **Live (next cycle)** | Motion mode: keep recording this long after the last motion event. | | `record.min_free_mb` | int | 200 | 0–1048576 | **Live (next cycle)** | Prune oldest segments until at least this much free space remains. Set it larger than the media can ever provide and the recorder **refuses to record** (saying so in the record status object's `last_error`) instead of emptying the archive chasing an unreachable target — see [Recording & Timelapse](Recording-Timelapse.md#free-space-pruning). | | `record.audio` | bool | 1 | 0/1 | **Live (next cycle)** | Mux AAC audio into the recording when available (G.711 cannot be muxed into fMP4). | `{"record":{"active":1|0}}` (not a config key) is a transient manual start/stop override, and `{"record":{"clip":"","seconds":N}}` triggers an independent on-demand fMP4 clip capture — neither is persisted to the config file. ## `timelapse.*` — periodic JPEG capture See [Recording & Timelapse](Recording-Timelapse.md). Like `record.*`, the running thread re-reads these live. | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `timelapse.enabled` | bool | 0 | 0/1 | **Live (next cycle)** | Master enable; also gates on-boot start. | | `timelapse.channel` | int (channel) | 0 | valid stream index | **Live (next cycle)** | Video stream whose JPEG is captured. | | `timelapse.dir` | string(128) | `/mnt/mmcblk0p1` | — | **Live (next cycle)** | Base directory (SD, NFS, any writable path; since v1.9.28: not on the root filesystem, same rule as `record.dir`). | | `timelapse.name` | string(96) | `%Y%m%d/%H/%Y%m%dT%H%M%S` | `strftime` template | **Live (next cycle)** | Path template under `//timelapses/`. | | `timelapse.interval_s` (alias `interval`) | int | 60 | ≥1 | **Live (next cycle)** | Seconds between shots. | | `timelapse.keep_days` | int | 7 | 0–3650 (since v1.9.28) | **Live (next cycle)** | Delete shots older than this; `0` = keep forever. Shots with a pre-2025 (pre-NTP) mtime are never age-pruned (since v1.9.28). | ## `daynight.*` — automatic day/night See [Day/Night](Day-Night.md) for the full decision logic. The short version: the metric is the **exposure index** (`total_gain × integration_time / max_integration_time`, higher = darker — equal to `total_gain` in a dark scene and far below it in a bright one); day→night is a direct measurement on the honest day pipeline, night→day only ever happens via a **check** of the day pipeline, asked for by a **look** (IR illuminator off 8 s, silent); the calendar (`time_*`, `sun_*`) is used by `mode = schedule` only. `switch_cmd`, `isp_path`, `trace_path` and `irprobe_cmd` are **not GET-readable** even though they are settable via the config file — each names a path or program the daemon acts on as root. **Renamed 2026-08-17:** `total_gain_day_threshold` → `day_gain` and `total_gain_night_threshold` → `night_gain`. The old names still work as aliases and the units and calibration are unchanged. **Removed 2026-08-17** (parsed, ignored, warned about once): `day_gain_pct`, `baseline_delay_s`, `boot_settle_max_s`, `boot_stable_pct`, `night_reconfirm_s`, `probe_max_skip_s`, `threshold_low`, `threshold_high`, `hysteresis`. **Removed 2026-08-22** (config consolidation — parsed, ignored, warned about once): the whole `learn`/`state_path` learning subsystem is gone outright. **Retired in v1.10.1** (the simple engine, `dev_notes/dn-redesign.md`; parsed, ignored with one warning each, `… is retired and IGNORED`): `probe_min_gap_s`, `probe_confirm_s`, `boot_probe`, `diagnose_thresholds`, and the eight constants of the 2026-08-22 consolidation listed at the end of this section, which are no longer echoed in `GET /control`. | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `daynight.enabled` | bool | 1 | 0/1 | **Live** | Auto-detection on/off; `0` = manual (thread still samples but forces nothing). | | `daynight.mode` | enum | `auto` | `auto`\|`schedule` | **Live** | `auto` = the sensor automaton (the calendar is not used). `schedule` = the calendar decides outright, no sensor and no looks. The pre-2026-08-17 tokens still parse: `sensor`→`auto`, `time`/`sun`→`schedule`. | | `daynight.time_night_start` | string(6) | `""` | `"HH:MM"` | **Live** | Calendar: local time night begins. Set together with `time_day_start`; takes precedence over the sun calendar. (since v1.9.28) a window with only one edge set, a malformed value or two equal edges is ignored with a startup warning (and the sun calendar used if a location is set). | | `daynight.time_day_start` | string(6) | `""` | `"HH:MM"` | **Live** | Calendar: local time day begins. | | `daynight.sun_latitude` | float | 0.0 | -90–90 | **Live** | Sun calendar: latitude. Used when no time window is set and either coordinate is non-zero. | | `daynight.sun_longitude` | float | 0.0 | -180–180 | **Live** | Sun calendar: longitude. | | `daynight.sun_sunrise_offset_min` | int | 0 | -1440–1440 | **Live** | Minutes added to computed sunrise. (since v1.9.28) an offset that pushes an edge across solar midnight is honoured; it used to be clipped to the current solar day. | | `daynight.sun_sunset_offset_min` | int | 0 | -1440–1440 | **Live** | Minutes added to computed sunset. | | `daynight.day_gain` | float | 768 | 1–1,000,000 | **Live** | Exposure index below which the **day pipeline** confirms day. 768 = 3× gain: "day is confirmed when the day pipeline can hold the scene at ≤3×". Alias: `total_gain_day_threshold`. | | `daynight.night_gain` | float | 4096 | 1–1,000,000 | **Live** | Exposure index above which day ends. 4096 = 16×, "colour is hopeless". Alias: `total_gain_night_threshold`. | | `daynight.jump_pct` | int | 20 | 5–70 | **Live** | **since v1.10.1.** At night: the illuminator-lit level this far below the last look for 20 s starts a look at once (new light). | | `daynight.color_pct` | int | 3 | 0–50 | **Live** | **Since v1.11.0.** At night: the colour of the illuminator-lit picture (global R/G or B/G of the ISP's AWB statistics, read every 5 s) this far from its value after the last look for 20 s starts a look at once — a weak lamp that hardly brightens the IR picture. At most every 5 min, the gap doubling up to 45 min until a timer look; `0` = off. Needs `irprobe_cmd` and a libimp with per-zone AWB statistics; status `daynight.color_trigger` (`on`/`off`/`unavailable`) and `color_dev_pct`. | | `daynight.day_confirm_s` | int | 30 | 1–3600 | **Live** | How long the index must stay above `night_gain` (or the AE at its limit) before day→night; 6 s when very dark. | | `daynight.heartbeat_s` | int | 14400 (4h) | 300–604800 | **Live** | Check interval at night for cameras **without** a switchable illuminator; also caps the 45-min look interval of the others. | | `daynight.heartbeat_max_s` | int | 43200 (12h) | 300–604800 | **Live** | The longest night without a day-pipeline reading: then a check runs regardless of looks and lockouts. | | `daynight.interval_ms` | int | 2000 | 100–60000 | **Live** | Sample interval. | | `daynight.ir_idle_off` | int | 0 | 0/1 | **Live** | **Since v1.10.0.** Idle-Dark: at night, once no picture consumer has been there for `ir_idle_linger_s`, switch only the IR LED off (IR-cut and ISP stay in night) and decide at the next consumer from the reading taken meanwhile. Needs the FrameSource keepalive (`general.fs_keepalive`, auto on OpenIMP T23/T40/T41), auto mode and a `timps-irprobe` with the `idle`/`state` verbs. Off by default: with 850 nm LEDs the glow going out shows that nobody watches. Status `daynight.ir_idle`; see [Day/Night](Day-Night.md#idle-dark-ir-led-off-while-nobody-watches). | | `daynight.ir_idle_linger_s` | int | 60 | 0–3600 | **Live** | **Since v1.10.0.** How long after the last picture consumer the IR LED may go off. A poll slower than this costs one LED on/off pair and one held snapshot per poll; keep it longer than `timelapse.interval_s` (at 60/60 every shot can light the LED once). | | `daynight.history_s` | int | 0 (off) | 0–172800 (48h) | **Live** | Seconds of decision history kept in RAM for the WebUI tuning graph, served by `GET /control?dn_history=1`; `0` = off, and at `0` nothing is allocated at all. One 16-byte sample per 10 s, allocated on first push and resized in place when this changes, so 4 h costs 22.5 KB and the 48 h ceiling 270 KB. Anything above the ceiling is clamped (the POST response echoes the clamped value, and the daemon logs the effective size). Opt-in and decoupled from the tuning page's lifetime: the page's "collect in background" switch is the only thing that POSTs it, and it persists like any other key, so an explicit opt-in survives a reboot. With it off the page collects live into the tab over SSE instead; with it on the daemon owns the series, which is what lets the graph show the hours no tab was open (the WebUI is plain HTTP and therefore has no Service Worker to collect with). | | `daynight.irprobe_cmd` | string(64) | `timps-irprobe` | — | File-only, not GET-readable | Board script run as ` on\|off\|idle\|state` to drive the IR illuminator **alone**, without moving the IR-cut filter (`on`/`off` for a look, `idle`/`state` for Idle-Dark). Empty = no looks: the camera checks like one without a switchable illuminator (night level halved, `heartbeat_s`). Two failures in a row turn looks off for the session. Run via `fork`+`execlp`, never a shell. | | `daynight.switch_cmd` | string(64) | `daynight` | — | File-only, not GET-readable | Board script run as ` day\|night` on a switch. | | `daynight.isp_path` | string(128) | `/proc/jz/isp/isp-m0` | — | File-only, not GET-readable | ISP exposure proc file (gain **and** integration time). **Since v1.11.0** never read on vendor T21 (that driver corrupts the kernel heap on the proc read); exposure from IMP only there. | | `daynight.trace_path` | string(128) | `""` | — | File-only, not GET-readable | Opt-in decision-trace CSV for the replay harness. **tmpfs only**; 256 KB cap (since 1.11.0, before 1 MB), rotated once to `.1`. | ### Constants of the v1.10.0 engine (historical) These eight were per-camera `daynight.*` fields until the 2026-08-22 consolidation, then fixed `DN_*` constants echoed in `GET /control`. Since v1.10.1 they are retired: a file value is ignored with one warning, and the status no longer echoes them. The table describes the v1.10.0 engine. | Key | Was type | Fixed value | What it did | | --- | --- | --- | --- | | `probe_jump_pct` | int | 50 | Probe when the index falls below this percentage of the *night reference* (the level at which night was last proven). 50 = one full stop of new brightening. | | `probe_settle_s` | int | 8 | AE settling on the day pipeline before the probe verdict is taken. One verdict, binary: below `day_gain` it sticks, otherwise it reverts immediately (bypassing `transition_s`). | | `ref_delay_s` | int | 30 | Wait after entering night before anchoring the night reference (IR LEDs settling). A floor — the anchor also waits for the reading to stop moving. | | `ir_ratio_night` | float | 2.0 | Silent probe: an IR ratio `r` at or above this means the illuminator is carrying the scene — stay night, no click. `r = D(illuminator off) / D(illuminator on)`, so it is dimensionless and fleet-wide, unlike any absolute level. | | `ir_ratio_day` | float | 2.0 | …and at or below this the room supplies the light. Deliberately equal to `ir_ratio_night`, so every silent probe reaches a verdict; a reading between the two is inconclusive and escalates to the audible probe instead of deciding on thin evidence. | | `ir_min_headroom` | int | 8 | Minimum AE reserve, in log2 units (32 = one stop), for a ratio to mean anything. An AE with nothing left cannot respond to the illuminator going off and returns `r ≈ 1`, which looks exactly like daylight — measured 1.14 on a pitch-dark scene. Below this the verdict is night: being pegged at the *dark* end is itself proof. | | `boot_settle_s` | int | 5 | Minimum wait after thread start/re-enable before the first decision (also gated on the reading having settled). | | `transition_s` | int | 5 | Minimum dwell between switches. A failed probe's revert bypasses it. | ## `general.*` — daemon-wide settings File-only except `debug_modules`, which is POST-able as `{"general":{"debug_modules":"HAL_ING"}}` and takes effect immediately. Everything else in this section is read at startup. Two keys are deliberately left out of the table below: `general.trace` and `general.trace_ms` are a developer probe for send-pipeline tracing (see `src/trace.h`), handled directly in `set_kv()` rather than as table fields — not POST-able, not echoed, not part of `/control?fields=1`. They are intentionally undocumented here; edit `/etc/timps.conf` by hand if you need them. | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `general.loglevel` | int | `LOG_INFO` (2) | SDK log-level enum | File-only | Startup log verbosity (`-v` on the command line also forces debug). | | `general.debug_modules` | string(64) | `""` | comma list of log tags | **Live** | Modules raised to DEBUG regardless of `loglevel` (`HAL_ING,DAYNIGHT`). Names rather than a bitmask: a mask in a config file silently means something else once a module is added, and nothing warns. It matters because the ring buffer is 64 KB and recycles in minutes under load - raising the global level to reach one subsystem loses the lines it was raised for. Case-insensitive, up to 8 names; over-long or surplus names are rejected with a WARN, an unknown (but well-formed) name simply never matches. A listed module passes at **every** level, not only its DEBUG lines. See [Logging](Logging.md). | | `general.imp_polling_timeout` | int | 500 | — | File-only | `IMP_Encoder_PollingStream` timeout, ms. | | `general.osd_pool_size` | int | 1024 | — | File-only | Max OSD pool size (KB) reserved for small OSD regions. | | `general.fs_keepalive` | int | -1 | -1..1 | File-only | FrameSource keepalive. `1`: every video FrameSource is enabled once at start and held until the daemon stops (encoders stay on-demand), so nothing disables a FrameSource at runtime. `0`: FrameSources run only while something consumes them (the classic on-demand model). `-1` (auto): **on for OpenIMP builds on T23, T40 and T41** (T23/T41: a FrameSource disable/enable while the sensor streams can hang the SoC; T40, since v1.11.0: the AE stops without frames and day/night read a frozen exposure); off everywhere else, because the idle cost of a permanently running FrameSource (CPU, heat, ~6.3 MB rmem for a 1080p main stream) is unmeasured there. Also `auto`, `on`/`true`/`yes`, `off`/`false`/`no`; an unknown word logs a warning and stays auto. Effective value: `caps.framesource` in `GET /control`. See [Architecture](Architecture.md#framesource-keepalive-generalfs_keepalive). | | `general.syslog` | bool | on | 0/1 | File-only | Also mirror log output to syslog (`logread`). A side-effecting key: applied immediately at config-load time via `log_set_syslog()`, but not stored as a field, so it cannot be echoed back by any getter. | ## `sim.*` — host simulation backend Only meaningful for `make sim` builds (`src/hal/hal_sim.c`); ignored by a real-hardware (`HAL_INGENIC`) build. File-only. | Key | Type | Default | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `sim.video0` | string(256) | `""` | file path | File-only | Annex-B H.264/H.265 file looped as the video0 source. | | `sim.video1` | string(256) | `""` | file path | File-only | Annex-B file looped as the video1 source. | | `sim.audio` | string(256) | `""` | file path | File-only | ADTS AAC file looped as the audio source. | | `sim.jpeg` | string(256) | `""` | file path | File-only | Still JPEG file republished as the JPEG source(s). | ## `video.*` — per-stream encoder settings `N` is the video stream index (`0` = main, `1` = sub; `MS_MAX_VSTREAM=2` streams total). Geometry/codec/identity keys (`enabled`/`codec`/`width`/ `height`/`fps`/`gop`/`max_gop`/`profile`/`rotation`/`buffers`) are persist-only / restart-required (the encoder and FrameSource are never reconfigured on the running pipeline); `rtsp_path` is honestly live — see the caveat at the top of this page. The rate-control knobs below (`bitrate`/`rc_mode`/`qp`/`min_qp`/`max_qp`/`i_bias_lvl`) are **Mixed**: a per-platform subset reaches the running encoder live as of 2026-08-21 — see [Rate Control Parameters: Live vs. restart](Rate-Control-Parameters.md#live-vs-restart-per-soc) for the exact key × SoC table, or query the running build's own `caps.video_live` (never hardcode the table into a client — see [HTTP Control API](HTTP-Control-API.md#the-caps-object)). `GET /control` still groups this whole section under `caps.restart` (entry `"video"`) as the conservative default; a key that also appears in `caps.video_live`, and `rtsp_path`, are the per-key exceptions. | Key | Type | Default (video0 / video1) | Range | Live? | Description | | --- | --- | --- | --- | --- | --- | | `video.enabled` | bool | 1 / 1 | 0/1 | Restart-only | Enable this stream. | | `video.codec` | enum | `h264` / `h264` | `h264`\|`h265` (aliases `hevc`) | Restart-only | Video codec. H.265 only where the platform's encoder API supports it — see [Platform & SDK Support](Platform-SDK-Support.md). | | `video.width` | int | 1920 / 640 | 64–4096 | Restart-only | Encode width (pre-rotation). With neither `width` nor `height` set, a known sensor size that fits inside the default is used instead, so nothing is upscaled (a 1280x720 sensor streams 1280x720; since v1.10.0). | | `video.height` | int | 1080 / 360 | 64–4096 | Restart-only | Encode height (pre-rotation). Unset: see `width`. | | `video.fps` | int | 25 / 25 | 1–120 | Restart-only | Encode frame rate of this stream only. It does **not** set the sensor rate; that is `sensor.fps`. | | `video.bitrate` | int | 3000 / 512 | 16–50000 (kbps) | **Mixed** (live on every SoC as of 2026-08-21, see below) | Target bitrate. | | `video.rc_mode` (alias `mode`) | enum | `cbr` / `cbr` | `cbr`\|`vbr`\|`fixqp`\|`smart`\|`capped_vbr`\|`capped_quality` | **Mixed** (live on classic SoCs only, see below) | Rate-control mode. | | `video.gop` | int | 50 / 50 | 1–1000 | Restart-only | GOP length (I-frame interval). | | `video.max_gop` | int | 60 / 60 | 1–1000 | Restart-only | **RESERVED / no effect** — parsed, clamped and persisted for compatibility but consumed by no HAL; the encoder's keyframe interval comes from `video.gop`. Setting a non-zero value logs a one-shot warning. | | `video.profile` | int | 2 / 2 | 0 (baseline) – 2 (high) | Restart-only | H.264/H.265 encode profile. **Matters for WebRTC:** the WHEP answer's `profile-level-id` comes from this stream's live SPS, and Firefox offers (and accepts) H.264 as Baseline only — it refuses a Main/High answer at `setRemoteDescription()`, so the session never establishes. Chromium accepts any profile and decodes from the in-band SPS. Set `0` on the `webrtc.channel` stream if Firefox must work; the preview page detects the mismatch and disables its WebRTC option either way. See the top-level `README.md` § "WebRTC / WHEP". | | `video.qp` | int | 35 / 35 | 1–51 | **Mixed**, see below | Fixed QP, used only when `video.rc_mode=fixqp` (passed as the encoder's initial/fixed QP: `iInitialQP` on the new API, `attrH264FixQp.qp`/`attrH265FixQp.qp` on the classic API). No effect under `cbr`/`vbr`/etc., which derive QP from rate control instead — use `video.min_qp`/`max_qp` there. | | `video.min_qp` | int | 20 / 20 | 1–51 | **Mixed** (live on every SoC as of 2026-08-21, see below) | Minimum QP. | | `video.max_qp` | int | 45 / 45 | 1–51 | **Mixed** (live on every SoC as of 2026-08-21, see below) | Maximum QP. | | `video.quality_lvl` | int | 2 / 2 | 0–7 | Restart-only | Classic-SoC (T10..T30) VBR/Smart only: minimum quality floor. 0 = highest quality/largest stream, 7 = lowest. The vendor SDK derives `minBitRate = bitrate * quality[lvl]` from it (`quality[]={0.8..0.1}`), but that floor was not found to be binding in testing — see [Rate Control Parameters](Rate-Control-Parameters.md). No equivalent field on the new encoder API (T31/C100/T40/T41); a non-default value there logs a one-shot warning and has no effect. | | `video.change_pos` | int | 80 / 80 | 50–100 | Restart-only | Classic-SoC VBR/Smart only: percentage of `bitrate` above which the controller starts raising QP. Measured to have no observable effect on a T23 (80/65/50 gave identical output) — treat as unverified rather than a reliable lever. No equivalent on the new API; same one-shot warning as `quality_lvl`. | | `video.i_bias_lvl` | int | 0 / 0 | -3–3 | **Mixed** (live on classic SoCs and T31/C100, see below) | I-frame QP bias (negative = more bits on keyframes). Classic SoCs: both CBR and VBR/Smart. New API: wired on T31/C100 via `IMP_Encoder_SetChnQpIPDelta` (readable back as `encoder..rc.ip_delta`); T40/T41 have no such call and warn once instead. | | `video.fluc_lvl` | int | 0 / 0 | 0–4 | Restart-only, H265 only | Classic-SoC H265 CBR/VBR/Smart: bitrate fluctuation allowed relative to the average. No H264 equivalent field, and the T23 SW-rotate path is H264-only so never sees this key. No equivalent on the new API. | | `video.rotation` | int | 0 / 0 | `0`\|`90`\|`270` (+`180` on T40/T41 only); legacy `1`/`2` accepted as raw `rotTo90` values | Restart-only, `USE_ROTATE` builds only | Image rotation — see [Platform & SDK Support](Platform-SDK-Support.md) and `docs/rotation.md`. Unsupported values on a given SoC are coerced to `0` with a warning. T10/T20/T21 (OpenIMP builds, since v1.10.0): sub streams only, 16-aligned, ≤704x576, and only with an OpenIMP that has T1x rotation (`6b7f5ae`); otherwise the stream starts unrotated with a warning. `GET /control` reports why a stream runs unrotated in `video..rotation_state` (since v1.10.1) and how it is rotated in `video..rotate_path` (`off`/`sw`/`hw`/`band`, since v1.11.0). | | `video.buffers` | int | 2 / 2 | 1–8 | Restart-only | IMP video buffer count (`nrVBs`). Explicitly setting this makes the HAL trust it as-is instead of applying its own safety clamp. | | `video.rtsp_path` | string(64) | `/ch0` / `/ch1` | — | **Live** (documented exception, see above) | RTSP mount path for this stream. | | `video.jpeg` (alias `jpeg_enabled`) | bool | 1 / 1 | 0/1 | File-only | Enable a piggyback JPEG encoder sharing this stream's FrameSource (used by `/snapshot.jpg`/`/stream.mjpeg?chn=N` and [timelapse](Recording-Timelapse.md)). | | `video.jpeg_quality` | int | 75 / 75 | 1–100 | File-only | Piggyback JPEG quality. | | `video.jpeg_fps` | int | 5 / 5 | 1–120 | File-only | Piggyback JPEG max publish rate. | | `video.jpeg_chn` | int | `MS_MAX_VSTREAM+1+N` | 0–8 | File-only | IMP encoder channel number for the piggyback JPEG encoder (internal). Must be unique; (since v1.9.28) a collision is moved to the lowest free channel at load (or that JPEG encoder disabled if none is free), with an `[ERR]` line. | | `video.imp_chn` | int | `N` | 0–7 | File-only, internal | IMP encoder channel number for the video stream itself. It doubles as the stream's hub slot, so it must equal `N`; (since v1.9.28) any other value is reset to `N` at load with an `[ERR]` line (before, it silently fed another stream's or the audio slot). | --- For the live-apply mechanics (how a POST maps onto these keys, JSON shapes, and the `caps` object a client can use to discover this classification programmatically) see [HTTP /control API Reference](HTTP-Control-API.md).