FMA-kovennus

Tämä liite kokoaa Osa 15:n syvätekniset yksityiskohdat: vikatilojen tarkat kuvaukset, korjausten koodit, commit-tunnisteet ja todennustavat. Itse artikkeli kertoo tarinan ja periaatteet; tämä liite on tarkoitettu lukijalle, joka haluaa nähdä, mitä koodissa todella tapahtui, tai joka tekee vastaavaa työtä omaan järjestelmäänsä.

Järjestelmäarkkitehtuuri lyhyesti: Home Assistant (integraatiokerros), Node-RED (optimointimoottori), MQTT/Mosquitto (viestiväylä), InfluxDB (historia), Grafana (visualisointi). Node-RED tekee päätökset; Home Assistant toteuttaa ne porttina laitteille. Viisikerroksinen datamalli: fyysiset laitteet → raakasensorit → energyhub_*-abstraktioentiteetit → Node-RED-optimoija → toimilaitteet.

FMA:n rakenne

17 vikatilaa (F1–F17), seitsemän erää. Kolme perusvikaluokkaa:

  1. Stale retained -viesti (zombie-latch)
  2. Hiljainen epäonnistuminen (silent failure)
  3. Kahden omistajan ristiriita (F7)

Toteutuksen kuri: jokainen korjaus oma commit, oma testi, kaatuva muutosskripti (sys.exit(1) jos kohdetekstiä ei löydy), ChatGPT-katselmointi. Toteutusloki: Era4_toteutus.md.

Vikaluokka 2 — hiljainen epäonnistuminen, esimerkkikorjaukset

F4 — cheap_slots-tyyppisuojaus

Alkuperäinen laskenta käytti || 36 -suojaa, joka suojaa nollalta mutta ei negatiiviselta eikä NaN-arvolta. Tyhjä hintalista johti tilaan sorted[0] = undefinedthreshold = undefinedprices[x] <= undefined aina false → EV ei lataudu halvalla, hiljaa.

Korjaus erottaa kolme tilannetta — virheellinen arvo, tyhjä lista, validi laskenta:

javascript

const rawCheapSlots = Number(global.get('m_cheap_slots'));
const cheapSlotsN = (Number.isFinite(rawCheapSlots) && rawCheapSlots > 0)
  ? Math.floor(rawCheapSlots) : 36;
const sortedPrices = [...pricesNow].sort((a, b) => a - b);
s.cheapThreshold = sortedPrices.length > 0
  ? sortedPrices[Math.max(0, Math.min(cheapSlotsN - 1, sortedPrices.length - 1))]
  : (global.get('m_cheap_threshold') ?? null);

Lisäksi isCheapNow / isCheapNext tarkistavat cheapThreshold !== null, jottei null johda virheelliseen vertailuun. Commit 82ffffb.

Tärkeä yksityiskohta defaulteissa: käytä ?? (nullish coalescing), älä koskaan ||, parametrien oletusarvoissa. || kohtelee nollaa epätotena, mikä on väärin numeerisille parametreille, joiden legitiimi arvo voi olla 0.

F6 — päivätilan nollaus päivämääräpohjaisesti

Ajastettu nollaus klo 00:05 jäi väliin, jos Node-RED oli alhaalla tasan silloin. dhw_heated_today ja floor_heated_today jäivät edellisen päivän true-arvoon → ei lämmitystä seuraavana päivänä, hiljaa.

Korjaus: päivämääräpohjainen nollaus joka tarkistetaan jokaisella resolver-syklillä, sijoitettuna Priority Resolverin alkuun ennen emergency-haaraa. Kriittinen yksityiskohta: jos nollaus olisi emergency-haaran jälkeen, return msg emergency-tilassa estäisi päivänvaihdon nollauksen. Nollaus on pelkkää tilanylläpitoa (ei fyysistä ohjausta), joten se on turvallista tehdä ennen emergency-tarkistusta.

javascript

const today = s.dateFi || '';
const lastDate = global.get('eh_daily_reset_date') || '';
if (today && today !== lastDate) {
  global.set('dhw_heated_today', false);
  global.set('dhw_failed_today', false);
  global.set('dhw_cancelled_today', false);
  // ... floor_heat_state, floor_heated_today, m_comfort_wheel_last_cmd
  global.set('eh_daily_reset_date', today);
}

s.dateFi lasketaan Suomen ajassa Intl.DateTimeFormat-pohjaisesti. Yhteinen eh_daily_reset_date nollaa sekä DHW:n että floorin. Selviää restartista, koska tarkistus on joka syklissä eikä kerran vuorokaudessa. Commit cb155ec.

