Cold-start anti-cycling bug — kotel zaseklý forever
Heating Fáze A šla live ráno 21.4. Kotel se sepnul, odběhl 5 min, vypnul.
Pak nikdy nesepnul znovu. EventLog hlásil každé 2 minuty
hold_off_180s_left, kde 180 bylo počítáno
z time-since-last-on a se neposouvalo dolů.
Anti-cycling guard byl pojištěný proti rychlému spínání kotle (ochrana stykače).
V podstatě fungoval, ale měl edge case na cold start — kde
min_offtime_s nikdy neexpiroval. Tady je 4 hodiny diagnostiky.
Symptom
Kotel sepnul jednou, ohřál koupelnu na cílovou teplotu (cca 5 min), pak vypnul dle hystereze. Tady end-of-story — od té chvíle už nikdy nesepnul, i když teplota klesla daleko pod cílovou.
EventLog z demand cronu každé 2 min:
06:14:00 demand=on jidelna=21.0/21.0 koupelna=22.1/22.0 → boiler_request=on
06:16:00 demand=on ... → boiler_request=on
06:21:00 demand=off jidelna=21.0/21.0 koupelna=22.0/22.0 → boiler_request=off
06:23:00 demand=on jidelna=20.4/21.0 → hold_off_180s_left=180
06:25:00 demand=on jidelna=20.3/21.0 → hold_off_180s_left=180
06:27:00 demand=on jidelna=20.2/21.0 → hold_off_180s_left=180
... (forever)
hold_off_180s_left count down nepokračoval. Kotel forever held off,
jídelna pomalu klesala 21 → 20 → 19 → 18 °C. Bez intervence by klesla na anti-freeze
7 °C (kde by safety bypass donutil kotel sepnout).
Anti-cycling guard logika
sh_heating_demand_v1 v1.1 měl tento check:
// v1.1
const sinceTs = parseInt(await getVar('sh_heating_boiler_since_ts'), 10);
const elapsed = Math.floor((Date.now() - sinceTs) / 1000);
const minOfftimeS = parseInt(await getVar('sh_cfg_heating_min_offtime_s'), 10);
if (boilerState === 'off' && elapsed < minOfftimeS) {
return {
request: 'off',
reason: `hold_off_${minOfftimeS - elapsed}s_left`
};
}
Logika byla: pokud je kotel off a od posledního off uplynulo méně než
min_offtime_s (default 180 s = 3 min), drž ho off pro
ochranu stykače.
Edge case — cold start
sh_heating_boiler_since_ts byl při čerstvé instalaci skriptu
nezapsaný / undefined. parseInt(undefined) = NaN.
Date.now() - NaN = NaN. Math.floor(NaN / 1000) = NaN.
A teď klíčový moment: NaN < 180 v JavaScriptu vyhodnotí
false. Takže během prvních 5 min běhu (kdy kotel poprvé sepnul)
byl else branch a všechno fungovalo.
Ale po prvním off transition (kotel vypne dle hystereze) se zapsal
since_ts = 0 nějakou shadow logikou (debug log dal najevo —
jeden execution path nesetoval since_ts při off transition correctly,
a default value byl 0 z initialization).
// since_ts = 0
elapsed = Math.floor((1697123456000 - 0) / 1000)
= 1697123456
// 1697123456 < 180 ? FALSE!
// Wait, that's not the bug...
Hmm. 1697123456 < 180 je false, takže by guard NESPÍCHL.
Skript by měl pokračovat normálně.
Skutečný root cause
Po dalším debug logu jsem zjistil, že problém nebyl elapsed < minOfftimeS,
ale jiný kus kódu níže:
// Safety cutoff
if (sinceTs > 0) {
// Validation runtime checks
...
}
// Anti-cycling check (the actual guard)
if (boilerState === 'off') {
const safeElapsed = sinceTs > 0 ? elapsed : 0;
if (safeElapsed < minOfftimeS) {
return {request: 'off', reason: `hold_off_${minOfftimeS - safeElapsed}s_left`};
}
}
Tady je defensive coding — kdyby sinceTs bylo 0 nebo negative
(chyba v init), použít safeElapsed=0 → fall through to guard.
0 < 180 = true → guard spíchne → return hold_off.
Logika měla být: „pokud sinceTs neplatí, nech kotel sepnout (skip guard)". Místo toho: „pokud sinceTs neplatí, treat as right-now (most conservative) → guard always blocks".
Fix v1.2
// v1.2 — explicit cold-start escape
if (boilerState === 'off') {
const safeElapsed = sinceTs > 0
? elapsed
: Number.MAX_SAFE_INTEGER; // ← cold start: bypass guard
if (safeElapsed < minOfftimeS) {
return {request: 'off', reason: `hold_off_${minOfftimeS - safeElapsed}s_left`};
}
}
Klíčová změna: sinceTs > 0 ? elapsed : Number.MAX_SAFE_INTEGER.
Cold start = pretend infinite time elapsed → guard never blocks.
Plus opraveno všude jinde v kódu (3 více míst), kde sinceTs > 0
check byl použit jako gate s nesprávnou default behavior.
Test
Reset všech heating state vars (simulace cold start), spustil demand cron ručně:
06:30:00 (cold start) demand=on jidelna=18.0/21.0 → boiler_request=on
06:30:00 since_ts=<set>
06:32:00 demand=on jidelna=18.5/21.0 → boiler_request=on (still cold)
...
06:45:00 demand=off jidelna=21.0/21.0 → boiler_request=off (target reached)
06:45:00 since_ts=<updated to off transition>
06:47:00 demand=on jidelna=20.4/21.0 → hold_off_60s_left (legitimately cooling down)
06:48:00 demand=on jidelna=20.3/21.0 → boiler_request=on (180s elapsed)
Test passed. Cold start nepoznán jako anti-cycling violation, normal cycling po prvním off chová se normálně.
Co se naučilo
„Default safe" často není default safe. Defensive coding který předpokládá worst-case může přidat své vlastní bug class — guard který „raději zablokuje než propustí" může vést k systémové paralýze.
Lessons z tohoto bugu:
- Edge case 0 / undefined / NaN. Vždy explicitní handling, ne
implicit fallthrough přes JS coercion.
NaN < 180= false je gotcha, na které jsem narazil. - Cold start = bezpečnostní bypass. Při cold startu mají guards z definice „nedostatek dat" — defaultní chování by mělo být „let through on first run", ne „block all".
- Safety cap orthogonality. Jiný safety guard (max_runtime_s 4h forced off) by tady ne pomohl — kotel byl off, ne on. Layered safeguards potřebují krýt všechny paths, ne jen on-path.
- EventLog je můj nejlepší přítel. Bez detailních logů by
tento bug byl invisible — Homey UI ukazuje kotel off, vše vypadá OK.
Insight přišel z EventLog grep
hold_off_*.
Bug byl třídeným u kategorií „state machine cold start" — který se v softwaru objevuje typicky jednou za projekt a pak nikdy. Tady ale dům, který se odehraje každé ráno znovu, má constant cold-start exposure (po každém power outage, po každém deploy, po každém Homey restart).
Defense in depth: sh_brain_guardian_v1 (V1 LIVE od 26.4.) má
invariant „pokud kotel forever held off + zóna pomalu klesá → alert".
Tahle metavrstva by stejný bug zachytila během 15 min, i kdyby fix v1.2
neexistoval.