/* 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 ident = !!(window.evModel?.identityMode && window.evModel.identityMode({ ev_chargers: chargers })); 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 : []; /* Identitetsläget (D6): ingen box på plats — boxen som laddar bilen fast, annars den enda boxen. */ return list.find(ch => ch && ch.car_mode !== 'select' && String(ch.default_car_id || '') === String(car.id || '')) || (ident ? (list.length === 1 ? list[0] : null) : 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 || '')); /* Identitetsläget (D6): borttagning spärras inte. Bekräftelsen säger vilka boxar som har bilen som förval, och de får inget förval (setCars nollar default_car_id). */ const preBy = (car) => (chargers || []).filter(ch => ch && 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 = !ident && 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) => (Math.round((a * 230 * effPhases) / 100) / 10).toFixed(1).replace('.', ','); /* Laddström under 6 A (28/9): den ström HEMS skickar, ur evModel.currentLimits (samma regel som servern). */ const lim = (box && window.evModel && window.evModel.currentLimits) ? window.evModel.currentLimits(box) : null; const pwr = lim ? { min: kw(lim.min), max: kw(lim.max), a: lim.fixed ? String(lim.max) : `${lim.min}–${lim.max}` } : 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 && ( {lim.fixed ? `Laddar ~${pwr.max} kW` : `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, approval }){ const c = { ...(window.evModel?.CHARGER_DEFAULTS || {}), ...charger }; const set = (patch) => onChange({ ...c, ...patch }); /* D8 (identitetsläget 3/10): "Byt laddbox" — samma box, ny enhet. Förklaras före anropet. POST /api/ev/chargers/{uid}/replace utan kropp. Laddboxtestet läser om sitt godkännande efteråt. */ const [rep, setRep] = React.useState(null); const replaceBox = async () => { if (!c.uid) return; if (!window.confirm(`Byt laddbox för ${title}? Boxen behåller sitt namn, sin plats, sina inställningar och sina flöden. Laddboxtestets godkännande och den inlärda laddeffekten nollställs. Kopplingen till den nya enheten gör du i HEMS-appen.`)) return; const api = window.HEMS_SETTINGS_API; if (!api || !api.base || !api.key) { setRep({ kind:'err', msg:'Kunde inte byta laddboxen.' }); return; } setRep({ kind:'info', msg:'Byter laddbox…' }); try { const r = await fetch(`${api.base}/api/ev/chargers/${encodeURIComponent(c.uid)}/replace?api_key=${encodeURIComponent(api.key)}`, { method:'POST', headers:{ 'Accept':'application/json' } }); let j = null; try { j = await r.json(); } catch(_){} if (!r.ok || !j || j.ok !== true) { const d = j && j.detail; setRep({ kind:'err', msg: (d && typeof d === 'object' && d.message) ? String(d.message) : (typeof d === 'string' && d) ? d : 'Kunde inte byta laddboxen.' }); return; } if (j.uid && j.uid !== c.uid) set({ uid: j.uid }); try { window.dispatchEvent(new Event('hems:settings-saved')); } catch(_){} setRep({ kind:'ok', msg:'Laddboxen är bytt. Koppla den nya enheten i HEMS-appen.' }); } catch(e){ setRep({ kind:'err', msg:'Kunde inte byta laddboxen.' }); } }; const phases = Number(c.phases) === 1 ? 1 : 3; const kwOf = (a) => (a * 230 * phases) / 1000; /* Avrundning som _h i ev-loads.jsx (8/10): toFixed ensam gav 10,3 för 10,35 kW, bilkortet 10,4. */ const f1 = (n) => (Math.round((n || 0) * 10) / 10).toFixed(1).replace('.', ','); /* Laddström under 6 A (TILL_DESIGN_lagstrom_under_6A, André 28/9): sidan visar det HEMS skickar. lim kommer ur evModel.currentLimits, samma regel som servern: under 6 A först efter bekräftelse. */ const evM = window.evModel && window.evModel.currentLimits ? window.evModel : null; const lim = evM ? evM.currentLimits(c) : { min: Number(c.min_current_a) || 0, max: Number(c.max_current_a) || 0, confirmed: false, lowTyped: null }; const fErr = evM ? evM.currentFieldErr(c) : { min: null, max: null }; const aTxt = (n) => String(n).replace('.', ','); /* Laddbox utan effektstyrning (TILL_DESIGN_laddstrom_utan_effektstyrning 8/10, L1–L5): ett fält Laddström = max_current_a, Lägsta dold (värdet skickas oförändrat), effektrutan och sol-rutan visar laddeffekten. Byte av svar ändrar inget värde. */ const fixed = c.variable_current === false; const spanTxt = fixed ? `${lim.max} A × ${phases} fas${phases>1?'er':''} ≈ ${f1(kwOf(lim.max))} kW` : `${lim.min}–${lim.max} A × ${phases} fas${phases>1?'er':''} ≈ ${f1(kwOf(lim.min))}–${f1(kwOf(lim.max))} kW`; const maxBad = !!fErr.max; const minBad = !!fErr.min; 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}`; /* Identitetsläget (D6): ingen bil på plats — förvalet, eller den enda bilen. */ const ident = !!(window.evModel?.identityMode && window.evModel.identityMode({ ev_chargers: [c] })); const boundCar = carList.find(x => String(x.id) === String(c.default_car_id)) || (ident ? (carList.length === 1 ? carList[0] : null) : (carList[index] || carList[0])) || null; /* Laddbox utan bil (TILL_DESIGN_laddbox_enda_bilen_koppling 4/10, krav 2 och 5): "Laddar" visar bara bilen boxen pekar på (default_car_id). Saknas kopplingen visas valet även med en bil, med Ingen förvald bil förvalt. Inläsningen ändrar ingenting. boundCar (med platsen som reserv) gäller bara fasreferensen. */ const linkedCar = window.evModel?.linkedCar ? window.evModel.linkedCar(c, carList) : (carList.find(x => String(x.id) === String(c.default_car_id)) || null); const needCar = !!(window.evModel?.boxNeedsCar && window.evModel.boxNeedsCar(c, carList)); const showCarSel = carList.length > 1 || (carList.length === 1 && (c.car_mode === 'select' || !linkedCar)); const needTxt = carList.length ? `Välj vilken bil som laddas i ${title}` : `Lägg till bilen som laddas i ${title} under Bilar ovan`; // Raden om 500 W: bilprofilens fasantal vinner över boxens, som i effekten. Vid "Fråga varje gång" // räknas den bil som ger lägst effekt, med den ström som gäller efter bekräftelse. const carsHere = c.car_mode === 'select' ? carList : (boundCar ? [boundCar] : []); const phOf = (car) => Number(car && car.phases) === 1 ? 1 : Number(car && car.phases) === 3 ? 3 : phases; const lowPh = carsHere.length ? Math.min(...carsHere.map(phOf)) : phases; const limOk = evM ? evM.currentLimits({ ...c, low_current_ok: true }) : lim; const lowW = lim.lowTyped != null ? limOk.min * 230 * lowPh : null; const carSel = c.car_mode === 'select' ? '__select' : String((linkedCar && linkedCar.id) || ''); // Bilbindning: fast bil (dagens beteende) eller "fråga varje gång". Valet // erbjuds när det FINNS mer än en bil, eller när kopplingen till den enda // bilen saknas eller boxen frågar varje gång (krav 2, 4/10). const setCarSel = (v) => { if (v === '__select') { set({ car_mode: 'select' }); return; } const car = carList.find(x => String(x.id) === String(v)); if (!car) { set({ car_mode: 'fixed', default_car_id: null }); return; } // battery_kwh speglas till boxen som backend-fallback när car_id saknas. set({ car_mode: 'fixed', default_car_id: car.id, battery_kwh: car.battery_kwh > 0 ? car.battery_kwh : c.battery_kwh }); }; return (
{title}{ident && c.slot && ( /* D7 (3/10): boxens plats och de fyra första tecknen i uid, som Driftcentralen ser dem. */ {c.slot}{c.uid ? ' · ' + String(c.uid).slice(0, 4) : ''} )} {ident && c.uid && ( )} {/* 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. */}
{rep && (
{rep.msg}
)} {/* v753 Laddboxtest (TILL_DESIGN_laddboxtest 29/9, K1–K4 och K6): överst i boxen. Syns bara när servern har testet påslaget. */} {window.CtCustomer && }
set({name: v})} help="Visas på översikten och i energiflödet"/> {showCarSel ? ( ({ value: String(car.id), label: `Alltid ${carName(car, i)}` })), { value: '__select', label: 'Fråga varje gång' }, ]} help={needCar ? {needTxt} : undefined}/> ) : ( {needTxt} : linkedCar ? 'Batteristorleken ändras under Bilar ovan' : 'Lägg till bilen under Bilar ovan'}/> )} {/* 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, low_current_ok: false})} help={maxBad ? {fErr.max} : fixed ? 'Den ström laddboxen och bilen laddar med när laddningen är igång.' : 'Laddboxens/säkringens max'}/> {!fixed && set({min_current_a: v, low_current_ok: false})} help={minBad ? {fErr.min} : (Number(c.min_current_a) < 6 ? undefined : 'De flesta bilar tar inte emot under 6 A')}/>} {/* Laddström under 6 A (André 28/9): varning och bekräftelse per box. Varje ändring av strömmen ovan tar bort bekräftelsen, så en bekräftelse av 3 A aldrig gäller för 1 A. Spara spärras inte. */} {lim.lowTyped != null && (
▲
De flesta bilar och laddboxar laddar inte under 6 A — laddstandarden går inte lägre. Kontrollera i bilens och laddboxens manual att båda klarar {aTxt(lim.lowTyped)} A. Klarar de det inte laddar bilen inte alls.
{lowW != null && lowW < 500 &&
Under 500 W märker HEMS inte att bilen laddar utanför planen. Den laddningen upptäcks inte, och batteriet pausas inte för den.
}
set({low_current_ok: v === true})}/>
)} 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'}/>
{showCarSel && (c.car_mode === 'select' || (linkedCar && 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(linkedCar, carList.indexOf(linkedCar))}. Kör ni flera bilar på samma box, välj "Fråga varje gång".`}
)}
{fixed ? 'Laddeffekt' : 'Effektspann'}
{spanTxt}
{lim.lowTyped != null && !lim.confirmed &&
HEMS använder 6 A tills du bekräftat.
}
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})}/> {/* K7 (TILL_DESIGN_laddlage_av_bevaras 4/10): Av här = Billigast från nätet på Bil-fliken. */}
{c.solar_mode === 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.' : 'Av: bilen laddas till målet före avresetiden, de timmar elen är billigast.'}
{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.'}
{fixed ? 'Laddeffekt' : 'Lägsta effekt din bil klarar'}
{f1(kwOf(lim.min))} kW
{lim.min} A × {phases} fas{phases>1?'er':''}
{fixed ? 'Solöverskottet måste räcka till hela laddeffekten innan bilen börjar ladda.' : 'Solöverskottet måste nå minst så här mycket innan bilen börjar ladda.'} {fixed ? ' Ändra "Laddström" ovan om din bil klarar lägre.' : ' Ä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) : []; // Identitetsläget: effekten per box ligger i ev_power_by_charger. Hinten tar största värdet oavsett box. const byUid = d && d.ev_power_by_charger && typeof d.ev_power_by_charger === 'object' ? Object.values(d.ev_power_by_charger).map(Number).filter(n => isFinite(n)) : []; const peak = Math.max(Number(d && d.ev_power) || 0, ...(list.length ? list : [0]), ...byUid); 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); const ident = !!(window.evModel?.identityMode && window.evModel.identityMode(s)); // 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) : []; /* Krav 1 (TILL_DESIGN_laddbox_enda_bilen_koppling 4/10): en ny box på ett konto med exakt en bil kopplas till bilen, så det som står i "Laddar" är det som POST:as. Härledda rader räknas inte; de är inga bilar än. */ const realCar = (car) => !!car && !car._derived && car.id != null && car.id !== ''; const onlyCar = cars.length === 1 && realCar(cars[0]) ? cars[0] : null; const carLink = (car, fallbackKwh) => ({ car_mode: 'fixed', default_car_id: car.id, battery_kwh: car.battery_kwh > 0 ? car.battery_kwh : (fallbackKwh ?? 0) }); // 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. // Identitetsläget (D5): ny box skickas utan uid och id, med _new: true — servern sätter uid och slot. const newCharger = () => ({ ...(ident ? { ...(window.evModel?.CHARGER_DEFAULTS || {}), id: null, name: '', _new: true } : { ...(window.evModel?.CHARGER_DEFAULTS || {}), name: '', _new: true, id: window.evModel?.nextChargerId ? window.evModel.nextChargerId(chargers) : undefined, }), ...(onlyCar ? carLink(onlyCar) : {}) }); 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)); 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)); /* Krav 4 (4/10): togs bilen bort som en box laddar fast och finns en bil kvar, kopplas boxen till den. Samma för en ny box utan koppling när kontots första bil läggs till (krav 1). Ingen box lämnas då pekande på en bil som inte finns. */ const wasCar = (id) => id != null && id !== '' && cars.some(x => String(x.id) === String(id)); const only = clean.length === 1 && clean[0] && clean[0].id != null && clean[0].id !== '' ? clean[0] : null; setChargers(chargers.map((c, i) => { if (only && c && c.car_mode !== 'select' && ((c.default_car_id && !byId(c.default_car_id) && wasCar(c.default_car_id)) || (c._new === true && !c.default_car_id))) return { ...c, ...carLink(only, c.battery_kwh) }; /* Identitetsläget (D6): ingen stämpling på position. Pekar förvalet på en borttagen bil får boxen inget förval. */ const bound = c && c.default_car_id ? byId(c.default_car_id) : (ident ? null : clean[i]); if (!bound) return (ident && c && c.default_car_id) ? { ...c, default_car_id: null } : 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)}/> ); } /* Planspärr i Inställningar (kravlista 2026-09-15, K4). Samma modell som flikarna: sektionen syns kvar med sin lagrade konfiguration, gråad bakom en panel med Uppgradera. Backend rör inte lagrade värden — boxar och laster vaknar när planen höjs. Saknas entitlements ⇒ ingen skillnad (K6). */ function SettingsEntLock({ text, children }){ const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const go = async () => { setErr(null); setBusy(true); const r = await window.HemsEnt.openPortal(); if (!r.ok) setErr(r.error); setBusy(false); }; return (
{text}
{err &&
{err}
}
); } // Wrapper — behåller export-namnet (settings.jsx renderar ). function EvLoadsCard({ s, u }){ const [ent, setEnt] = React.useState(() => (window.HemsEnt ? window.HemsEnt.get() : { ev:true, loads:true })); React.useEffect(() => { const on = (e) => setEnt(e.detail); window.addEventListener('hems:entitlements', on); return () => window.removeEventListener('hems:entitlements', on); }, []); const L = (window.HemsEnt && window.HemsEnt.LOCK_TEXT) || { ev:'', loads:'' }; const ev = ; const loads = ; return ( {ent.ev ? ev : {ev}} {ent.loads ? loads : {loads}} ); } Object.assign(window, { EvLoadsCard, EvChargingCard, DeferrableLoadsCard, CarsEditor, EV_CHARGING_DEFAULTS, SettingsEntLock });