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.