F10 — DHW retry-suoja ja keskeytyksen erottelu

Boost-timeoutin laukaistessa asetettiin vain dhw_boost_active = false, mutta trigger-ehdot (aika, lämpötila, vienti) jäivät voimaan → boost käynnistyi heti uudelleen. Korjaus erottaa kolme lopputulosta:

  • Timeout → dhw_failed_today = true (estää uuden yrityksen samana päivänä)
  • Ulkoinen keskeytys (capability poistui / legionella alkoi) → dhw_cancelled_today = true
  • Trigger-suojat: !dhwFailedToday && !dhwCancelledToday && !hpLegionellaActive

Keskeytystarkistus on ongoing-boost-haaran ensimmäinen ehto, joten boost ei jää päälle vaikka tilanne muuttuu kesken jakson. Commit a5bb42b.

F8 — comfort wheel readback-vahvistus

m_comfort_wheel_last_cmd päivitettiin lähetyshetkellä, ei vahvistushetkellä. Jos Thermia ei ottanut komentoa vastaan (Modbus-virhe), last_cmd sanoi silti ”lähetetty” → ei uudelleenlähetystä → comfort wheel jäi väärään arvoon, hiljaa.

Korjaus on pending/confirmed-tilakone:

  • target = Priority Resolverin haluama arvo
  • pending = lähetetty, ei vielä vahvistettu (arvo + aikaleima)
  • readback = Thermian todellinen Modbus-arvo (m_hp_comfort_wheel_readback)
  • last_confirmed = viimeksi vahvistettu (readback vastasi, deadband 0,5 °C)
  • retry 5 min jos pending ei vahvistu

Nimi last_cmdlast_confirmed, koska vanha nimi houkutteli sekoittamaan lähetetyn ja vahvistetun. Commit 97612c8.

Vikaluokka 3 — kahden omistajan ristiriita (F7) ja sen kerrokset

Tämä on artikkelin ”sama virhe kolmessa kerroksessa”. Kronologia:

Kerros 1: moodin kaksoisomistus — commit cc4cd49

sys_operating_mode kirjoitettiin kahdesta lähteestä nodessa ”Päivitä flow-konteksti”: energyhub/system/mode (oma topic) ja energyhub/system/capabilities.payload.mode (koontiviestin mode-kenttä). Kolme lukijaa luki samaa muuttujaa. Ristiriidassa viimeksi saapunut viesti voitti → epädeterministinen moodi.

Korjaus: system/mode on moodin ainoa omistaja. Perustelu: oma dedikoitu topic, julkaistaan retain: true, broker palauttaa retained-arvon käynnistyksessä → ei tarvita capabilities-fallbackia. Poistettiin rivi global.set('sys_operating_mode', payload.mode) capabilities-haarasta.

Kerros 1.5: HA-puolen kanoninen julkaisija — commit ad76dd8

F7:n puuttuva puolisko. HA julkaisi system/mode-topicin vain emergency-recoveryssa (arvolla normal). Normaali operaattorin moodinvaihto meni vain capabilities-viestiin, jota Node-RED ei enää lue moodille → moodi olisi jäänyt jumiin. Korjaus: uusi automaatio energyhub_publish_canonical_mode julkaisee energyhub/system/mode (retained) aina kun input_select.sys_operating_mode muuttuu.

Kerros 2: Node-RED-lukijan zombie — commit 610db7e

Sama F7-kuvio turvakatkaisussa. Node ”Päivitä flow-konteksti” luki capabilities-haarassa:

javascript

global.set('sys_safety_trip', payload.sys_safety_trip);

Kun vanha retained capabilities kantoi sys_safety_trip: true, se palautti turvalukon globaaliin vaikka oma topic (energyhub/system/safety_trip) oli nollattu → zombie-latch, EV+EVU jäivät estoon. Korjaus: poistettiin rivi. sys_safety_trip omistaa vain oma topic.

Kerros 3: capabilities-payloadin julkaisu — commit 16ec467

Vaikka lukija oli korjattu, HA julkaisi yhä sys_safety_trip-kentän capabilities-payloadissa (40_optimization.yaml). Kenttä oli enää raportointia — kukaan ei ajanut sitä mooditilaan — mutta se rikkoi periaatetta ja jätti zombie-riskin tulevaisuuteen. Poistettiin kaksi riviä capabilities-automaatiosta: payload-kenttä ja vastaava trigger-rivi. Automaatiot mqtt_safety_trip_sync ja mode_emergency_recovery säilyivät koskemattomina — ne ovat oikea MQTT→HA-omistusketju.

