/* settings-ev.jsx — Inställningsflik "Elbil & laster" TVÅ HELT OBEROENDE BLOCK (2026-06-15, oberoende-spec): • EvChargingCard — elbilsladdning, eget på/av (ev_charging.enabled). När AV (default): alla bil-fält dolda, bara rubrik + aktivera-knapp. "Pausa hembatteriet…" och kW/W- valet hör hit (meningslösa utan elbil). • DeferrableLoadsCard — skjutbara laster, läggs till/sparas HELT utan att röra elbil. Ingen klientvalidering som kräver bil-värden. EvLoadsCard = tunn wrapper som renderar båda (behåller export-namnet så settings.jsx är orört). Kontraktet oförändrat: ev_charging.enabled styr på/av; allt annat under ev_charging är meningslöst tills den är på. - ev_charging.{enabled, battery_kwh, max_current_a, min_current_a, phases, variable_current, departure_time, target_pct} — nested, backend-namn exakt. - deferrable_loads = [{id?, name, power_kw, duration_hours, earliest, latest, enabled, mode, max_parts}] — id sätts av backend; aldrig för nya laster. - ev_charger_power_in_kw, pause_battery_on_ev_charging — flata fält i elbils-blocket, GET/save-mappning oförändrad i settings.jsx. */ const EV_CHARGING_DEFAULTS = { enabled: false, battery_kwh: 0, max_current_a: 16, min_current_a: 6, phases: 3, variable_current: true, departure_time: '07:00', target_pct: 80, }; const DEFERRABLE_LOAD_DEFAULT = { name: '', /* 2,0 kW, inte 0 (2/9): backend validerar power_kw > 0 och avvisar HELA sparningen. Med 0 som default såg det ut som att namnet inte fastnade. */ power_kw: 2.0, duration_hours: 1, earliest: '09:00', latest: '21:00', enabled: true, // SPEC_LASTER_MODE_SPLIT §1 (2026-06-12) — backend-namn exakt. mode: 'consecutive', // 'consecutive' | 'split' max_parts: 2, // 2–8, relevant endast när mode='split' // SPEC_SOLAR_ONLY_LOADS (2026-07-23) — kör lasten ENBART i kvartar där // prognostiserat solöverskott täcker hela lastens effekt. Äkta bool. solar_only: false, // DESIGN_KRAVLISTA_lastknapp (2026-09-02) — Homey rapporterar lastens // verkliga effekt till HEMS. Äkta bool, default AV. reports_power: false, /* `existed_before` ÅTERKALLAD 6/9 (André) — se gc-last.jsx. Övergången mäts automatiskt; lägg inte tillbaka frågan. */ }; // Fönster i timmar mellan earliest→latest — utgått 2026-09-02. Körtiden // valideras nu per fönster i lastfonster.js (lwSpan/lwValidate), med samma // kvartsavrundning som backend. Funktionen hade inga läsare kvar. function DeferrableLoadsEditor({ loads, onChange }){ /* Fönsterlogiken (lastfonster.js) och fönstervyn (settings-lastfonster.jsx) är egna filer. Saknas någon vid deploy ska kortet DEGRADERA, inte ta ner hela inställningssidan med ett kast i första lw*-anropet. Vakten ligger före alla hooks — villkoret är konstant under sidans livstid. */ if (!window.lwFromLoad || !window.SettingsWindowList) { return (
Lastinställningarna kunde inte laddas — en delfil saknas i den här versionen. Läs in sidan igen; dina laster är orörda.
); } const upd = (i, patch) => onChange(loads.map((l, j) => j === i ? { ...l, ...patch } : l)); /* Fönsterlistan ersätter hela lastobjektet i stället för att patcha det: lwToPayload TAR BORT toppnivåns earliest/latest/duration_hours/solar_only/ mode/max_parts när windows skickas (spec §2), och en merge-patch kan inte ta bort nycklar. Normaliseringen sker först vid en verklig ändring — att skriva om dokumentet vid ren visning skulle smutsa ner formuläret. */ const setWins = (i, wins) => onChange(loads.map((l, j) => j === i ? window.lwToPayload({ ...(l || {}), windows: wins }) : l)); // Max 3 laster (2/9) — samma spärr som gc-last.jsx. const add = () => { if (loads.length >= 3) return; onChange([...loads, { ...DEFERRABLE_LOAD_DEFAULT }]); }; const remove = (i) => onChange(loads.filter((_, j) => j !== i)); /* Uppmätt energi (p5) — hämtas bara när någon last rapporterar effekt. */ const runs = window.useLoadRuns(loads.some(l => l && l.reports_power === true)); return (
{loads.length === 0 && (
Inga laster konfigurerade än — t.ex. varmvattenberedare eller poolpump. HEMS lägger körningen på dygnets billigaste timmar inom ditt fönster.
)} {loads.map((l, i) => { const wins = window.lwFromLoad(l); const winErrs = window.lwValidate(wins); const pwrBad = !(l.power_kw > 0); const solarAny = wins.some(w => w && w.solar_only === true); return (
{l.name || `Last ${i+1}`} {solarAny && ( ☀ Bara sol )}
upd(i, {enabled: v})}/>
upd(i, {name: v})}/> upd(i, {power_kw: v})} help={pwrBad ? Måste vara större än 0 — annars avvisas hela sparningen : 'Märkeffekt — används för säkringen även när HEMS planerar efter uppmätt energi'}/>
{/* Ett fönster per körning: veckodagar, tider, körtid, bara sol och delbarhet väljs PER FÖNSTER (spec §1). En apparat = en last med ett Homey-flöde, oavsett antal fönster. */} setWins(i, w)}/> {/* DESIGN_KRAVLISTA_lastknapp 2026-09-02 §1–3 — reports_power. Hjälptexterna VERBATIM ur kravlistan (AV-texten måste vara ärlig om att sparad förbrukning kan bli för låg — simulering 2/9 visade ~2× överskattning). Id-raden vid PÅ binder Homey-kortet mot rätt last; nya osparade laster har inget id än. */}
upd(i, {reports_power: v === true})}/>
); })} {loads.length < 3 ? ( ) : (
Max 3 laster.
)}
); } // ── Bilprofiler (SPEC_BILPROFILER_PER_BOX, 2026-07-30) ────────────────── // Bilar ≠ boxar: batteristorleken hör till BILEN, inte till laddboxen. // Khalil-fallet = två bilar, en fysisk box. Raderna kan vara _derived (härledda // ur boxarna för konton som aldrig sparat bilar) — de ser identiska ut och // materialiseras först när användaren rör dem. Backend ekar ev_cars permanent // sedan 2026-07-30; ingen lokal skugga, ingen pending-banner. function CarsEditor({ cars, chargers, onChange }){ const MAX = window.evModel?.MAX_CARS || 4; const upd = (i, patch) => onChange(cars.map((c, j) => j === i ? { ...c, ...patch } : c)); // §5.4: "Som laddboxen" = nyckeln UTELÄMNAS helt. Inte null, inte 0, inte "". // Därför delete i stället för patch med undefined. const setCarPhases = (i, v) => onChange(cars.map((c, j) => { if (j !== i) return c; const next = { ...c }; if (v === '1' || v === '3') next.phases = Number(v); else delete next.phases; return next; })); // Boxens fasantal är referensen i etiketten "Som laddboxen (N faser)". // Den box som laddar bilen fast gäller först, annars första boxen. const boxFor = (car) => { const list = Array.isArray(chargers) ? chargers : []; return list.find(ch => ch && ch.car_mode !== 'select' && String(ch.default_car_id || '') === String(car.id || '')) || list[0] || null; }; const add = () => { if (cars.length >= MAX) return; onChange([...cars, { ...(window.evModel?.CAR_DEFAULTS || {}), name: '', id: window.evModel?.nextCarId ? window.evModel.nextCarId(cars) : undefined }]); }; // Bil som en box laddar fast får inte försvinna under fötterna på boxen — // samma mönster som laddar-borttag: blockera med förklaring, radera aldrig // tyst (backend skulle falla tillbaka på boxens battery_kwh). const boundBy = (car) => (chargers || []).filter(ch => ch && ch.car_mode !== 'select' && String(ch.default_car_id || '') === String(car.id || '')); return (
Bilar Batteristorleken hör till bilen — boxen är bara uttaget
{cars.map((car, i) => { const bound = boundBy(car); const blocked = bound.length > 0 && cars.length > 1; const battBad = !(car.battery_kwh > 0); const box = boxFor(car); const boxPhases = Number(box && box.phases) === 1 ? 1 : 3; const own = Number(car.phases) === 1 ? 1 : Number(car.phases) === 3 ? 3 : null; const effPhases = own || boxPhases; // Härledd effekt på boxens ampere — samma formel som backend // (A × 230 V × faser / 1000). Minsta effekten avgör om bilen kan // sol-laddas alls, därför visas den intill valet. const kw = (a) => ((a * 230 * effPhases) / 1000).toFixed(1).replace('.', ','); const minA = Number(box && box.min_current_a) || 0; const maxA = Number(box && box.max_current_a) || 0; const pwr = (minA > 0 && maxA > 0) ? { min: kw(minA), max: kw(maxA), a: `${minA}–${maxA}` } : null; return (
{(car.name && car.name.trim()) || `Bil ${i + 1}`} {/* "Ta bort" finns ALLTID, även för sista bilen (2026-08-08) — `cars.length > 1` låste in alla med en bil. Tom lista är giltig: normalizeCars returnerar [] för en tom ev_cars-array och härleder bara när fältet saknas helt. */}
upd(i, {name: v})} help="Visas på översikten och när du väljer bil"/> upd(i, {battery_kwh: v})} help={battBad ? Krävs : 't.ex. 77'}/> {/* §5.2: fasantal per bil. Default "Som laddboxen" — ingen ny obligatorisk fråga, tomt fält = dagens beteende. */} setCarPhases(i, v)} options={[ {value:'inherit', label:`Som laddboxen (${boxPhases} fas${boxPhases>1?'er':''})`}, {value:'1', label:'1 fas'}, {value:'3', label:'3 faser'}, ]} help={ {own ? `Den här bilen laddar med ${own} fas${own>1?'er':''} oavsett vad laddboxen klarar.` : 'Följer laddboxens inställning. Ändra bara om den här bilen laddar annorlunda.'} {pwr && ( Minst ~{pwr.min} kW · max ~{pwr.max} kW (vid {pwr.a} A, {effPhases} fas{effPhases>1?'er':''}) )} }/>
{blocked && (
Kan inte tas bort: {bound.map((ch, k) => (ch.name || `Laddbox ${k+1}`)).join(', ')} laddar alltid den här bilen. Byt bil på boxen först.
)}
); })} {cars.length < MAX && ( )}
); } // ── Multi-EV (§5): en lista av laddboxar (max 2 i v1) ─────────────────── // En box → identiskt med dagens vy. Noll boxar → lugn aktivera-ruta. // Namnf\u00e4lt tomt med platsh\u00e5llare; visning faller tillbaka p\u00e5 "Bil N". function ChargerBlock({ charger, index, onChange, onRemove, cars }){ const c = { ...(window.evModel?.CHARGER_DEFAULTS || {}), ...charger }; const set = (patch) => onChange({ ...c, ...patch }); const phases = Number(c.phases) === 1 ? 1 : 3; const kwOf = (a) => (a * 230 * phases) / 1000; const f1 = (n) => (n || 0).toFixed(1).replace('.', ','); const spanTxt = `${c.min_current_a || 0}–${c.max_current_a || 0} A × ${phases} fas${phases>1?'er':''} ≈ ${f1(kwOf(c.min_current_a || 0))}–${f1(kwOf(c.max_current_a || 0))} kW`; const maxBad = !(c.max_current_a > 0); const minBad = !(c.min_current_a > 0); const title = (c.name && c.name.trim()) || `Laddbox ${index + 1}`; const carList = Array.isArray(cars) ? cars : []; // §5.1: statusrad när grunden är överstyrd av minst en bilprofil. const ownPhaseCars = carList.filter(x => Number(x && x.phases) === 1 || Number(x && x.phases) === 3).length; const carName = (car, i) => (car && car.name && car.name.trim()) || `Bil ${(i ?? 0) + 1}`; const boundCar = carList.find(x => String(x.id) === String(c.default_car_id)) || carList[index] || carList[0] || null; const carSel = c.car_mode === 'select' ? '__select' : String((boundCar && boundCar.id) || ''); // Bilbindning: fast bil (dagens beteende) eller "fråga varje gång". Valet // erbjuds först när det FINNS mer än en bil — en bil + en box ska inte se // någon ny fråga alls (spec §1). const setCarSel = (v) => { if (v === '__select') { set({ car_mode: 'select' }); return; } const car = carList.find(x => String(x.id) === String(v)); // battery_kwh speglas till boxen som backend-fallback när car_id saknas. set({ car_mode: 'fixed', default_car_id: v, battery_kwh: car && car.battery_kwh > 0 ? car.battery_kwh : c.battery_kwh }); }; return (
{title} {/* SPEC_EV_SETTINGS_UX §1 (2026-08-02): "Ta bort" finns ALLTID, även för sista boxen — `!single`-villkoret låste in users med en box. Backend klarar tom lista: sparvägen diffar removed_charger_ids (spöksessions- städning) och tombstone (#290) når boxen med ett sista stopp. POST måste skicka ev_chargers: [] — nyckeln får ALDRIG utelämnas. */}
set({name: v})} help="Visas på översikten och i energiflödet"/> {carList.length > 1 ? ( ({ value: String(car.id), label: `Alltid ${carName(car, i)}` })), { value: '__select', label: 'Fråga varje gång' }, ]}/> ) : ( )} {/* SPEC_EV_FASER_PER_BIL §5.1: boxens fasfält = INSTALLATIONEN och får ALDRIG tas bort — utan bilprofiler finns ingen annan plats att ange fasantal, och befintliga users skulle tappa sin inställning. Min/max ampere stannar också här (huvudsäkring + installation). */} set({phases: Number(v)})} options={[{value:'3', label:'3 faser'},{value:'1', label:'1 fas'}]} help={ Grunden för alla bilar. Så många faser är dragna till laddboxen. Varje bil kan sedan ställas in separat — laddhybrider laddar ofta bara med 1 fas även på en 3-fas box. {ownPhaseCars > 0 && ( {ownPhaseCars} av {carList.length} bil{carList.length>1?'ar':''} har eget fasval. )} }/> set({target_pct: v})}/> set({max_current_a: v})} help={maxBad ? Krävs : 'Laddboxens/säkringens max'}/> set({min_current_a: v})} help={minBad ? Krävs : 'De flesta bilar tar inte emot under 6 A'}/> set({departure_time: v})} help={c.solar_mode === true ? (c.departure_day ? 'Sätts under "Garanterat laddad till" nedan' : 'styrs av sol — ingen avresetid behövs') : 'Bilen är klar till denna tid — kan ändras per tillfälle på översikten'}/>
{carList.length > 1 && (
{c.car_mode === 'select' ? 'Du väljer bil när du sparar laddningen på översikten — HEMS planerar då mot den bilens batteri.' : `Boxen laddar alltid ${carName(boundCar, 0)}. Kör ni flera bilar på samma box, välj "Fråga varje gång".`}
)}
Effektspann
{spanTxt}
A × 230 V × faser
set({variable_current: v})}/>
{/* SPEC_EV_SOLAR_MODE — sol-läge per laddbox. Husägarspråk; skriv aldrig "solar_mode"/"car_first" i UI-text. Max EN box i sol-läge/hem (v1); backend ignorerar fler. */}
set({solar_mode: v === true})}/>
Bilen laddas bara när solprognosen ger överskott som täcker lägsta effekten din bil klarar. Utan avresedag laddas den aldrig från elnätet. Med avresedag använder HEMS nätet endast om solen inte räckt när avresan närmar sig.
{c.solar_mode === true && (
{c.variable_current === false && (
Din laddbox är på/av-styrd. Sol-laddning kräver då överskott för hela laddeffekten och blir gles — bilen laddar sällan. Har boxen reglerbar ström rekommenderar vi att slå på det (upp till ~3× mer solel till bilen).
)}
set({solar_priority: v})} options={[ {value:'car_first', label:'Bilen först (rekommenderad)'}, {value:'auto', label:'Låt HEMS välja'}, {value:'battery_first', label:'Hembatteriet först'}, ]}/>
{c.solar_priority === 'battery_first' ? 'Hembatteriet fylls först — bilen får solöverskottet som blir kvar.' : c.solar_priority === 'auto' ? 'HEMS fördelar solöverskottet mellan bil och hembatteri efter läget.' : 'Bilen får solöverskottet först — rekommenderas, särskilt med stort hembatteri.'}
Garanterat laddad till (valfritt)
set({departure_day: v || null})} options={[ {value:'', label:'Ingen — bara sol'}, {value:'mon', label:'Måndag'}, {value:'tue', label:'Tisdag'}, {value:'wed', label:'Onsdag'}, {value:'thu', label:'Torsdag'}, {value:'fri', label:'Fredag'}, {value:'sat', label:'Lördag'}, {value:'sun', label:'Söndag'}, ]}/>
set({departure_time: v})}/>
{c.departure_day ? 'HEMS garanterar att bilen är laddad till denna tid — sol först, nät bara om solen inte räckt fram till avresan.' : 'Utan dag laddas bilen enbart ur solöverskott — aldrig från elnätet.'}
Lägsta effekt din bil klarar
{f1(kwOf(c.min_current_a || 0))} kW
{c.min_current_a || 0} A × {phases} fas{phases>1?'er':''}
Solöverskottet måste nå minst så här mycket innan bilen börjar ladda. Ändra "Lägsta laddström" ovan om din bil klarar lägre.
)}
); } // SPEC_EV_SETTINGS_UX §3 (2026-08-02): Homey rapporterar ev_power men ingen // laddbox är konfigurerad ⇒ HEMS ser boxen men STYR den inte (Tibber-fallet). // Diskret hint, aldrig auto-skapning (produktbeslut 2/8). Avfärdas permanent — // den som medvetet valt extern styrning ska inte tjatas på. const EV_HINT_DISMISS_KEY = 'hems_ev_unmanaged_hint_dismissed'; const EV_HINT_MIN_W = 500; function UnmanagedEvHint({ chargers, onAdd }){ const [w, setW] = React.useState(null); const [gone, setGone] = React.useState(() => { try { return localStorage.getItem(EV_HINT_DISMISS_KEY) === '1'; } catch(e){ return false; } }); const empty = !chargers || chargers.length === 0; React.useEffect(() => { // Hämtas bara när det kan bli en hint: tom boxlista och inte avfärdad. if (!empty || gone) return; const api = window.HEMS_SETTINGS_API; if (!api || !api.base || !api.key) return; let dead = false; (async () => { try { const r = await fetch(`${api.base}/api/live-data?api_key=${encodeURIComponent(api.key)}`); if (!r.ok) return; const d = await r.json(); // ev_power (skalär) eller ev_power_list[] — ta det största värdet. const list = Array.isArray(d && d.ev_power_list) ? d.ev_power_list.map(Number) : []; const peak = Math.max(Number(d && d.ev_power) || 0, ...(list.length ? list : [0])); if (!dead) setW(peak); } catch(e){ /* hint är frivillig — tyst vid fel */ } })(); return () => { dead = true; }; }, [empty, gone]); if (!empty || gone || !(w > EV_HINT_MIN_W)) return null; const kw = (w / 1000).toFixed(1).replace('.', ','); const dismiss = () => { try { localStorage.setItem(EV_HINT_DISMISS_KEY, '1'); } catch(e){} setGone(true); }; return (
HEMS ser en laddbox ({kw} kW) men styr den inte. Vill du att HEMS planerar laddningen?
); } function EvChargingCard({ s, u }){ const chargers = Array.isArray(s.ev_chargers) ? s.ev_chargers : []; const MAX = window.evModel?.MAX_CHARGERS || 2; const setChargers = (arr) => u('ev_chargers', arr); // Nya boxar får ett id som är ledigt mot HELA listan (aldrig indexbaserat, // aldrig kopierat från en befintlig rad). Befintliga rader rörs aldrig. const newCharger = () => ({ ...(window.evModel?.CHARGER_DEFAULTS || {}), name: '', _new: true, id: window.evModel?.nextChargerId ? window.evModel.nextChargerId(chargers) : undefined, }); const addCharger = () => { if (chargers.length < MAX) setChargers([...chargers, newCharger()]); }; const updCharger = (i, next) => setChargers(chargers.map((c, j) => j === i ? next : c)); const removeCharger = (i) => setChargers(chargers.filter((_, j) => j !== i)); // Bilprofiler (SPEC_BILPROFILER_PER_BOX). Saknas ev_cars härleds listan ur // boxarna — vid första ändringen materialiseras den (och boxar utan // default_car_id stampas mot sin position, så bindningen aldrig är implicit // i det vi POST:ar). const cars = window.evModel?.normalizeCars ? window.evModel.normalizeCars(s) : []; const setCars = (arr) => { const clean = arr.map((car) => { const c = { ...(car || {}) }; delete c._derived; return c; }); u('ev_cars', clean); const byId = (id) => clean.find(c => String(c.id) === String(id)); setChargers(chargers.map((c, i) => { const bound = c && c.default_car_id ? byId(c.default_car_id) : clean[i]; if (!bound) return c; const next = { ...c }; if (!c.default_car_id && c.car_mode !== 'select') next.default_car_id = bound.id; // Spegla batteriet till boxen så backendens fallback (car_id saknas) // aldrig planerar mot en gammal siffra. if (c.car_mode !== 'select' && bound.battery_kwh > 0) next.battery_kwh = bound.battery_kwh; return next; })); }; return ( 0 ? {chargers.length} av {MAX} laddboxar : null}> {chargers.length === 0 ? ( // Inga boxar (default): lugn aktivera-rad, inga krav.
Har du en laddbox? Lägg till den så planerar HEMS laddningen till dygnets billigaste timmar och soltoppen — bilen är alltid klar till avresetiden.
) : (
HEMS planerar varje bils laddning till billigaste timmarna och soltoppen. Bilarna är alltid klara till sina avresetider.
{chargers.map((c, i) => ( updCharger(i, next)} onRemove={()=>removeCharger(i)}/> ))} {chargers.length < MAX && ( )}
u('ev_charger_power_in_kw', v)}/>
)} {/* "Pausa hembatteriet när elbilen laddar" flyttad till Driftläge → "Gäller alltid" (SPEC_GRAVIO_USER_MODE_UI §3.2). Fältet pause_battery_on_ev_charging → ev_pause_battery är oförändrat. */}
); } function DeferrableLoadsCard({ s, u }){ // Helt oberoende av elbil — går att lägga till/spara utan bil-uppgifter. return ( } subtitle="Flyttbara förbrukare (t.ex. varmvattenberedare, poolpump) som HEMS lägger på dygnets billigaste timmar"> Skjutbara laster är nytt och ännu inte färdigtestat av oss. Planeringen kan flytta en körning, hoppa över den eller visa fel kostnad. Kontrollera att lasten körts som du tänkt, och styr den själv om något måste bli klart i tid. u('deferrable_loads', arr)}/> ); } // Wrapper — behåller export-namnet (settings.jsx renderar ). function EvLoadsCard({ s, u }){ return ( ); } Object.assign(window, { EvLoadsCard, EvChargingCard, DeferrableLoadsCard, CarsEditor, EV_CHARGING_DEFAULTS });