Skip to content

Day/Night

src/daynight.c implements native, automatic day/night switching, replacing thingino's separate daynightd daemon. Built with USE_DAYNIGHT (on by default — see Building); without it, stub functions report enabled=0 and fall back to whatever image.running_mode currently holds.

Since v1.10.1 the decision engine is new (dev_notes/dn-redesign.md, docs/ai/config-keys.md §14). Day→night: the day pipeline above night_gain or the AE at its limit for day_confirm_s (6 s when very dark). Night: a look (IR illuminator off 8 s, silent) every 45 min or when the lit level falls jump_pct (20 %) below the last look; a check (day pipeline, one click) when the look reads below the bar; failed checks lock out for 1, 2, 4 … min; a check anyway after heartbeat_max_s (18 h). Since v1.11.0 a look also starts when the colour of the lit picture (AWB statistics) moves color_pct (3 %) from the last look for 20 s — a weak lamp that barely brightens the IR picture (at most every 5 min, doubling to 45 min; status color_trigger, color_dev_pct); a look taken while the lit level is still moving sets no reference, the next settled level does; vendor T21 no longer reads isp_path. Since v1.12.0 (dawn): a look whose lit level already sits at the bar (lit × 0.9 ≥ bar) is skipped, the illuminator stays on; a creeping light (no 10 s step of more than 15 %) is judged with the jump bar and a failed check's k; near the bar a creep looks once the last dark reading predicts day (look (near)); a lamp lit right after a check is not taken as the night reference. Retired: probe_min_gap_s, probe_confirm_s, boot_probe, diagnose_thresholds, the IR-ratio verdict, the trend trigger, the night reference ratchet and its vetoes. The sections below that describe probes, the ratio and the heartbeat describe v1.10.0 and earlier; Idle-Dark, switch_cmd, the hooks and the calendar still apply.

What timps does — and does not — control directly