Live-todennus: reload (Developer Tools → YAML → Reload Automations), c_pv_curtail_allowed flipattu pakottamaan tuore julkaisu, mosquitto_sub vahvisti ettei tuore capabilities-viesti enää sisällä kenttää.

Turvakatkaisun kolme juurisyytä — commit 610db7e

Artikkelin huipentuma yksityiskohtina. Yksi tapahtuma (sauna + EV → ylivirta → trip), kolme estävää vikaa palautumiselle:

  1. Dead-code deadlock Sulakevalvonnassa: if (safetyTripActive) return teki auto-reset-haaran saavuttamattomaksi. Vapautuslogiikka oli ehdon jälkeen → ei koskaan ajettu niin kauan kuin trip oli päällä.
  2. Zombie-latch capabilities-MQTT:stä (kerros 2 yllä): vanha retained sys_safety_trip: true palautti lukon manuaalisen resetin jälkeen.
  3. Ennenaikainen vapautus: auto-reset lähetti ev_allowed: true välittömästi, mikä riski laukaista uuden tripin jos sauna oli yhä päällä.

Kaikki korjattu kaatuvilla Python-skripteillä (sys.exit(1)-turvatarkistukset) ja read-back-verifioinnilla. Manuaalinen reset-prosessi ilman SSH:ta dokumentoitu: MQTT energyhub/system/safety_trip → false (retained) + energyhub/command/mode → normal; HA Developer Tools input_boolean/input_select -flippaukseen.

EVU emergency zombie -juurisyy (sama tapahtumaperhe)

28 A:n L1-piikki → vanha HA sys_fuse_guard_trip -automaatio (automations.yaml) triggeröi input_boolean.sys_safety_trip → emergency mode retained MQTT:nä → Priority Resolver pakotti hpMode: block → SG1-rele jäi EVU-asentoon → zombie säilyi tripin selvittyä. Fix: vanha fuse guard -automaatio poistettiin, zombie tyhjennettiin mosquitto_pub:lla. Node-RED Safety Guardian -kynnykset vahvistettu: warn 30 A / reduce 36 A / trip 40 A.

Huomio releen semantiikasta: c_hp_evu_allowed capabilitiesissa on lupaflagi, ei releen ohjaussignaali. Todellinen HP-moodi tulee non-retained-topicista energyhub/command/hp/mode Node-REDin Command Layeriltä. EVU-jumi oli emergency-zombien oire, ei itsenäinen bugi.

Telemetrian validointi — commit 61fd3b4

Periaate ”rikkinäinen/puuttuva arvo ei saa naamioitua validiksi”. Pahin yksittäinen kohta oli const runningPriority = payload.running_priority ?? 0 — puuttuva arvo pakotettiin nollaan, jolloin === 3 (DHW) ja === 7 (legionella) menivät epätodeksi ja Thermian tila katosi hiljaa. 0 ei ole edes dokumentoitu Thermian tila.

Apufunktiot: ehNum (Number.isFinite-validointi), ehSetIfValid (aseta vain jos kelvollinen), ehBadCount (harvennettu diagnostiikka). Domain-kohtaiset säännöt: kelvoton power_w hylkää koko grid-päivityksen ja säilyttää edellisen (sulakevalvonta ja EV eivät saa väärää nollaa); tyhjä hintalista ei yliaja edellistä hyvää listaa; running_priority säilyttää edellisen tilan eikä pakota nollaan. Lisätty aikaleimat stale-telemetrian havaitsemiseen.

Komento vs. toteuma — F5 ja F17

Koko FMA:n läpileikkaava periaate: päätös perustuu mitattuun/vahvistettuun toteumaan, ei oletettuun komentoon.

F5a/F5b (commitit 40d1af8, bc68944): EV-päättely toteumaan. ev_relay_on = desired (komento), m_ev_relay_on = actual (mitattu Shellyltä). ev_on_since/ev_off_since perustuvat toteuman muutoshetkeen, jotta minRuntimeMs ja viiveet perustuvat todelliseen latausaikaan, ei komennon kaikuun. Boolean-normalisointi ehAsBool (hyväksyy true/’on’/’true’/1).

F17 (commitit 9225e79, 6587e85): päätös-vs-toteuma-havaintokerros. Yhteinen actuals-objekti Priority Resolverissa. EV-status: idle / charging / relay_on_no_power / decided_no_relay. DHW-status käyttää yhdistelmäehtoa, ei pelkkää immersionia (katselmoijan tärkein tarkennus):

running_priority === 3 AND (shelly_total_w > 800 OR compressor_pct > 5 OR immersion_active)

Debounce 120 s: poikkeama vasta kun boost on ollut aktiivinen yli käynnistysviiveen. actuals julkaistaan observability/decisions-kanavaan (MQTT + InfluxDB). Vain havaintokerros — ei muuta päätöksiä, ei lähetä komentoja. F17 vaihe 2 (aktiiviset reaktiot poikkeamiin) odottaa tuotantodataa.

Erä A — rajapintakorjaukset (staattisen katselmoinnin löydöt)

Katselmoijan staattinen arkkitehtuurikatselmus nosti esiin rajapintatason poikkeamia, jotka eivät olleet enää päätöslogiikassa vaan HA↔Node-RED-rajapinnassa ja aikalaskennassa.

Aikavyöhykebugit (A2, A3): kontti pyörii UTC:ssä, mutta Nordpool-hinnat ja Open-Meteo-ennuste ovat Suomen ajassa. getHours()*4 antoi UTC-tunnin → hinnat luettiin ~3 h väärästä slotista → väärät latauspäätökset. Korjaus: ehHelsinkiSlot()-apufunktio (Intl-pohjainen). Aikavyöhykebugit piiloutuvat useaan kohtaan; sama perhe löytyi neljästä eri paikasta.

Null-suoja väärällä kerroksella (A4): export_w:n null-suoja 40_optimization.yaml:ssa oli tehoton, koska lähdesensori (20_measurements.yaml) käytti sisäisesti float(0)-fallbackia → ei koskaan palauttanut unavailable-tilaa ylemmälle kerrokselle. Periaate: null-suoja pitää lisätä siihen kerrokseen, missä unavailable ensin syntyy tai katoaa, ei mihin tahansa kohtaan ketjussa.

Orpo entity_registry -merkintä (A5): sensor.m_hp_shelly_total_power_w oli rekisteröity core.entity_registry:ssä (platform: template) mutta toteutusta ei löytynyt mistään YAML-tiedostosta eikä .storage-helperista → pysyvästi unavailable, ei virhettä lokissa, täysin hiljainen vika. Tämä naamioitui aluksi ”InfluxDB-kirjoitus puuttuu” -ongelmaksi. Korjaus: lisättiin puuttuva template-sensori. Oma vikaluokkansa: rekisteri sanoo template, mutta toteutus puuttuu kokonaan.

Toteutustavasta opittua

  • Kaatuva skripti: kun flows.json-muokkaus tehdään merkkijonokorvauksella, skriptin pitää sys.exit(1) jos kohdetekstiä ei löydy. Muuten voi luulla tehneensä muutoksen, vaikka flow jäi ennalleen.
  • Erilliset commitit semantiikan muutoksille: käyttäytymistä muuttava korjaus pidetään erillään puhtaasta bugikorjauksesta.
  • git add -A on vaarallinen: aina eksplisiittinen git add <tiedosto>. Yhdessä tapauksessa -A veti takaisin 22 aiemmin poistettua .bak-tiedostoa, koska .gitignore ei kattanut kaikkia nimimuotoja.
  • Tarkista git log ennen kuin oletat työn olevan tekemättä: osa korjauksista oli jo tehty aiemmassa commitissa mutta jäänyt kirjaamatta lokiin → päällekkäistä analyysiä. git log -S "<hakusana>" (pickaxe) on nopea tarkistus.
  • Context-tiedoston tallennusviive: Node-REDin global.json päivittyy ~30 s viiveellä; heti restartin jälkeen luettu arvo voi olla vanhentunut. MQTT-state-kanava on luotettavampi live-mittari.
  • Commit-viestit: vältä !-merkkejä (bashin history-expansion).

FMA-tilanne

Kaikki 17 failure modea (F1–F17) käsitelty seitsemässä erässä, plus Erä A:n rajapintakorjaukset (A1–A6). F17 vaihe 2 (aktiiviset reaktiot poikkeamiin) ja capabilities-payloadin viimeinen siivous (16ec467) viimeisteltiin myöhemmin. Toteutusloki kaikkine commit-tunnisteineen: Era4_toteutus.md.

Piditkö artikkelista?

Seuraa blogia myös Blogit.fi:ssä, niin löydät uudet kirjoitukset helposti.

Seuraa blogia Blogit.fi:ssä