A single background thread (dn_thread) decides day vs. night and, on a change, runs an external board switch script as <daynight.switch_cmd> day|night (default script name daynight, via fork()+execlp() — never system(), so a config value can't inject shell commands). timps does not flip the ISP mode itself from this path — the board script is expected to drive the physical IR-cut filter and IR LEDs, and then call back into timps's own /control to set image.running_mode, closing the loop:

flowchart TB
  a["dn_thread decides day/night"] --> b["vfork()+execlp(switch_cmd, day | night)<br/>e.g. thingino's daynight script"]
  b --> c["board script flips the IR-cut filter and the IR LEDs, then"]
  c --> d["POST /control #123;#quot;image#quot;:#123;#quot;running_mode#quot;:0 | 1#125;#125;<br/>(the colour hook)"]
  d --> e["control.c: timps_apply_setting(#quot;image.running_mode#quot;, ...)"]
  e --> f["hal_ingenic.c: IMP_ISP_Tuning_SetISPRunningMode(...)"]
  f --> g["fs_kick_chn0() (see below)"]

This indirection keeps the whole board in sync: the same script that already knows how to drive this specific camera's IR-cut hardware is responsible for telling the ISP the new mode, rather than timps duplicating board-specific logic. The mode change is committed (cur=target) even if the script exits non-zero (logged as a warning) — a missing script only warns once, it doesn't retry every sample.

The one asymmetry everything follows from

The two optical paths are not equally trustworthy, and every rule below is a consequence of that:

  • Day pipeline — IR-cut closed, illuminator off, colour tuning. The exposure the ISP settles on is an honest measure of ambient light.
  • Night pipeline — IR-cut open, illuminator on. The camera is partly measuring its own light. An absolute reading here means nothing; only a change in it means anything.

So day→night is a plain measurement, and night→day can only be answered by physically switching to the day pipeline and looking — a probe, which costs an audible IR-cut click. All of the scheduling in daynight.c exists to bound how often that click happens without ever giving up the ability to correct a wrong night.

Design note. Between 2026-08-02 and 2026-08-16 this file accumulated nine separate rules rationing that one probe — backoff, failure ratchet, anchor override, evidence skip, probe_max_skip_s, trend suspension, brightening hold, oscillation breaker, verify deadlines. Each was correct and each was bought with a real incident, but because they all gated the same single path their failures multiplied instead of cancelling; the worst case was a camera rendering IR video in broad daylight for four hours while a service restart fixed it in ten seconds. The 2026-08-17 redesign replaces them with independent paths (five today) and one rate limit. The full argument is in dev_notes/DAYNIGHT_REDESIGN_2026-08-17.md; the incident record it came from is in Day/Night Design Notes.

The metric: exposure index, not gain

D = total_gain × (integration_time / max_integration_time)      higher = darker

total_gain alone has a hard floor at 256 (1.0×). Once the AE rails there, further brightening of the scene is invisible — which is why a camera resting at 256–268 under its own IR (mounted close to a reflective object) could never have its brightening detected at all, no matter how the probe was scheduled. The AE has a second control, and when the gain bottoms out it keeps shortening the exposure instead, so the product carries the signal across the whole range.

  • Dark scene: integration railed at max → D == total_gain. Identical to the pre-redesign behaviour, and the reason day_gain/night_gain keep their historic calibration.
  • Bright scene: D falls far below 256, continuously.
  • max_integration_time unreadable → D = total_gain, i.e. clean degradation to the old behaviour.
  • Normalising on max makes it frame-rate independent.

Both fields come from the daynight.isp_path scrape, so that scrape now runs on every decision tick — which is what the 2 s default interval_ms pays for (the gain half still prefers IMP_ISP_Tuning_GetTotalGain where available). Since v1.11.0 vendor T21 skips the scrape (the vendor tx-isp-t21 driver corrupts the kernel heap on that proc read; an oops killed the day/night thread) and takes the integration-time ratio from IMP_ISP_Tuning_GetExpr.

Five paths, none of which can block another

Path Signal Covers Cost
A day → night day-pipeline D vs night_gain, held day_confirm_s dusk, lights off 1 click
C spontaneous probe relative fall of D below probe_jump_pct% of the night reference, held probe_confirm_s lights on; indoor cameras; cameras with no location data — the path that carries most of a fleet 2 clicks if it fails
T trend probe a 3-minute EMA of D below 75% of a 60-minute one, held probe_confirm_s dawn — a ramp too slow for path C's step bar (measured: a real twilight needs 106 minutes to reach it) 0 clicks; only armed when daynight.irprobe_cmd makes the probe silent
B heartbeat probe wall clock only, no sensor a reference anchored too low, a scene the index cannot read 2 clicks
D boot one measurement at t=0, then one assertion the persisted mode is a guess of unbounded age, and the board may be in the other mode 1 switch_cmd if the answer is day, 2 if night

When night_gain is out of reach (path A)

Path A needs an index above night_gain, and the index is gain × ratio with the ratio at most 1. An AE that stops at its analog cap and leaves the digital gain at 0 therefore tops out at the cap itself, 256·2^(MAX SENSOR analog gain/32) — 4096 for a cap of 128, exactly the default night_gain. Measured on one Wyze Pan v1 (T20/jxf22, OpenIMP), one night: analog 128/128, ISP digital 0 of 32, integration time 87.6 %, d = 3587 on almost every sample — the camera stayed in day in a pitch-dark cellar, and the 32 unused digital units counted as reserve, so the railed variant of path A did not fire either.

Since v1.10.0, where the published cap is <= night_gain (provably out of reach while the digital gain stays unused), path A also counts a day tick as dark when the analog gain is at >= 98 % of its cap, the ISP and sensor digital gain read 0 and the smoothed index is within 95 % of its highest settled value of that stretch at the cap — held day_confirm_s like the threshold. The plateau bar must clear 1.5 × day_gain, or the next probe would read the same scene as day and undo it. One WARN per session names the numbers. After the first such switch every night_gain comparison in the loop (the plain day test, the filter-cost and noir vetoes) uses that bar, so a ratio verdict cannot chase the rule. Night→day and boot are untouched: both read against day_gain, which a dark room exceeds. Everywhere the cap is above night_gain, or the dump has no cap or no ISP digital gain line, nothing changes. The pure rule is src/dn_reach.h (make test-dn-reach); corpus scenarios 55–59 (59: a lamp makes the silent probe say day while the day pipeline stays at the cap — one learning round trip, then the filter-cost veto holds night).

The plateau is the highest settled value of the current stretch at the cap, not a learnt constant, so one transient cannot raise it for good. The price: on an AE that reaches the cap before its integration-time limit, the switch comes once the cap has been held day_confirm_s with the index not falling more than 5 %, possibly before the integration time has topped out (bounded below only by the 1.5 × day_gain bar).

What is measured: one camera, one night, the stuck-in-day symptom and its numbers. What is not: the rule on that camera (pending a field test), why that AE stops where it does (OpenIMP), and any camera whose cap is slightly above night_gain but whose AE stops below it — the cap proves nothing there and the old behaviour stays.

That last case turned out to be the Pan itself. On the open stack the dump is isp-m0, written by open-tx-isp's compact T20 AE, and in that mode the MAX SENSOR analog gain line prints the sensor maximum (attr->max_again >> 11, 158 on jxf22) while the AE clamps at its tuning ceiling (128). Derived, not read off the camera: the open-tx-isp 3cf9bdac source of the image, and the probe line's lit_hr=124, which is (158 − 66) + 32 for the analog 66 the exposure log shows at that moment (128 would give 94). With 158 the cap is 7838 > 4096, so the plateau rule never fired in the 2026-10-07 soak (no reachable plateau line in the central log).

Confirmed on the device 2026-10-09 (Pan, open-tx-isp 3cf9bdac, timps ed60eae): isp_info on the same camera publishes MAX SENSOR analog gain : 128 — the AE's cap — next to isp-m0's 158. Reproduced with daynight.boot_probe = 0 and a persisted day (image.running_mode = 0) in the dark: day adopted at boot, d 3587 constant, no switch. Since v1.10.0 timps reads that line from isp_info beside a configured isp-m0 (cached, re-read every ~5 min) and uses the lower of the two maxima for the plateau rule and the AE reserve; it logs once … publishes MAX SENSOR analog gain 158, isp_info 128: using 128 …. On the Pan the reserve at the cap drops from 30 + 32 to 0 + 32 units (the IT stop), still above the 8-unit clip limit, so nothing else changes there. Scenario 64 models the reproduction (one switch to night after day_confirm_s), 65 is its negative control (isp_info 160: the lower value stays 158, day as before); not yet run on the camera. On older builds: daynight.night_gain below the plateau, e.g. 3000.

When the silent probe sees light the day pipeline does not

A silent probe compares the night pipeline (IR-cut open) with and without the camera's own illuminator. r ≤ 2 means "the illuminator is not what lights this scene" — which is also true when the light is infrared from somewhere else: another camera's illuminator in the same cellar. The IR-cut filter removes that light, so the day pipeline stays dark. Two cameras, one night each (2026-10-07):

  • Wyze Pan v1 (T20/jxf22, OpenIMP): came up in day (running_mode=0), the boot measurement read the day pipeline at 3587 and switched to night. 20 s later path C fired against a reference of 3489 — the level it had come up with, i.e. a day-pipeline level — the probe read lit 401, dark 483, r = 1.20, and switched to day. The plateau rule (above) was inert, so the camera stayed in day: no DAYNIGHT line for the rest of the night.
  • SecuPlug SP1 (T10L/jxh42, OpenIMP): switching to day (IR ratio 1.36) at 23:17:38, switching to night (exposure 4179 > 4096) at 23:18:36 — a switch pair per such probe. (From the field notes; this camera is not in the central syslog, so its probe line is not quoted here.)

Since v1.10.0 two changes:

  1. A night that a measurement proved — path A's switch, a probe that found night (the boot measurement included), a silent probe with r ≥ 2, the pegged or filter-cost verdict — keeps a flag. While it is set, a silent probe's "the room supplies the light" only switches to day if the lit level is measurably brighter than the night reference (below 90 % of it, the heartbeat's DN_MOVED_MARGIN). Otherwise it stays night — light the day pipeline does not see: silent probe (…): r=… but lit … is no brighter than the proven night reference …. A night that was adopted (another tool switched the ISP, boot_probe=0) is not proven and keeps the old behaviour, so a night adopted in daylight still ends at the next probe. The filter-cost veto runs first and is unchanged.
  2. When the boot measurement starts from a board that came up in day, its pre-level is not used as the night reference (it is the day pipeline's); the reference is anchored from the night pipeline after 30 s, like after any other switch. That removes the immediate path-C probe after such a boot.

Unchanged: a real light (the lit level falls below 90 % of the reference) goes to the ratio exactly as before, and so does a dawn — r can only fall below 2 once the ambient light outweighs the illuminator, which puts the lit level under the reference. Residual: a reference anchored before the other cameras' illuminators came on is darker than the scene with them, so the first probe after that can still try day once; the bounce off night_gain re-anchors the reference and the next probe is held. On the Pan, a legitimate day (cellar lamp on) that ends with the lamp off still has no way back to night while timps reads only isp-m0's 158 (fixed by the isp_info read above). The pure gate is src/dn_proven.h (make test-dn-proven); corpus scenarios 60 (Pan), 61 (T10), 62 (a lamp still switches to day), 63 (a dawn after a night of held probes switches once).

The construction rule, and the thing the previous design lacked:

A rationing rule may only be applied while the independent trigger it defers to is actually working.

That is not an aspiration but a runtime predicate (dn_c_sighted()): if the integration-time fields are unavailable and the night reference sits at the gain floor, path C is structurally blind, so the heartbeat is not allowed to stretch itself on path C's behalf.

The night reference

One number, and it is a proof, not a filter state:

ref = the exposure level at which night was last proven — either by entering night from day, or by a probe that went and found darkness.

A probe that fails is simultaneously a proof of night and a contemporaneous night-pipeline reading, which is why one variable does the job the old design spread across night_baseline (drifting), smooth_tg, probe_fail_smooth, min_smooth_since_probe and brighten_ref. It never drifts. It moves in exactly two places, and the failed-probe assignment is the entire "ratchet": after a failed probe the bar is derived from the level that did fire, so a repeat of the same stimulus (headlights, a dip in an overcast dawn) cannot re-fire it.

Nothing is load-bearing on any single sample the way night_baseline was:

  • reference anchored too high → one probe fires, finds night, and re-anchors at the true resting level. Permanently correct after one self-correcting click pair. (The a5dae07 incident was this exact situation looping forever every 25 minutes.)
  • reference anchored too low → path C goes quiet until the heartbeat re-anchors it. Bounded by heartbeat_max_s.

It can also be lowered directly, with no probe needed: a silent probe verdict of "night" — the illuminator carries the scene (r >= ir_ratio_night), or day mode is measured-to-bounce (the filter-cost projection; that branch forgot the rule until 2026-08-21 and cam-06 re-fired every 26 s against a stale bar, scenario 26-projection-verdict-no-way-down) — at a level below the current probe bar (ref * probe_jump_pct / 100) is proof in the other direction — the reference predicted a brightening worth looking at, and the look found darkness, so the reference described a scene that no longer exists. Without this a reference anchored too high while the ISP was misbehaving had no way down at all: measured on a camera whose ISP came up wrong and anchored at 131072, then read 6070 once it recovered — the jump trigger fired every fourteen seconds, the silent probe correctly answered "the illuminator carries this scene," and nothing moved. Seventeen probes in a row, which reads from outside as an IR lamp that keeps switching itself off. Logged as night reference lowered to <level>, proven by the silent probe (bar <level>). Corpus scenario 23-stale-reference-no-way-down.

Since 2026-08-21 the reference additionally refuses clips: a reading taken while the AE is railed (reserve known and < ir_min_headroom) is a clip, not a level — it may prove night, but it may not be remembered. The post-entry anchor waits for an honest sample (night reference deferred: …), and a failed probe whose pre-probe level was a clip leaves the reference unset instead of ratcheting the clip in. Until a trustworthy reading arrives the automaton runs on the configured absolute thresholds and the heartbeat alone, which changes nothing about its steady-state probe cadence. The same rule keeps clips out of filter_cost, the threshold diagnostic and the trend memory. Measured: a T23 whose ISP boots railed anchored 131072 where the scene was worth a twentieth of that, and the probe bar of 65536 then sat above anything a real brightening could reach. Corpus scenario 24-pegged-boot-poisoned-reference.

The trend (path T)

ref answers a step; it cannot answer a ramp. The bar sits at probe_jump_pct (50%) of a proven night level, and natural twilight takes an hour or more to get there — measured on cam-C, the dawn of 2026-08-18 fell 11839 → 5312 over two hours (a factor of 2.23) and did not reach the bar until 07:31, 67 minutes after sunrise. Meanwhile the camera renders IR video in daylight.

So night also keeps two EMAs of the index and compares them with each other:

fast = EMA, tau 3 min       (follows the scene)
slow = EMA, tau 60 min      (remembers it)
probe when  fast / slow < 75%,  held probe_confirm_s

Nothing absolute is consulted — the question is "is this scene brighter than it remembers being", which is the one relative question the night pipeline can answer honestly. Both EMAs are re-anchored after every verdict, so an answered question cannot immediately re-ask itself, and both are frozen while the illuminator is off (that is a third optical state, not a darker version of this one).

Measured over 12 cameras and 181 camera-hours of night (scripts/dn-trend-eval.py): 10 of 12 dawns found, 0.22 false fires per camera-hour at these constants. Shorter memories fire later and find fewer, because they track the twilight instead of noticing it.

Only armed when daynight.irprobe_cmd is set. That rate is affordable as silent probes — a few seconds of dimmer image each — and not as audible ones: it would be roughly 2.6 extra motor movements per 12-hour night. A board that cannot switch its LEDs separately keeps path C plus the heartbeat.

The constants are compile-time (DN_TREND_FAST_MS, DN_TREND_SLOW_MS, DN_TREND_PCT in src/daynight.c), not config keys: they are dimensionless and fleet-wide by construction, which is the same property that made the IR ratio worth having in the first place.

The silent probe

Where the board can drive its IR illuminator without moving the IR-cut filter (daynight.irprobe_cmd), every request for a verdict tries this first:

turn the illuminator OFF
wait probe_settle_s for the AE
r = index(illuminator off) / index(illuminator on)
turn the illuminator back ON

r is a ratio, so it is dimensionless and the same pair of thresholds fits every camera in a fleet. No absolute level does: genuine daylight readings span a factor of 63 across twelve cameras at one instant.

verdict condition costs
night lit reserve < ir_min_headroom nothing — r divides two clips and says nothing; railed-dark is still night evidence
night r >= ir_ratio_night nothing — the illuminator was carrying the scene
night reserve < ir_min_headroom nothing — the meter is pegged at the dark end, which is itself proof
escalate reserve unknown (no ceiling fields in the ISP dump) one audible probe — a lit room and a railed meter look identical without the reserve, so the day pipeline judges (2026-08-21; previously this fell into the "pegged" row and answered night forever)
day r <= ir_ratio_day, reserve sufficient, and the filter-cost gate below one switch
escalate anything in between one audible probe

The headroom test is not a refinement. An AE with nothing left cannot respond to the illuminator going off, so it returns r ≈ 1 — indistinguishable from daylight. Measured on a pitch-dark scene: r = 1.14, below ir_ratio_day. Only the reserve separates that from a genuinely lit room.

A camera whose ISP dump has no ceiling fields (MAX SENSOR analog gain / MAX ISP digital gain) has no reserve at all — and therefore none of the clip protection either. That is announced once per session as a WARN (no gain ceilings in the ISP dump - AE reserve is unknown …), not left to be inferred from hr=-1 in a probe line. Both fleet T20s do publish the ceilings (jxf23: 32 units ISP digital, jxf22: 45 — same SoC, different sensors, so a hard-coded value would be wrong on one of them). The open-tx-isp T41 dump is such a variant: its AeMaxAGain is the uncapped sensor maximum, not the AE's ceiling, so it is not used. On T41 with the open stack the reserve comes, since v1.10.0, from IMP_ISP_Tuning_GetAeExprInfo instead (AeMaxAGain, which open-tx-isp fills with the smaller of sensor maximum and gain cap, against TotalGainDb, log2 in Q16); integration time adds a stop only while the gain is still 1x, because above that the AE sits at its own limit, which can be below the published maximum (T55A dump: 1750 of 2092 lines at 6.9x).

Both ratio thresholds ship at 2.0, so with the defaults the "escalate" row never fires. Setting ir_ratio_day below ir_ratio_night opens a deliberate dead zone, trading an occasional audible click for not deciding on thin evidence.

When the illuminator command doesn't work

A board that cannot switch its IR LEDs separately from the filter answers every attempt to run irprobe_cmd with a failure. Falling back to the audible probe on each of those tries would be worse than never having tried it — a motor movement paid for a command that was never going to succeed. Two consecutive failures retire the silent probe for the session, and the trend trigger (path T, which only exists to make silent probing cheap) retires with it, leaving the jump trigger (path C) and the heartbeat — the fleet's pre-existing behaviour on a board without a working irprobe_cmd. Not persisted: a restart re-tests it, which is the cheap direction to be wrong in. Logged once, at the second failure: '<cmd>' failed N times - silent probe retired until restart; the trend trigger goes with it, leaving the jump trigger and the heartbeat.

POST /control {"daynight":{"probe":1}} asks for one silent probe on the next tick — useful to confirm a camera can see daylight without waiting for the jump trigger, the trend or the heartbeat. It is rejected (not silently swallowed) when there is no irprobe_cmd configured or the probe has retired itself as above; see HTTP /control API Reference.

What switching to day also costs

A low ratio answers is the illuminator earning its keep. It does not answer is there enough light to run day mode — because switching to day also closes the IR-cut filter, and in a dim interior that filter alone can cost a factor of three.

Left unguarded the two rules chase each other. Measured on a bedroom-class scene with one dimmed lamp: the probe reports r = 1.27 and switches to day; the day pipeline, filter now closed, reads 11480 against a night_gain of 4096; path A sends it straight back; the illuminator returns and the ratio reports 1.27 again. Eight round trips in one evening, each an audible click.

So the automaton measures filter_cost — the day reading over the night reading the verdict left, 3.27 in that scene — the first time a ratio verdict is undone that way, and from then on requires

current night reading × filter_cost < night_gain

before it will act on a ratio at all. One exploratory switch is unavoidable: nothing can know the day-pipeline reading without trying it once. Every later pass refuses in the log (at LOGD, like every per-probe line — see Logging) and costs nothing:

silent probe (trend): r=1.27, but day mode would read ~11500 (3.27x) > night_gain 4096

filter_cost may be below 1. The first version of this guard only learned when the day reading came back higher than the night one, on the assumption that closing the IR-cut filter can only cost light. A T20 whose illuminator contributes nothing measurable disproved that: the silent probe returned r = 1.00 with ample AE reserve, the day pipeline read 6166 against a night level of 8171 — lower, not higher — and 6166 was still above night_gain, so the guard never armed and the camera flapped 11 times in a day. What filter_cost captures is not "what the filter costs" but what day mode actually reads in this scene, and that is meaningful in both directions; the log line where it's first measured says so:

day mode reads 0.75x the night level here (6166 vs 8171) - a ratio verdict
now needs the night reading below 5428

At real dawn the night reading falls, the product drops below night_gain on its own, and the switch happens — no special case, no calendar. filter_cost is measured per scene rather than configured, and is deliberately not persisted: a quantity that moves with the scene is cheaper to re-measure once per restart than to keep correct in flash. Corpus scenarios 21-ir-ratio-flap-cam-05 (factor above 1) and 22-ir-ratio-flap-t20 (factor below 1) hold this to two switches where the unguarded automaton spent twelve.

The probe

switch to the day pipeline
wait probe_settle_s for the AE
judge ONCE against day_gain:  below → stay (day confirmed)
                              else  → revert immediately, ref := pre-probe level

There is no third, "ambiguous" outcome — which is what the entire dead-zone/dn_verify/deadline-extension machinery used to exist for. The revert bypasses transition_s: retreating from a wrong probe is free.

probe_min_gap_s is the only rule rationing probes, so the worst-case click rate is a property of the configuration rather than of interacting heuristics. This is also why there is no longer an oscillation breaker: an IR-reflection feedback loop needs a threshold crossing to flip night→day, and no threshold can do that any more.

The heartbeat

The only bound on how long a wrong night can last, and deliberately a flat interval rather than a multiplying backoff — a backoff is precisely how the previous design turned a bounded guarantee (night_reconfirm_s) into an unbounded one (×4, plus a skip gate, plus a 12 h outer bound).

  • scene moving since the last probe → heartbeat_s (4 h)
  • scene flat (smoothed range within 15%) and path C sighted → deferred, but never past heartbeat_max_s (12 h) since the last probe
  • a configured calendar can only pull the deadline in, to the next sunrise — never push it out. Since v1.9.28 that pull-in applies at every re-arm of the heartbeat; before, it ran at only one of the re-arm sites, so after the first re-arm of a night the calendar was not consulted again. The calendar's sunrise here includes daynight.sun_sunrise_offset_min. In mode=schedule, where the calendar decides outright, a sunrise/sunset offset that pushes an edge across solar midnight is now honoured too (it used to be clipped to the current solar day, while sun_computed_* showed the shifted time). A time window counts only when both edges are valid HH:MM and differ; anything else is ignored with a startup warning.

A dark closet therefore costs two click pairs a day, and a camera that sees anything at all costs six. Note what the calendar is not doing: it never decides. That is what keeps basements, windowless rooms and artificially lit spaces working, and it is why a camera with no location data loses only sharpness of scheduling, not correctness.

Boot

The persisted image.running_mode is a guess about a scene nobody measured, and nothing bounds how old it is — a camera that spent a year in a drawer boots carrying a year-old opinion about the light. So since 2026-08-22 boot does not adopt it. It measures, then asserts:

  1. Wait for the AE to converge (DN_BOOT_SETTLE_S, then DN_STABLE_N on-EMA samples, hard-capped at DN_STABLE_MAX_MS).
  2. Run one ordinary probe into the day pipeline — the same dn_switch, the same DN_PROBE_SETTLE_S, the same verdict and the same ISP readback gate a runtime probe uses. Day is the only optical state whose reading means something absolute (IR-cut closed, illuminator off).
  3. Read it against day_gain and assert the answer on the board once.

Cost, counted honestly: one switch_cmd invocation when the answer is day — the probe's own drive is the assertion — and two when it is night. On a board that comes up in its reset state (/run/thingino/ daynight_mode absent, IR-cut in the day position, LEDs off) the first of those moves nothing, and the second is the movement the camera actually needs.

That second call is what a dark-time reboot used to be missing: on 2026-08-22 five fleet cameras rebooted after dark, adopted night in software, and spent the night with the IR LEDs off, because the old boot path deliberately never called switch_cmd at all. Under this sequence that outcome is unreachable by construction — every boot ends in exactly one assertion of the mode it decided.

It also makes restart-equivalence — the property the design notes identified as the one every stuck-mode incident violated ("a service restart fixes it") — literal at t=0 for every boot, not only for a persisted night with boot_probe left on.

The boot probe never takes the silent route, cheap as it is. The silent probe divides a reading taken with our illuminator on by one taken with it off, and at boot nothing has established that it was ever on: a board in its reset state has the LEDs off, so the "lit" reading is not lit, r comes back near 1, and the verdict is whatever the AE reserve happens to say. In a dark room that lands on "pegged, therefore night" — the right mode, no assertion, all night. That is exactly how the desync above went unnoticed.

The boot probe also does not arm the repeating post-switch re-assert. The re-assert re-drives whatever image.running_mode currently says, and at boot that is still the persisted guess (only the board hook chain ever writes it, and it has just been asked to). Armed here it lands DN_REASSERT_MS in — right where the boot verdict is read — and pushes the stale value over the optics the probe just commanded. That is the incident a living room already paid for: the switch to day was overwritten twice, eight seconds apart, and the room stayed in night mode through daylight. The boot decision's own switch arms its own re-assert normally.

What the boot probe does do like any other probe is keep its pre-probe level as the night-reference candidate, and charge probe_min_gap_s. Both were tried the other way and the corpus refused them. Deferring the anchor to the ordinary DN_REF_DELAY_S window lets it land inside a lighting transition — scenario 02's dip to 1200 against a resting 3500 anchors a bar of 600 that a genuinely lit scene at 900 can never clear, i.e. incident f8a7b21. Exempting the boot probe from the ration lets that same transient fire a second probe 24 s later, whose revert re-anchors on the dip: the same incident through a different door. A reference that lands too high costs one self-correcting probe; one that lands too low costs the trigger outright.

boot_probe=0 opts out of the measurement, not the assertion. The persisted value is taken on trust, as it always was under that setting, and then asserted on the board once. A railed AE (0 units of reserve) overrides the opt-out and measures anyway: a pegged meter reads a clip rather than a level, re-asserting running_mode is a documented no-op on an ISP that came up mistuned, and only a real transition re-tunes it (T23, measured 2026-08-21: day and back read a factor 23 lower within 12 s of each leg). With boot_probe=1 the measurement is that transition, so the case is subsumed rather than special-cased.

Fallback. If no usable exposure reading has appeared within DN_STABLE_MAX_MS of start-up (no ISP file, a wedged /proc dump), there is nothing to measure. Boot then falls back to the persisted value — and still asserts it on the board once — with a WRN that says so (boot: no exposure reading <N>s after start (<path>) - adopting persisted <mode>). Silently behaving like a normal boot is how a stale value acquires authority it never earned.

Learning (removed 2026-08-22)

An earlier version of this automaton recorded each confirmed day's lowest D and, with daynight.learn=1, let the median of the last 8 raise the effective day_gain when the configured value turned out to be unreachable — the failure that had three cameras stuck in night on 2026-08-16. In practice its own safety clamp (never raise the threshold past night_gain/2) could not raise it far enough for the cameras that actually needed it, so the config surface (daynight.learn, daynight.state_path) was removed rather than kept as dead weight. daynight.diagnose_thresholds covers the same failure mode today: it names the value to raise day_gain above instead of trying to raise it automatically.

Changes in v1.12.0

  • Looks ask only near day. Whatever the bar, a look asks for a check only when k × dark is within 4 × day_gain. After a railed check the bar was 0.85 × dark, which says nothing about the distance to day: at dawn the colour trigger asked at 19 × day_gain and each look cost a click pair.
  • A darker but recoloured scene looks. When the lit night picture gets darker while its colour moves beyond color_pct, timps starts a colour look instead of adopting the darker scene. A lamp switched on while other cameras' infrared in the room went off left a camera in night until the 6 h timer.
  • heartbeat_max_s 18 h (was 12 h): a winter night is longer than 12 h, so the guarantee check failed every morning before dawn on every camera.
  • Looks that cannot find day are skipped. Without the illuminator the scene reads no brighter, so when the lit level × 0.9 is already at or above the bar the look is skipped: the illuminator stays on, the lit level becomes the reference and the timer starts over. Log (debug): look (<why>) skipped: lit L, bar B. Timer and requested looks are never skipped, nor a look that only learns the bar (after a check without a look of its own, such as the boot or guarantee check).
  • Creep or lamp from a 10 s step. A lamp steps the lit level by more than 15 % from one 10 s sample to the next (within the last minute); otherwise the light creeps (dawn, a fire). A creeping look, also a colour look while the lit level is below the reference (brighter), is judged with the jump bar and, within 1 h of a failed check, with day_gain / k of that check. Before, only a creeping jump got the failed check's k, and colour looks used day_gain.
  • Near-bar look. A creep close to the bar moves less than jump_pct before the dark reading would beat it. When the last look's dark reading scaled by the lit level now (dark × lit / reference) predicts a dark reading under 0.95 × the bar, a look runs after the usual 20 s hold (look (near)). cam-wohn lost 60 min of day to this on 2026-11-07.
  • No lamp as the reference after a check. When the night reference is taken from the next settled level after a check (also at boot, and through the look that learns the check's bar) and that level is jump_pct brighter than the lit level before the check, it is not adopted: the earlier level stays the reference, so the lamp is a jump and looks.
  • A near miss waits for the AE. A check that reads under 1.5 × day_gain waits the 10 s day hold before it is called night (the AE is still settling); before, only a day reading waited.
  • A railed check sets no k. A check with the AE at its limit or above 4 × night_gain says nothing about k and no longer sets the failed check's k for the next hour.
  • Measured: replay corpus 77 pairs, 0 failed (six new families and profiles from the measured 2026-11-07 dawns of cam-wohn and cam-schuppen); a fleet what-if over 121 mornings: failed morning checks 178 → 94, day on average 5 min later, about half of the morning looks skipped. Background: Day-Night Design Notes.

Changes in v1.11.0

  • Jump bar from what failed. Every look → check pair teaches k (day reading / dark look reading); the dark readings of the last failed checks are kept across jumps. A jump look asks again only below 0.9 × the lowest failed dark reading (at most 2 × day_gain, at least day_gain / k).
  • Light that creeps in (dawn with daylight infrared, k 4-5; a fire) asks again within 60 min of a failed check only once that check's k predicts day; a sudden light (a lamp: the level moved more than 15 % within the minute before the jump) still asks at once when it pulls the dark reading below 70 % of the failed one. Measured: a bedroom dawn went from 6 to 3 failed checks. (v1.12.0 tells creep from lamp by a 10 s step instead, see above.)
  • A check's "day" must hold. "Night" is final at once; "day" only once the reading has stayed below day_gain for 10 s after the settle time. The open T40 driver reported the night-side exposure for about 14 s after the switch, so a dark check read "day" and the camera sat in day mode with its illuminator off, setting off every IR camera next to it.
  • Colour trigger (daynight.color_pct): a colour change of the lit picture starts a look; with it working, the timer look runs every 6 h.
  • Boot check at once when clearly dark, otherwise after at most 30 s.
  • A keyframe 0.8 s after every IR or IR-cut switch; day/night is held in day while an OpenIMP test pattern runs.

Anti-flapping guards

Three, all unchanged from the pre-redesign machinery, which the design notes correctly identified as the part that was working:

  • transition_s — minimum dwell between switches. A failed probe's revert bypasses it.
  • Confirmation windows — day_confirm_s for path A, probe_confirm_s for path C. Also keeps the SetISPRunningMode call from landing mid AE ramp, which is the stuck-mode class the latch section below covers.
  • Post-switch re-assert — DN_REASSERT_MS/DN_REASSERT_COUNT, plus a warning when running_mode never follows a switch (a lost POST from the fire-and-forget board hook chain, or a manual override).

Regression corpus

scripts/dn-replay.py --all scripts/dn-scenarios drives a host-built timpsd-sim through synthetic two-pipeline traces on a virtual clock. Fifteen scenarios, one per historical incident plus the two the redesign added, each asserting a mode timeline, a click budget, a maximum continuous wrong-mode run, restart-equivalence and probe-time monotonicity.

Two of them exist as a pair and should stay that way:

  • 09-dawn-ramp feeds no integration-time channel, so the exposure index degrades to bare gain and its night curve floors at 260 — a sensor pinned at the gain floor. Path C is structurally exhausted exactly as dawn completes, and recovery costs one heartbeat_s. That is the honest bound of the degraded path.
  • 15-dawn-ramp-exposure-index is the same dawn with the integration-time channel. The scene keeps producing evidence, path C fires on its own, and day is confirmed within minutes — with no extra rule, timer or state.

The difference between the two is the entire argument for changing the metric, written as an assertion rather than a claim.

The ISP running-mode latch quirk and fs_kick_chn0()

Even with all of the above working correctly, one more hardware quirk remained and was root-caused on a real deployed camera (cam-L Y4, T23/sc2336) on 2026-08-01: the ISP core only processes a queued SetISPRunningMode change while FrameSource channel 0 is actively delivering frames.

With chn0 idle-stopped — the normal state when nobody is watching the main stream, per timps's on-demand encoding design — IMP_ISP_Tuning_SetISPRunningMode() still returns success, but the driver silently leaves the change queued indefinitely. This happens even while a different, always-scaled substream channel (chn1) keeps streaming the whole time: on the camera where this was found, the dusk day→night switch (plus both automatic re-asserts) all reported success, /proc/jz/isp/isp-m0 kept reporting "Running Mode: Day," and the always-on substream showed a visibly magenta, IR-lit image all evening.

The fix, in src/hal/hal_ingenic.c (fs_kick_chn0(), state machine in src/hal/fs_ref.h): after every running_mode apply, and once per request that changed hflip/vflip, the HAL makes sure FrameSource channel 0 runs:

  • chn0 idle: enable it (a real EnableChn edge, which also re-applies hflip/vflip/running_mode), let it run 500 ms (roughly 12 frames at 25 fps, generous margin over the 1–2 frames empirically needed to latch the mode), then return. The kick keeps its reference for another MS_IDLE_STOP_US (2 s) and the idle producer loops release it, so chn0 is disabled about 2–3 s after the kick instead of right after the 500 ms.
  • chn0 already running (a client, the motion pin or a previous kick): no edge and no wait.
  • chn0 kept alive (general.fs_keepalive, default 1 on every SoC since v1.12.0; up to v1.11.1 the default auto was on only on open-stack T23/T40/T41): the kick does nothing at all; chn0 has been running since start, so the Set latches on the next frames (assumed, not yet measured on that stack). The kick only takes (or extends) the same 2 s hold, so the channel cannot idle-stop before the Set has latched.

Up to v1.9.33 the kick dropped its reference right after the 500 ms, even when chn0 was already running (and then still waited 500 ms). That made the end of every kick a DisableChn, and at start-up the next thing that happened was the first timelapse shot, an EnableChn a few milliseconds later. A disable followed by an enable within a few hundred milliseconds while another channel streams is the pattern that hangs open-stack T23/T41 SoCs (dev_notes/FS_KEEPALIVE_2026-10.md). On the vendor SoCs the latch behaviour is unchanged: chn0 still runs at least 500 ms after an idle-time Set, now longer. What differs is that chn0 is disabled 2–3 s later, and that a consumer arriving inside those seconds finds it running instead of re-enabling it.

This means the return code of SetISPRunningMode() is now also checked and logged (LOGW on failure) rather than ignored outright, and a diagnostic (debug-level only) read-back via GetISPRunningMode is logged, with the caveat that this readback is a libimp userspace value (there is no kernel read path on T23) and so isn't by itself proof the pipeline actually latched — it's fs_kick_chn0() that provides the actual fix.

Machine-readable log lines

Two lines are deliberately fixed-format, columnar key=value text: fleet dashboards grep for them, and a reworded sentence would empty their count silently, the same failure the 2026-08-17 hand-run exposure campaign had when its script quietly stopped being called. Both use -1 for "not measured", the same convention as the GET /control status fields, and both avoid spaces inside a value.

Every switch, at LOGI on the same line as the human-readable reason:

switching to night (heartbeat): daynight night [mode=night exp=6182 ref=-1 bar=768]

The [...] tail: mode is the mode being switched to (day/night); exp is the exposure index at the moment of the switch; ref is the night reference at that moment (-1 when not currently in night, e.g. a day→night switch); bar is the day threshold (day_gain) — the same value on every switch line regardless of direction, since it is read once per tick rather than chosen per branch.

Every silent probe, once per verdict, at LOGD, at the point where all branches have decided:

probe: r=1.05 lit=3350 dark=3520 hr=199 verdict=day mode=night ref=12098 why=brightening

r is the IR ratio (dark / lit, -1 if the probe got no usable reading); lit/dark are the two raw readings compared — with the illuminator on and off — the same pair the hand-run campaign used to collect by script (-1 alongside r=-1); hr is the AE headroom in log2 units at the dark reading; verdict is day, night, or escalate (inconclusive — the audible probe was asked for instead); mode is the automaton's mode at the time of the probe; ref is the night reference (-1 outside night); why is the trigger that asked for this probe (jump, trend, heartbeat, boot measure, requested, …) with spaces replaced by underscores, since a key=value value can't carry one; lit_hr (appended 2026-08-21) is the AE headroom at the lit reading, the number that decides whether r compared two measurements or two clips.

Since 2026-08-20 this line is LOGD — a measurement, not an event (see Logging). A collector that counts or plots it needs

general.debug_modules = daynight

in /etc/timps.conf; without it the line simply stops, and a counting dashboard reads "no probes" where it should read "not logged". The switching to line above stays at LOGI unconditionally.

Idle-Dark (IR LED off while nobody watches)

Since v1.10.0, off by default: daynight.ir_idle_off = 1, daynight.ir_idle_linger_s (default 60 s, 0..3600), both live via /control. WebUI: Day / Night → Settings → "IR light when idle".

What it does

At night the IR LED normally burns all night, whether anyone looks or not. With Idle-Dark, once no picture consumer has been there for ir_idle_linger_s, timps switches only the IR LED off. The IR-cut filter stays open and the ISP stays in night mode, so nothing clicks and no colour switch happens. Picture consumers are video and JPEG subscribers (RTSP, WebRTC, fMP4, MJPEG, SRT, /snapshot.jpg, timelapse, the recorder), motion detection's FrameSource and a set jpeg.snapshot_path. Audio-only clients do not count. A consumer inside the linger is not an event; the linger covers 10 s snapshot polling and DESCRIBE→PLAY. Keep it longer than timelapse.interval_s: with both at 60 s the linger can run out on the tick before each shot, and every shot lights the LED once (set e.g. 90 s).

Requirements

  • FrameSource keepalive (general.fs_keepalive; on by default since v1.12.0, auto = on only for OpenIMP builds on T23/T40/T41). Without it the AE stops when the last consumer leaves and nothing can be measured while idle. With 0, or with auto (also a misspelt value) on other SoCs, it is off; Idle-Dark is untested outside OpenIMP T23/T40/T41.
  • A switchable illuminator: daynight.irprobe_cmd (default timps-irprobe) with the verbs idle (LED off) and state (prints on/off). Firmware must ship the new hook (thingino ciao from commit 67fbc42e9, 2026-10; the failsafe below from 3af7d99e4). The illuminators it switches are the ones ticked in daynight.controls (IR 850/940 nm); a ticked white light makes it refuse, because it would light every reading.
  • Automatic switching by light level: daynight.enabled = 1 and daynight.mode = auto. Calendar mode (schedule) hands the LED back.

Why off by default

With 850 nm LEDs the red glow is visible. When it goes out, anyone in front of the camera can tell that nobody is watching or recording - "IR off" means "nobody is watching". The first seconds of a new stream are also darker, and the decision path has not yet been measured on enough cameras (see the measurement list in dev_notes/DN_IDLE_DARK_2026-10.md). For 940 nm or indoor cameras the tell does not matter.

How it decides, in plain words

  1. Going dark. At night, in auto mode, after the boot measurement, with no probe in flight, at least 5 s after the last switch and with the linger over, timps runs <irprobe_cmd> idle. The next 8 s are a silent probe that does not relight: a large drop (ratio ≥ 2) confirms night, a small one with exposure reserve left means the room is lit (day, one switch, nobody watches). Anything unclear stays night and dark. An unwatched boot whose silent probe proves night goes dark straight away.
  2. Measuring while dark. The IR-cut is still open and the LED off, so the AE now reads the ambient light without the LED ("noir"). timps keeps that reading; the LED is re-asserted off after 8 s, 16 s and then every 10 min in case the board script lit it. Each re-assert also renews the hook's 15 min failsafe (step 7).
  3. Freshness check. If the exposure has not moved at all in the 8 s after the LED went off, the AE is not running without a consumer on this SoC/stack. timps logs a WARN, turns the LED back on and does not try again until the next day/night switch.
  4. Dawn without a viewer (noir day rule). Noir below day_gain for probe_confirm_s, and the hook's state says the LED really is off → switch to day. Noir is optimistic (the filter is open), so if the day optics turn out too dark, path A switches back within ~30 s and timps learns how much darker the scene gets with the filter closed (noir_cost); that veto stops the same false day the next morning.
  5. A viewer arrives (wake). The subscribe kicks the day/night thread (not the next 2 s tick): the LED goes on at once (one hook call). If the noir reading is valid, already below day_gain and state confirms the LED is off, timps switches to day instead and the LED stays off.
  6. Warm-up hold. After a wake, JPEG grabs (/snapshot.jpg, timelapse) wait for the AE for ~3.5 s, at most 5 s. Streams are not held.
  7. Giving the LED back. ir_idle_off = 0, daynight.enabled = 0, mode = schedule, a failing re-assert and a timps stop all turn the LED on. If timpsd dies while the LED is off (SIGKILL, OOM, a crash), nothing in the firmware restarts it. The hook's idle arms a 15 min watchdog that each re-assert renews; once timps stops renewing it, it relights the LED, unless the board has switched to day meanwhile. The picture is therefore dark for at most ~15 min after the last re-assert, and the camera stays without a streamer until S95timps start; that start's boot probe then decides afresh. A hook without the watchdog (firmware before 3af7d99e4) leaves the LED off until that start.

What the user sees

  • The IR glow goes out about ir_idle_linger_s after the last viewer.
  • A new viewer: LED on within one hook call; the first 1-4 s of video are dark or overexposed while the AE settles on the LED.
  • /snapshot.jpg after a dark period answers up to ~3.5 s (max 5 s) later than usual. Home Assistant's default timeout is 10 s.
  • WebUI chip: "IR off (idle)", "IR on · watched", "IR on · off in N s", "IR on · going dark", "ready (day)", "off" or "not available: …". The page's own preview counts as a viewer.

Trade-offs

  • The 850 nm tell above.
  • First-snapshot delay after every wake; a poll slower than the linger pays one LED on/off pair and one held snapshot per poll.
  • Heartbeat probes in the dark are audible (every heartbeat_s / heartbeat_max_s, 4-18 h by default): a silent probe cannot work while the LED is already off, so the heartbeat uses the IR-cut probe.
  • Cameras with a permanent consumer (Frigate/NVR, record.enabled, motion.enabled, jpeg.snapshot_path) never go idle: no effect, no cost.

Troubleshooting: daynight.ir_idle.reason

GET /control → daynight.ir_idle = {cfg, available, reason, dark, watched, linger_left_s}; caps.daynight.ir_idle says whether this start can use it at all (keepalive).

reason Meaning Fix
disabled ir_idle_off = 0 (cfg 0), or daynight.enabled = 0 turn the switch / automatic switching on
schedule daynight.mode = schedule "Decided by: Light level" (mode = auto)
keepalive no FrameSource keepalive on this start (one WARN) general.fs_keepalive = 1 (the default since v1.12.0; check for 0 or a typo, which parses as auto: on only for OpenIMP T23/T40/T41) + restart
no_illuminator daynight.irprobe_cmd empty, or the illuminator was retired after two failed switches set the hook, tick IR 850/940 nm, check the board's IR GPIOs; a restart re-tests
white_light the hook refused idle (exit 3): white light is in daynight.controls untick "White light"
hook idle exit 1/2 (hook without the verb, nothing switchable) or repeated failures update the firmware's timps-irprobe; switching the key off and on retries

available = 1 but the LED never goes out: someone is watching (watched = 1, check GET /control?clients=1, motion detection, the recorder), or the log has exposure did not move in 8s with the illuminator off (the AE stops without a consumer here: leave the feature off on this camera). The WARN lines are listed in docs/ai/troubleshooting.md §3.9.

How to turn it off

POST {"daynight":{"ir_idle_off":0}} (or the WebUI switch): the LED comes back on at once and the key is persisted.

Live control and status

Action Effect
POST /control {"daynight":{"enabled":false}} Switches to manual mode; the thread keeps sampling for status but stops forcing switches.
POST /control {"daynight":{"mode":"schedule",...}} Switches decision source; validated against auto/schedule (and the legacy sensor/time/sun spellings) before being applied.
POST /control {"image":{"running_mode":1}} Manually forces the ISP mode (what the board switch script itself calls). Re-posting the same value still re-drives the ISP (see HTTP /control API Reference) — necessary precisely because of the latch quirk above.
POST /control {"daynight":{"probe":1}} Arms one look (IR illuminator off 8 s) for the next tick, without waiting for a jump, a colour change or the 45-min timer. A command, not a setting — counted in accepted/rejected like record.clip. Rejected when there is no daynight.irprobe_cmd configured or looks were turned off for the session after two hook failures.
GET /control "daynight" object Read-only status: enabled, mode (0 day/1 night), brightness (%), total_gain (IMP [24.8] linear), exposure (the index the decision actually runs on — plot this one when diagnosing), ae_luma, night_baseline (the illuminator-lit level at the last look) and day_trigger (the bar a look must read below to ask for a check; both always published since v1.10.1), since v1.11.0 color_trigger (on, off = color_pct 0 / no irprobe_cmd / schedule / disabled, unavailable = no AWB statistics in this libimp) and color_dev_pct (colour change of the lit picture since the last look in %, -1 without a reference), the configured keys, the Idle-Dark state ir_idle (cfg, available, reason, dark, watched, linger_left_s, see above), and — always — today's computed sun_computed_sunrise/sun_computed_sunset.
GET /events?stream=daynight Pushes the same status object on connect and then whenever mode flips, brightness moves ≥1%, or gain moves ≥5% relative (or ≥8 absolute near zero) — see HTTP /control API Reference.
GET /control?dn_history=1 The decision series (gain, exposure index, ae_luma, brightness, mode, one sample per 10 s) out of the in-RAM ring daynight.history_s sizes — off by default. Unlike the SSE stream this keeps accumulating with no client connected, which is what lets the WebUI tuning graph plot hours nobody was watching. Cursor-paged; see HTTP /control API Reference.

See Configuration Reference for the full key table (including which fields are settable via /control vs. config-file-only).