/* settings.jsx — HEMS Inställningar Loads existing settings from /settings/api/{api_key}, lets the user edit, POSTs the FULL settings object back. No partial updates (per backend spec). */ const SETTINGS_BASE_URL = window.HEMS_BASE(); // Säkerhetsfix 2026-05-29: API-nyckel läses från localStorage (session-token // sparas där efter login). Tidigare hårdkodning av Andrés Homey-nyckel gjorde // att alla inloggade staging-användare läste/skrev mot Andrés settings när de // öppnade /v2/settings.html. STRIKT sess_*-validering: gamla hems_*-spöktokens // (från tidigare cutover-fönster) rensas → tvingar fram login. Saknad token → // redirect till login.html. // Master-gaten (user_mode_enabled) persisteras av backend sedan 2026-08-01 // (SPEC_DRIFTLAGE_UI_V2, rev 01259-kuc). localStorage-speglingen // `hems_user_mode_enabled_` är RIVEN — backend är sanningskälla. // Bygg aldrig tillbaka den: ny inloggning/enhet = ny token = tom nyckel. function _readSessionToken() { if (typeof localStorage === 'undefined') return ''; let tok = localStorage.getItem('hems_session_token') || ''; if (tok && !tok.startsWith('sess_')) { // Spöke från tidigare cutover — rensa och tvinga login localStorage.removeItem('hems_session_token'); tok = ''; } if (!tok) { // Ej inloggad — skicka till login (sparar request_url så vi kan komma tillbaka) window.location.href = 'login.html'; return ''; } return tok; } const SETTINGS_API_KEY = _readSessionToken(); // Babel-scripts delar inte scope: settings-ev.jsx behöver bas-URL + nyckel för // den frivilliga live-data-koll som driver "HEMS ser en laddbox"-hinten // (SPEC_EV_SETTINGS_UX §3). Ingen ny endpoint — /api/live-data finns redan. window.HEMS_SETTINGS_API = { base: SETTINGS_BASE_URL, key: SETTINGS_API_KEY }; // Defaults derived from clean_settings_api.json + the live screenshots. // Used while the real GET is in flight, and as a fallback if it fails. // Säkerhetsfix 2026-05-29: tidigare hade defaults Andrés exakta personliga data // (Kinnekulle-koord, 19.78 kWp sol, 25.6 kWh batteri, peak-config). När annan // användares settings saknade ett fält visade UI Andrés värden istället. Nu är // alla personberoende fält neutrala/0 — bara svenska standarder (SE3, moms 25%) // och tekniska defaults (verkningsgrader). UI ska enbart visa användarens // faktiska sparade data, aldrig "någon annans" som fallback. const SETTINGS_DEFAULTS = { name: '', email: '', area: 'SE3', latitude: 0, longitude: 0, // Solar — up to 3 arrays (tomt = inte konfigurerat) solar_array_1_kwp: 0, solar_array_1_azimuth: 180, solar_array_1_tilt: 30, solar_array_2_kwp: 0, solar_array_2_azimuth: 180, solar_array_2_tilt: 30, solar_array_3_kwp: 0, solar_array_3_azimuth: 180, solar_array_3_tilt: 30, // Battery (0 = inte konfigurerat) battery_capacity_kwh: 0, battery_charge_rate_kw: 0, battery_discharge_rate_kw: 0, battery_soc_min_pct: 5, battery_soc_max_pct: 100, battery_efficiency_pct: 98, inverter_efficiency_pct: 96, batteries: [], // Installation main_fuse_a: 16, // Homey / inverter format homey_power_in_kw: false, ev_charger_power_in_kw: false, pause_battery_on_ev_charging: false, // Elbil & skjutbara laster (SPEC_LASTER_EV 2026-06-11) — nested objekt + // array, backend-namnen exakt enligt spec. Defaults speglar EV_CHARGING_DEFAULTS // i settings-ev.jsx. ev_charging: { 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, }, // Multi-EV (§5): lista av laddboxar (max 2). Normaliseras från backend vid // GET (migrerar gammalt ev_charging-objekt vid behov). Skickas som // ev_chargers vid save; gamla ev_charging strippas ur payloaden. ev_chargers: [], deferrable_loads: [], negate_charge: false, negate_chargesolar: false, negate_selfconsumption: false, negate_peakshaving: false, negate_export: false, // Buy economy (öre/kWh except moms) — svenska standardvärden grid_fee_ore: 0, energy_tax_ore: 0, retailer_margin_ore: 0, vat_pct: 25, // Sell economy grid_benefit_ore: 0, sale_margin_ore: 0, cycle_cost_ore: 10, // Peak shaving (av som default) peak_shaving_enabled: false, peak_shaving_mode: 'simple', peak_max_grid_kw: 5, peak_cost_kr_per_kw: 50, peak_reserve_kwh: '', // tomt = automatiskt (null i API:t, ALDRIG 0) peak_day_max_kw: 0, peak_night_max_kw: 0, peak_day_start_h: 6, peak_day_end_h: 22, peak_winter_high_start_h: 6, peak_winter_high_end_h: 21, peak_winter_high_max_kw: 0, peak_winter_high_cost_kr_per_kw: 0, peak_winter_low_max_kw: 0, peak_winter_low_cost_kr_per_kw: 0, peak_winter_months: [11,12,1,2,3], // Nov-Mar (kalendermånad 1-12) peak_summer_max_kw: 0, peak_summer_cost_kr_per_kw: 0, peak_summer_months: [4,5,6,7,8,9,10], // Apr-Okt (kalendermånad 1-12) // Driftläge (2026-07-30, SPEC_GRAVIO_USER_MODE_UI): self_sufficiency-läget är // rivet — operating_mode finns inte längre i UI:t eller i payloaden. // user_mode_enabled = master-gate. AV → flottans defaults gäller, men // rattvärdena raderas inte (backend ignorerar dem, UI:t minns dem). user_mode_enabled: false, // Rattar: null = OJUSTERAD (flottans standard, visas som ghost, skickas // aldrig). 0 = av och är ett giltigt sparat värde. Blanda aldrig ihop dem. traj_premie_ore_per_kwh_h: null, sellsolar_min_price_ore: null, maxprofit_import_aversion_kr: null, soc_planning_buffer_pct: null, soc_buffer_release_ore: null, }; // ── Section colour accents (matches the gradient borders in the original) ── /* Kategorier för SNav (2026-06-11) — id:n matchar
-wrapparna i main. Färgprickarna speglar respektive korts accent. */ const SETTINGS_SECTIONS = [ { id: 'grp-hem', label: 'Hem & plats', color: '#3FCC8B' }, { id: 'grp-drift', label: 'Driftläge', color: '#00BFA5' }, { id: 'grp-sol', label: 'Sol & batteri', color: '#F4C84B' }, { id: 'grp-install', label: 'Elinstallation', color: '#5BA8FF' }, { id: 'grp-ev', label: 'Elbil & laster', color: '#7AB8FF' }, { id: 'grp-data', label: 'Datafält & enheter', color: '#8C97AE' }, { id: 'grp-ekonomi', label: 'Ekonomi', color: '#FF8A47' }, { id: 'grp-peak', label: 'Effektavgift', color: '#9D7BFF' }, { id: 'grp-konto', label: 'Konto & säkerhet', color: '#FF5A65' }, ]; /* ---- Reserv för toppar (kontrakt 1/9 punkt A) -------------------------- Tomt fält och 0 betyder BÅDA "automatiskt" och skickas som null. 0 skrevs tidigare om till 1 kWh — den som skriver 0 vill inte reservera något själv, så klampet gav honom motsatsen. Utanför 1–50 klampas inget tyst: felet visas rött vid fältet och sparningen stoppas, för ett tyst klamp sparar ett annat värde än det användaren lämnade. */ function peakReserveOut(v){ if (v === '' || v == null) return null; const n = Number(v); if (!isFinite(n) || n <= 0) return null; return n; } function peakReserveErr(v){ if (v === '' || v == null) return null; const n = Number(v); if (!isFinite(n)) return 'Ange ett tal i kWh, eller lämna fältet tomt för automatiskt.'; if (n === 0) return null; // 0 = automatiskt, giltigt /* Negativt är inte "automatiskt" — det är ett skrivfel. Utan den här raden föll −5 igenom båda gränskontrollerna och sparades TYST som null, så användaren trodde att han satt en reserv. */ if (n < 0) return 'Reserven kan inte vara negativ. Lämna fältet tomt eller skriv 0 för automatiskt.'; if (n < 1) return 'Reserven måste vara minst 1 kWh. Lämna fältet tomt eller skriv 0 för automatiskt.'; if (n > 50) return 'Reserven kan högst vara 50 kWh.'; return null; } /* Backendens 422 i läsbar form. Utan detta blev ett fältfel "HTTP 422 — {"detail":[{"type":"greater_than"…" i statusraden, och användaren fick gissa vilket fält som stoppade sparningen. Högst tre fel, som backend. */ const SB_FIELD_NAME = { deferrable_loads: 'skjutbara laster', power_kw: 'effekt', windows: 'körfönster', peak_reserve_kwh: 'reserv för toppar', duration_hours: 'körtid', earliest: 'tidigast start', latest: 'senast klar', days: 'veckodagar', name: 'namn', max_parts: 'max antal delar', /* Batterilistan (B7). Index i loc är nollbaserat och settings422 hänger numret på fältet FÖRE, så ['batteries',1,'capacity_kwh'] blir "batteri 2 → kapacitet". Serverns egna svenska texter passerar orörda; "Value error, "-prefixet strippas redan nedan. */ batteries: 'batteri', capacity_kwh: 'kapacitet', charge_rate_kw: 'laddeffekt', discharge_rate_kw: 'urladdningseffekt', soc_min: 'min SOC', soc_max: 'max SOC', }; function settings422(body){ const det = (body && Array.isArray(body.detail)) ? body.detail : []; if (!det.length) return null; const lines = det.slice(0, 3).map(d => { const loc = Array.isArray(d && d.loc) ? d.loc.filter(x => x !== 'body') : []; /* Numret hör till f\u00e4ltet FÖRE det, inte till slutet: loc ['deferrable_loads',0,'power_kw'] är "skjutbara laster 1 → effekt", inte "skjutbara laster → effekt 1". */ const parts = []; loc.forEach(x => { if (typeof x === 'number'){ if (parts.length) parts[parts.length - 1] += ' ' + (x + 1); } else parts.push(SB_FIELD_NAME[x] || String(x)); }); const msg = String((d && d.msg) || '').replace(/^Value error,\s*/i, ''); return parts.length ? `${parts.join(' → ')}: ${msg}` : msg; }); return lines.join(' · '); } const ACCENTS = { contact: 'linear-gradient(90deg, #3FCC8B, #FF8A47)', area: 'linear-gradient(90deg, #5BA8FF, #00BFA5)', position: 'linear-gradient(90deg, #F4C84B, #FF8A47)', solar: 'linear-gradient(90deg, #F4C84B, #3FCC8B)', battery: 'linear-gradient(90deg, #9D7BFF, #5BA8FF)', install: 'linear-gradient(90deg, #5BA8FF, #00BFA5)', homey: 'linear-gradient(90deg, #5BA8FF, #9D7BFF)', econBuy: 'linear-gradient(90deg, #FF8A47, #FF5A65)', econSell: 'linear-gradient(90deg, #3FCC8B, #00BFA5)', peak: 'linear-gradient(90deg, #FF5A65, #9D7BFF)', selfSuf: 'linear-gradient(90deg, #F4C84B, #FF8A47)', password: 'linear-gradient(90deg, #3FCC8B, #5BA8FF)', api: 'linear-gradient(90deg, #9D7BFF, #5BA8FF)', billing: 'linear-gradient(90deg, #F4C84B, #3FCC8B)', }; /* Prenumeration (kravlista 2026-09-13, K6–K7). GET /me ger has_subscription, plan, subscription_status, current_period_end. Sektionen döljs helt utan prenumeration (undantagna användare). POST /billing/portal → {url}. En 401/404 här får aldrig logga ut: endpointen kan saknas på revisionen. */ const SUB_PLAN_NAME = { bas:'Bas', standard:'Standard', premium:'Premium' }; function subStatusText(st, end){ const d = end ? new Date(end) : null; const ds = d && !isNaN(d) ? d.toLocaleDateString('sv-SE', { day:'numeric', month:'long', year:'numeric' }) : '—'; if (st === 'trialing') return `Gratismånad, första debitering ${ds}`; if (st === 'active') return 'Aktiv'; if (st === 'past_due') return 'Betalningen misslyckades, uppdatera kortet'; if (st === 'canceled') return 'Avslutad'; return ''; } /* Spärr/villkorsgrind på inställningssidan (T2/K3, 20/9): en sida, inte två. Godkännandesidan och spärrsidan bor i dashboardens grind (gc-entitle.jsx); inställningarna skickar dit i stället för att bära en egen kopia. */ function SettingsBlocked(){ React.useEffect(() => { window.location.replace('index.html'); }, []); return null; } /* Din supportkod (backend 23/9, S1): ur HemsEnt:s /me — inget eget anrop. Saknas koden visas inget kort. */ function SupportCodeCard(){ const [code, setCode] = React.useState(() => { const m = window.HemsEnt && window.HemsEnt.me(); return (m && m.support_code) || null; }); React.useEffect(() => { const on = () => { const m = window.HemsEnt && window.HemsEnt.me(); if (m && m.support_code) setCode(m.support_code); }; window.addEventListener('hems:entitlements', on); if (window.HemsEnt) window.HemsEnt.load().then(on); return () => window.removeEventListener('hems:entitlements', on); }, []); if (!code) return null; return ( ); } function SubscriptionCard(){ const [me, setMe] = React.useState(() => (window.HemsEnt && window.HemsEnt.me()) || null); const [ent, setEnt] = React.useState(() => (window.HemsEnt ? window.HemsEnt.get() : { access:'full', ev:true, loads:true, plan:null })); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); /* entitlements.js hämtar /me asynkront. Utan lyssnare kapplöper kortets eget anrop med det och K5-raden kan utebli tyst. */ React.useEffect(() => { const on = (e) => { setEnt(e.detail); const m = window.HemsEnt && window.HemsEnt.me(); if (m) setMe(m); }; window.addEventListener('hems:entitlements', on); return () => window.removeEventListener('hems:entitlements', on); }, []); React.useEffect(() => { (async () => { if (window.HEMS_BASE_IS_LOCAL) { setMe({ has_subscription:true, plan:'standard', subscription_status:'trialing', current_period_end:'2026-10-13T20:00:00Z' }); return; } /* HemsEnt läser redan /me — återanvänd svaret i stället för ett andra anrop. */ const cached = window.HemsEnt && window.HemsEnt.me(); if (cached) { setMe(cached); return; } try { const r = await fetch(`${SETTINGS_BASE_URL}/me?api_key=${encodeURIComponent(SETTINGS_API_KEY)}`, { headers:{ 'Accept':'application/json' } }); if (!r.ok) return; const d = await r.json(); setMe(d); if (d && d.entitlements && window.HemsEnt) setEnt(window.HemsEnt.get()); } catch (_) {} })(); }, []); if (!me || !me.has_subscription) return null; const openPortal = async () => { setErr(null); setBusy(true); try { if (window.HEMS_BASE_IS_LOCAL) { await new Promise(r => setTimeout(r, 600)); setErr('Testläge: skulle öppna Stripes kundportal.'); return; } const r = await fetch(`${SETTINGS_BASE_URL}/billing/portal?api_key=${encodeURIComponent(SETTINGS_API_KEY)}`, { method:'POST', headers:{ 'Accept':'application/json' } }); let data = null; try { data = await r.json(); } catch (_) {} if (!r.ok || !data || !data.url) { setErr('Kunde inte öppna prenumerationen, försök igen.'); return; } window.location.href = data.url; return; } catch (_) { setErr('Kunde inte öppna prenumerationen, försök igen.'); } finally { setBusy(false); } }; const pastDue = me.subscription_status === 'past_due' || ent.reason === 'payment_failed'; /* T8 (trappan): nedräkningen ur entitlements, samma text som spärrsidan. Visas bara när backend skickat ett tal — null ⇒ ingen rad. */ const E = window.HemsEnt; const ctl = E && E.daysPhrase(ent.days_until_control_stops, 'for'); const del = E && E.daysPhrase(ent.days_until_data_deleted, 'in'); const payWarn = E && E.paymentWarning ? E.paymentWarning(ent) : null; /* K8: villkorsraden ur terms.accepted_at / accepted_version. */ const terms = (E && E.terms()) || null; const accepted = terms && terms.accepted_at ? new Date(terms.accepted_at) : null; const acceptedTxt = accepted && !isNaN(accepted) ? `Godkänt ${accepted.toLocaleDateString('sv-SE', { day:'numeric', month:'long', year:'numeric' })} · version ${terms.accepted_version || terms.current_version}` : null; const lnk = { color:'var(--ink)', textDecoration:'underline', textDecorationColor:'var(--ink-3)', textUnderlineOffset:3 }; return (
Plan {SUB_PLAN_NAME[ent.plan || me.plan] || '—'} Status {subStatusText(me.subscription_status, me.current_period_end)}
{/* K5 rev 2: under gratismånaden ingår allt oavsett plan — säg vad som gäller efteråt i stället för att locka med en uppgradering. */} {ent.reason === 'trial' && (
Gratismånad – allt ingår. Efter den gäller {SUB_PLAN_NAME[ent.plan || me.plan] || 'din plan'}.
)} {/* K5: planen räcker inte till elbil/laster — säg det där kunden byter plan. */} {ent.access === 'full' && ent.reason !== 'trial' && (!ent.ev || !ent.loads) && (
Uppgradera för {!ent.ev ? 'elbil och laster' : 'laster'}
)} {/* D1 (rev 2): utebliven betalning — samma mening som bannern på dashboarden. */} {payWarn && (
{payWarn.text}
)} {!payWarn && (ctl || del) && (
{ctl &&
{ent.days_until_control_stops === 0 ? 'HEMS styr din anläggning som vanligt i dag. Sedan upphör styrningen.' : `HEMS styr din anläggning som vanligt ${ctl}.`}
} {del &&
Dina data raderas {del} om abonnemanget inte förnyas.
}
)}
{err &&
{err}
}
{terms && (
{acceptedTxt &&
{acceptedTxt}
}
)}
); } // ── Helper: deep-equal for dirty detection ────────────────────────────── const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b); // ── Timme-väljare för höglastfönstret (2026-07-30) ───────────────────── // "0" och "24" i ett nummerfält var obegripligt för villaägaren. Samma // värden (heltal 0–24) skickas till backend, men UI:t säger "kl 07:00". const HOUR_LABEL = (h) => String(h).padStart(2, '0') + ':00'; const hourOpts = (isEnd) => Array.from({length: 24}, (_, i) => { const h = isEnd ? i + 1 : i; return { value: String(h), label: 'kl ' + HOUR_LABEL(h === 24 ? 0 : h) + (h === 24 ? ' (midnatt)' : '') }; }); function PeakWindow({ start, end, onStart, onEnd, startLabel, endLabel }){ const a = Number(start) || 0, b = Number(end) || 0; const wraps = b <= a; const hours = wraps ? (24 - a) + b : b - a; const allDay = hours === 0 || hours >= 24; return (
onStart(Number(v))} options={hourOpts(false)}/> onEnd(Number(v))} options={hourOpts(true)}/>
{allDay ? 'Höglast gäller dygnet runt — låglastgränsen används aldrig.' : <>Höglast gäller kl {HOUR_LABEL(a)}–{HOUR_LABEL(b === 24 ? 0 : b)}{wraps ? ' (över midnatt)' : ''} — {hours} timmar per dygn. Övrig tid räknas som låglast.}
); } /* ── Förluster in och ut (spec 21/9 B17) ─────────────────────────────── Backend lagrar ANDELEN SOM BLIR KVAR (battery_efficiency 96 = 4 % förlust) och det behåller vi i state — sidan visar och tar emot FÖRLUSTEN. Skälet: hjälptexten "Förlust vid laddning + urladdning (typiskt 85–95%)" fick kunder att skriva databladets tur-och-retur-siffra i ett fält som bara gäller laddning, så förlusten räknades två gånger. */ const lossOf = (share) => (share === '' || share == null) ? '' : Math.round((100 - Number(share)) * 10) / 10; const shareOf = (loss) => (loss === '' || loss == null || loss === '-') ? '' : 100 - Math.min(50, Math.max(0, Number(loss))); // 0–50 % förlust const roundTrip = (effShare, invShare) => { const a = Number(effShare), b = Number(invShare); if (!Number.isFinite(a) || !Number.isFinite(b)) return null; return Math.round(a * b / 100); }; function LossPair({ effShare, invShare, onEff, onInv, disabled }){ const rt = roundTrip(effShare, invShare); return ( <>
onEff(shareOf(v))} help="Så mycket som går förlorat på vägen IN i batteriet, från elmätare eller solceller till cellerna. Typiskt 4–7 %."/> onInv(shareOf(v))} help="Så mycket som går förlorat på vägen UT ur batteriet, från cellerna till huset. Typiskt 4–7 %."/>
{/* Uträknad, inget fält: kunden ska kunna stämma av mot databladet utan att kunna skriva i ett tal som är en följd av de två ovan. */}
Tur och retur: {rt == null ? '—' : rt + ' %'}
Jämför med databladets AC–AC round-trip. Står bara den siffran där, t.ex. 88 %: dela förlusten lika, 6 % + 6 %.
); } /* ── Batterilistan ───────────────────────────────────────────────────── Spec 21/9 (flera batterier). Bakgrunden: med reglaget PÅ stod fälten överst kvar OVANFÖR ett kort som hette "Batteri 1", så listan lästes som batteri 2 och framåt. 11 av 13 prod-hem med lista hade en lista som inte stämde med fälten överst (värsta fallet 130 kWh mot 10 kWh), och en kund fick 400 var 20:e sekund. Regeln är: så fort listan finns är den hela sanningen — backend räknar själv om fälten överst ur listan. Reglagets läge har därför ingen egen lagring (backend har inget multi_battery_enabled): PÅ = listan har minst en post. */ const BATT_EMPTY = { /* Tomma strängar, inte 0 (B5): ett nytt batteri med 0 kWh gick inte att spara — servern kräver minst 0,1 — och felet kom som rå engelsk text. */ capacity_kwh: '', charge_rate_kw: '', discharge_rate_kw: '', soc_min: 5, soc_max: 100, ai_controlled: false, /* Flera batterier = flera växelriktare, så förlusterna är per batteri (B18). Backendens standard 93/97 = 7 % in och 3 % ut. */ battery_efficiency: 93, inverter_efficiency: 97, }; const battFromTop = (t) => ({ capacity_kwh: t.battery_capacity_kwh ?? '', charge_rate_kw: t.battery_charge_rate_kw ?? '', discharge_rate_kw: t.battery_discharge_rate_kw ?? '', soc_min: t.battery_soc_min_pct ?? 5, soc_max: t.battery_soc_max_pct ?? 100, ai_controlled: false, /* Batteri 1 ärver hemmets gemensamma förluster, inte standardvärdena. */ battery_efficiency: t.battery_efficiency_pct ?? 93, inverter_efficiency: t.inverter_efficiency_pct ?? 97, }); const BATT_NUM = ['capacity_kwh', 'charge_rate_kw', 'discharge_rate_kw']; /* Fälten som stoppar Spara, per batteri. Samma gräns som servern: 0,1. */ function battBad(b){ const bad = {}; BATT_NUM.forEach(k => { const v = Number(b && b[k]); if (!(v >= 0.1)) bad[k] = true; }); const lo = Number(b && b.soc_min), hi = Number(b && b.soc_max); if (Number.isFinite(lo) && Number.isFinite(hi) && lo > hi){ bad.soc_min = true; bad.soc_max = true; } return bad; } /* Meddelandet för det FÖRSTA ofullständiga batteriet (B6). Ordningen är medvetet samma som kortens: kunden rättar uppifrån och ner. */ function batteriesErr(list){ const arr = Array.isArray(list) ? list : []; for (let i = 0; i < arr.length; i++){ const bad = battBad(arr[i]); if (BATT_NUM.some(k => bad[k])) return `Batteri ${i+1}: fyll i kapacitet, laddeffekt och urladdningseffekt`; if (bad.soc_min) return `Batteri ${i+1}: min SOC kan inte vara större än max SOC`; } return null; } const battNum = (n) => Number(n || 0).toLocaleString('sv-SE', { maximumFractionDigits: 1 }); function MultiBatteryEditor({ batteries, onChange, showBad, locked, rtShared }){ const upd = (i, patch) => onChange(batteries.map((b, j) => j === i ? { ...b, ...patch } : b)); const add = () => { if (batteries.length < 3) onChange([...batteries, { ...BATT_EMPTY }]); }; const remove = (i) => onChange(batteries.filter((_, j) => j !== i)); /* Summan räknar bara de batterier HEMS styr — ett observerat batteri får aldrig kommandon och ska inte se ut som styrbar kapacitet (B11). */ /* Låst hem med befintlig lista (B16): bara Batteri 1 är HEMS:s, resten läses bara av — så summan räknar bara det. */ const styrda = batteries.filter((b, i) => !b.ai_controlled && !(locked && i > 0)); const sum = (k) => styrda.reduce((t, b) => t + (Number(b[k]) || 0), 0); const allaObserverade = batteries.length > 0 && styrda.length === 0; return (
{batteries.map((b, i) => { /* showBad sätts av Spara-spärren: innan dess markeras bara fält som HAR ett värde som inte går igenom (t.ex. 0), aldrig tomma. */ /* Låsta kort (B16) kan inte rättas, så de ska inte heller stoppa Spara-knappen med röda ramar. */ const ro = !!(locked && i > 0); const raw = ro ? {} : battBad(b); const bad = {}; Object.keys(raw).forEach(k => { bad[k] = showBad || !(b[k] === '' || b[k] == null); }); return (
Batteri {i+1} {locked && i > 0 && ( Ingår i Premium — HEMS läser bara av det här batteriet )} {/* Batteri 1 kan inte tas bort (B8): tillbaka till ett batteri går via reglaget, som också flyttar värdena till fälten överst. En "Ta bort" på Batteri 1 hade tappat dem. */} {i > 0 && ( )}
upd(i, {capacity_kwh: v})}/> upd(i, {charge_rate_kw: v})}/> upd(i, {discharge_rate_kw: v})}/>
upd(i, {soc_min: v})}/> upd(i, {soc_max: v})}/>
{/* Förlusterna är per batteri när listan styr (B18) — varje batteri har sin egen växelriktare. */}
upd(i, {battery_efficiency: v})} onInv={(v)=>upd(i, {inverter_efficiency: v})}/>
{ if (!ro) upd(i, {ai_controlled: v}); }}/>
); })} {allaObserverade && (
HEMS styr inget batteri — minst ett måste vara okryssat för att HEMS ska kunna optimera.
)} {batteries.length > 0 && ( /* Ren text, inget fält (B11): summan är en följd av korten, och ett fält här hade sett ut att gå att skriva i. Tur och retur kommer ur hemmets gemensamma fält (B18) — backend väger ihop batteriernas värden efter kapacitet, sidan räknar inte om det själv. */
Totalt som HEMS styr: {battNum(sum('capacity_kwh'))} kWh {' · '}ladda {battNum(sum('charge_rate_kw'))} kW {' · '}ladda ur {battNum(sum('discharge_rate_kw'))} kW {rtShared != null && <>{' · '}tur och retur {rtShared} %}
)} {batteries.length < 3 && ( /* Låst hem får inte utöka listan (B16) — backend svarar 402 på en utökad lista, och en knapp som bara ger ett fel är ingen knapp. */ )}
); } function BatteryCard({ s, u, uMany, showBad }){ /* B15/B16: flera batterier är en Premium-funktion. Utan rättigheten OCH utan befintlig lista låses reglaget helt; har hemmet redan en lista (lagd under gratismånaden) visas korten men Batteri 2 och 3 är låsta. */ const [ent, setEnt] = React.useState(() => (window.HemsEnt ? window.HemsEnt.get() : { multi_battery:true })); React.useEffect(() => { const on = (ev) => setEnt(ev.detail); window.addEventListener('hems:entitlements', on); return () => window.removeEventListener('hems:entitlements', on); }, []); const [portalBusy, setPortalBusy] = React.useState(false); /* PÅ = listan finns (B4; tidigare length > 1, vilket visade AV för hem med EN post fast listan styrde). Inget eget state: reglaget ÄR listan. */ const multiOn = Array.isArray(s.batteries) && s.batteries.length >= 1; const mayMulti = ent.multi_battery !== false; const lockedList = !mayMulti && multiOn; // B16 const lockedOff = !mayMulti && !multiOn; // B15 const [askOff, setAskOff] = React.useState(false); const upgrade = async () => { if (!window.HemsEnt) return; setPortalBusy(true); await window.HemsEnt.openPortal(); setPortalBusy(false); }; const sharedLoss = ( u('battery_efficiency_pct', v)} onInv={(v)=>u('inverter_efficiency_pct', v)}/> ); const toggle = (on) => { if (on && !mayMulti) return; // reglaget går inte att slå PÅ utan Premium if (on){ /* Två kort direkt (B3): ett enda kort hade lämnat samma tvetydighet som förut — "är fälten överst batteri 1?". Batteri 1 ärver fälten överst så inget värde tappas. */ setAskOff(false); u('batteries', [battFromTop(s), { ...BATT_EMPTY }]); } else if (multiOn){ setAskOff(true); // frågar först (B9) } }; const confirmOff = () => { const b1 = s.batteries[0] || {}; uMany({ battery_capacity_kwh: b1.capacity_kwh ?? 0, battery_charge_rate_kw: b1.charge_rate_kw ?? 0, battery_discharge_rate_kw: b1.discharge_rate_kw ?? 0, battery_soc_min_pct: b1.soc_min ?? 5, battery_soc_max_pct: b1.soc_max ?? 100, /* Även förlusterna följer med tillbaka till de gemensamma fälten (B18) — annars hade hemmet tappat Batteri 1:s värden vid nedgången. */ battery_efficiency_pct: b1.battery_efficiency ?? s.battery_efficiency_pct, inverter_efficiency_pct: b1.inverter_efficiency ?? s.inverter_efficiency_pct, batteries: [], }); setAskOff(false); }; return ( {(window.HemsEnt && window.HemsEnt.LOCK_TEXT.multi_battery) || 'Flera batterier ingår i Premium'} ) : }> {askOff && (
Gå tillbaka till ett batteri? Batteri {s.batteries.length > 2 ? '2 och 3' : '2'} tas bort. Batteri 1:s värden behålls.
)} {/* Fälten överst döljs helt när listan finns (B2). De skickas fortsatt med vid Spara — servern räknar om dem ur listan (B14) — men inget på sidan får se ut som ett extra batteri. */} {!multiOn && (<> u('battery_capacity_kwh', v)} help="Total kapacitet (brutto, innan SOC-gränser)"/>
u('battery_charge_rate_kw', v)}/> u('battery_discharge_rate_kw', v)}/> u('battery_soc_min_pct', v)}/> u('battery_soc_max_pct', v)}/>
{sharedLoss}
)} {multiOn && (<>
Homey måste skicka ett SOC-värde per batteri, i samma ordning som här. Stämmer inte antalet svarar HEMS med fel 400 och tar inte emot någon data.
u('batteries', arr)} showBad={showBad} locked={lockedList} rtShared={roundTrip(s.battery_efficiency_pct, s.inverter_efficiency_pct)}/> {/* Rubriken "Gäller hela anläggningen" (B10) utgår med reglaget PÅ: förlusterna är per batteri (B18) och backend väger ihop dem till de gemensamma fälten. Två uppsättningar hade varit två sanningar. */} )}
); } function SettingsApp(){ const [original, setOriginal] = React.useState(SETTINGS_DEFAULTS); const [s, setS] = React.useState(SETTINGS_DEFAULTS); const [loading, setLoading] = React.useState(true); const [loadErr, setLoadErr] = React.useState(null); /* Spärr (access ≠ full) eller villkorsgrind ⇒ hela sidan ersätts (skickas till dashboardens grind). */ const gated = () => !!(window.HemsEnt && (window.HemsEnt.locked() || window.HemsEnt.termsRequired())); const [blocked, setBlocked] = React.useState(gated); React.useEffect(() => { const on = () => { if (gated()) setBlocked(true); }; window.addEventListener('hems:entitlements', on); return () => window.removeEventListener('hems:entitlements', on); }, []); const [saving, setSaving] = React.useState(false); const [status, setStatus] = React.useState(null); // {kind:'ok'|'err'|'info', msg} // Load real settings on mount // Engångsstädning av den rivna bil-skuggan (2026-07-30). React.useEffect(() => { window.evModel?.purgeLegacyCarShadow?.(); }, []); /* Automatiska reservvärdet till placeholder (kontrakt 1/9): visar vad "tomt" betyder i praktiken. Tyst vid fel — placeholdern blir generisk. RAW FETCH, inte window.hems: fasaden konfigureras aldrig med en sessionsnyckel på den här sidan (settings.html använder SETTINGS_API_KEY överallt), så getPeak() kastade "ingen sessionsnyckel satt än" och catch:en svalde det tyst — placeholdern kunde ALDRIG bli annat än fallbacken. Samma mönster som den frivilliga live-data-hinten i settings-ev.jsx:552. */ const [autoReserve, setAutoReserve] = React.useState(null); /* v746 (TILL_DESIGN_aktiv_grans_effektavgift, ärende P5N-1): "Aktiv gräns just nu" läses ur samma svar, peak_shaving.is_peak_now och max_grid_kw, som räknas av samma funktion som styrningen (peak_limit_for_hour). Sidan räknar aldrig fram säsong eller klockslag själv. max_grid_kw läses bara när is_peak_now är true (annars är det enkla lägets värde). null = ingen ruta: anropet misslyckas, effektavgiften är avstängd på servern eller svaret saknar värdet. */ const [peakNow, setPeakNow] = React.useState(null); const peakHourRef = React.useRef(null); const loadPeak = React.useCallback(async () => { const hour = (window.hemsDate ? window.hemsDate() : new Date()).getHours(); peakHourRef.current = hour; try { const r = await fetch(`${SETTINGS_BASE_URL}/api/peak-insights?api_key=${encodeURIComponent(SETTINGS_API_KEY)}`, { headers: { 'Accept': 'application/json' } }); if (!r.ok) { setPeakNow(null); return; } const p = await r.json(); const ps = p?.peak_shaving; const sr = ps?.soc_reserve; if (sr?.kwh != null) setAutoReserve(sr); const kw = ps && ps.max_grid_kw != null ? Number(ps.max_grid_kw) : NaN; setPeakNow(!ps || ps.enabled === false ? null : ps.is_peak_now === true ? (Number.isFinite(kw) ? { on: true, kw, hour } : null) : ps.is_peak_now === false ? { on: false, hour } : null); } catch(e){ setPeakNow(null); } }, []); React.useEffect(() => { loadPeak(); /* Gränsen kan bytas vid heltimme (t.ex. höglast 06–21): hämtas igen när timmen bytts och sidan syns. */ const again = () => { if (!document.hidden && (window.hemsDate ? window.hemsDate() : new Date()).getHours() !== peakHourRef.current) loadPeak(); }; const iv = setInterval(again, 60000); document.addEventListener('visibilitychange', again); return () => { clearInterval(iv); document.removeEventListener('visibilitychange', again); }; }, [loadPeak]); React.useEffect(() => { (async () => { /* A1 (rev 4): vänta in /me innan något inställningsanrop går iväg. Är prenumerationen utgången görs inget anrop alls — sidan visar utgången-vyn direkt i stället för att samla 402:or. */ if (window.HemsEnt) { try { await window.HemsEnt.load(); } catch(_){} if (gated()) { setBlocked(true); setLoading(false); return; } } try { const url = `${SETTINGS_BASE_URL}/settings/api/${SETTINGS_API_KEY}`; const r = await fetch(url); /* 402 = planspärr (kravlista 2026-09-15, K1/K4). subscription_ended ⇒ utgången prenumeration: visa utgången-vyn i stället för inläsningsfel. plan_bas/plan_standard låser bara sektionerna. */ if (r.status === 402) { const b = await r.json().catch(() => null); const ended = window.HemsEnt ? window.HemsEnt.note402(b) : true; if (ended) { setBlocked(true); setLoading(false); return; } } if (r.status === 403 && window.HemsEnt) { const b = await r.clone().json().catch(() => null); if (window.HemsEnt.note403(b)) { setBlocked(true); setLoading(false); return; } } // #294: död session → rensa token + tillbaka till login (aldrig felloop) if (r.status === 401) { localStorage.removeItem('hems_session_token'); window.location.href = 'login.html?expired=1'; return; } if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json(); // Säkerhetsfix 2026-05-29: backend skickar solar_arrays: [{kwp, azimuth, // tilt}, ...] som array, men UI har flata fält solar_array_1_kwp etc. // Mappar arrayen → flata fält så Lennies riktiga sol-config visas // istället för defaults (= Andrés värden i föregående bug). const arr = Array.isArray(data.solar_arrays) ? data.solar_arrays : []; const flatSolar = {}; for (let i = 0; i < 3; i++) { const a = arr[i] || {}; flatSolar[`solar_array_${i+1}_kwp`] = a.kwp ?? 0; flatSolar[`solar_array_${i+1}_azimuth`] = a.azimuth ?? 180; flatSolar[`solar_array_${i+1}_tilt`] = a.tilt ?? 30; } // Backend-namn → frontend-namn-mapping för fält som heter olika. // Bygger på fullständig audit 2026-05-29 av staging-users Lennie + André. // Använder ?? istället för || så 0 (giltigt värde) inte ersätts av default. const renamed = { // Position latitude: data.lat ?? data.latitude ?? 0, longitude: data.lon ?? data.longitude ?? 0, // SOC + moms battery_soc_min_pct: data.soc_min ?? data.battery_soc_min_pct ?? 5, battery_soc_max_pct: data.soc_max ?? data.battery_soc_max_pct ?? 100, vat_pct: data.vat_percent ?? data.vat_pct ?? 25, // Versionsnumret följer med orört genom hela rundgången (se toBackend). settings_rev: data.settings_rev ?? null, // Batteri-verkningsgrader battery_efficiency_pct: data.battery_efficiency ?? data.battery_efficiency_pct ?? 98, inverter_efficiency_pct: data.inverter_efficiency ?? data.inverter_efficiency_pct ?? 96, // EV-pause pause_battery_on_ev_charging: data.ev_pause_battery ?? data.pause_battery_on_ev_charging ?? false, /* Enheter + teckenvändning (André 23/9): backend lagrar power_unit / ev_power_unit ('w'|'kw') och _negative. UI-namnen lästes rakt ur svaret, fanns aldrig där, och rutorna stod alltid tomma efter omladdning oavsett vad som sparats. */ /* Saknat power_unit = kW, samma default som backend (backend 23/9: Homey-svaret räknas om till W bara vid "w"; 34 av 180 hem saknar fältet). ev_power_unit har default "w" i backend — tom ruta rätt. */ homey_power_in_kw: data.power_unit != null ? String(data.power_unit).toLowerCase() === 'kw' : true, ev_charger_power_in_kw: data.ev_power_unit != null ? String(data.ev_power_unit).toLowerCase() === 'kw' : !!data.ev_charger_power_in_kw, negate_charge: data.charge_negative ?? data.negate_charge ?? false, negate_chargesolar: data.chargesolar_negative ?? data.negate_chargesolar ?? false, negate_selfconsumption: data.selfconsumption_negative ?? data.negate_selfconsumption ?? false, negate_peakshaving: data.peakshaving_negative ?? data.negate_peakshaving ?? false, negate_export: data.export_negative ?? data.negate_export ?? false, // Elbil & skjutbara laster — nested objekt + array rakt från backend; // saknade nycklar får form från defaults så UI:t aldrig kraschar. ev_charging: { ...SETTINGS_DEFAULTS.ev_charging, ...(data.ev_charging || {}) }, // Multi-EV (§5): normalisera till lista (migrerar singular vid behov). ev_chargers: (window.evModel ? window.evModel.normalize(data) : []), deferrable_loads: Array.isArray(data.deferrable_loads) ? data.deferrable_loads : [], // Ekonomi köp (öre/kWh — backend skickar samma siffra med _kwh-suffix) grid_fee_ore: data.grid_fee_kwh ?? data.grid_fee_ore ?? 0, energy_tax_ore: data.energy_tax_kwh ?? data.energy_tax_ore ?? 0, retailer_margin_ore: data.elhandel_margin_kwh ?? data.retailer_margin_ore ?? 0, // Ekonomi sälj grid_benefit_ore: data.grid_benefit_kwh ?? data.grid_benefit_ore ?? 0, sale_margin_ore: data.sell_margin_kwh ?? data.sale_margin_ore ?? 0, cycle_cost_ore: data.cycle_cost_kwh ?? data.cycle_cost_ore ?? 10, /* Listan normaliseras till array (backend skickar lista eller null). Reglagets läge lagras inte längre — det härleds ur listans längd i BatteryCard (B4). */ /* Poster utan egna förlustvärden (null) visar hemmets gemensamma värde och sparas som eget värde vid nästa Spara (B18). */ batteries: (Array.isArray(data.batteries) ? data.batteries : []).map(b => ({ ...b, battery_efficiency: b.battery_efficiency ?? data.battery_efficiency ?? data.battery_efficiency_pct ?? 93, inverter_efficiency: b.inverter_efficiency ?? data.inverter_efficiency ?? data.inverter_efficiency_pct ?? 97, })), /* Peak shaving. enabled/mode lästes ALDRIG in (kritiskt, backend 31/8): kortet visade alltid av + "Enkel" och aktivering sparades aldrig. mode BEVARAS ordagrant även när värdet är okänt — klubbas det till default skriver nästa spara sönder användarens läge (punkt 5). */ peak_shaving_enabled: data.peak_shaving_enabled === true, peak_shaving_mode: data.peak_shaving_mode || 'simple', peak_max_grid_kw: data.peak_shaving_max_grid_kw ?? 5, peak_cost_kr_per_kw: data.peak_shaving_cost_kr_per_kw ?? 50, peak_reserve_kwh: data.peak_reserve_kwh ?? '', peak_day_max_kw: data.peak_shaving_day_max_kw ?? 0, peak_night_max_kw: data.peak_shaving_night_max_kw ?? 0, peak_day_start_h: data.peak_shaving_day_start ?? 6, peak_day_end_h: data.peak_shaving_day_end ?? 22, // period1 = vinter (high/low), period2 = sommar peak_winter_high_start_h: data.peak_shaving_period1_day_start ?? 6, peak_winter_high_end_h: data.peak_shaving_period1_day_end ?? 21, peak_winter_high_max_kw: data.peak_shaving_period1_day_max_kw ?? 0, peak_winter_high_cost_kr_per_kw: data.peak_shaving_period1_day_cost_kr ?? 0, peak_winter_low_max_kw: data.peak_shaving_period1_night_max_kw ?? 0, peak_winter_low_cost_kr_per_kw: data.peak_shaving_period1_night_cost_kr ?? 0, peak_winter_months: Array.isArray(data.peak_shaving_period1_months) ? data.peak_shaving_period1_months : [], peak_summer_max_kw: data.peak_shaving_period2_max_kw ?? 0, peak_summer_cost_kr_per_kw: data.peak_shaving_period2_cost_kr ?? 0, peak_summer_months: Array.isArray(data.peak_shaving_period2_months) ? data.peak_shaving_period2_months : [], // Driftläge (2026-07-30): operating_mode läses INTE längre. // Master-gate + rattar: fältet saknas i svaret ⇒ ojusterat (null), // inte 0 — därför `in`-koll och inte ?? mot flottans default. // Master-gate = TRESTAT (SPEC_DRIFTLAGE_UI_V2 §2.2). Läs ALDRIG // `!!data.user_mode_enabled`: null ⇒ false vore fel för legacy-users // med satta rattar. true/false används rakt av; null/saknas = // användaren har aldrig sparat sedan backend-fixen — motorn kör då // rattarna, så UI:t härleds ur dem (någon ratt satt ⇒ Egna inställningar). user_mode_enabled: (data.user_mode_enabled === true || data.user_mode_enabled === false) ? data.user_mode_enabled : (window.MODE_KNOB_FIELDS || []).some(f => data[f] !== null && data[f] !== undefined && data[f] !== ''), traj_premie_ore_per_kwh_h: ('traj_premie_ore_per_kwh_h' in data) ? data.traj_premie_ore_per_kwh_h : null, sellsolar_min_price_ore: ('sellsolar_min_price_ore' in data) ? data.sellsolar_min_price_ore : null, maxprofit_import_aversion_kr: ('maxprofit_import_aversion_kr' in data) ? data.maxprofit_import_aversion_kr : null, soc_planning_buffer_pct: ('soc_planning_buffer_pct' in data) ? data.soc_planning_buffer_pct : null, soc_buffer_release_ore: ('soc_buffer_release_ore' in data) ? data.soc_buffer_release_ore : null, }; // Filtrera bort password_hash och api_key_hash — ingen frontend ska se dem const safeData = { ...data }; delete safeData.password_hash; delete safeData.password_set_at; delete safeData.api_key_hash; // Merge defaults så saknade nycklar har form const merged = { ...SETTINGS_DEFAULTS, ...safeData, ...flatSolar, ...renamed }; setOriginal(merged); setS(merged); } catch (e){ console.warn('Settings load failed, using defaults', e); setLoadErr(String(e.message || e)); } setLoading(false); })(); }, []); // Update a single field const u = (key, val) => setS(prev => ({...prev, [key]: val})); /* Flera fält i ETT svep: reglaget AV flyttar Batteri 1 till fälten överst och tömmer listan, och en halv flytt hade lämnat en osann sida. */ const uMany = (patch) => setS(prev => ({...prev, ...patch})); /* Sätts när Spara stoppats av ett ofullständigt batteri — då, men inte tidigare, ramas de tomma fälten in. */ const [battShowBad, setBattShowBad] = React.useState(false); const isDirty = !eq(s, original); /* v746: osparade ändringar i Effektavgift. Rutan Aktiv gräns bygger på sparade inställningar och säger det då. */ const peakDirty = !!original && Object.keys(s).some(k => k.startsWith('peak_') && !eq(s[k], original[k])); // Reverse-mapping UI-fält → backend-namn. Speglar GET-mappingen ovan så // Lennies sparade ändringar når rätt fält i Firestore. Skickar BÅDA namnen // (UI + backend) för felräddande — backend väljer det den känner igen. const toBackend = (ui) => { const arrs = [0,1,2].map(i => ({ kwp: ui[`solar_array_${i+1}_kwp`] ?? 0, azimuth: ui[`solar_array_${i+1}_azimuth`] ?? 180, tilt: ui[`solar_array_${i+1}_tilt`] ?? 30, })).filter(a => a.kwp > 0); // Driftläge (2026-07-30): self_sufficiency är rivet. operating_mode och de // gamla MSC/självförsörjnings-fälten destruktureras ut så de ALDRIG följer // med via ...ui (backend tolererar men skriver aldrig operating_mode). // Rattfälten plockas också ut här och läggs tillbaka villkorligt nedan — // ett ojusterat fält får inte skickas alls. const { operating_mode: _deadMode, maximize_self_consumption, maximize_self_consumption_enabled, self_sufficiency_mode, self_sufficiency_threshold_pct, self_sufficiency_penalty_ore, minimum_export_profit_ore, msc_export_min_profit_ore, traj_premie_ore_per_kwh_h: _k1, sellsolar_min_price_ore: _k2, maxprofit_import_aversion_kr: _k3, soc_planning_buffer_pct: _k4, soc_buffer_release_ore: _k5, ev_charging: _legacyEvCharging, settings_rev: _rev, ...uiClean } = ui; const evIdent = !!(window.evModel?.identityMode && window.evModel.identityMode(ui)); // Rattar (§3.3): satt värde skickas; ojusterat utelämnas; en ratt som VAR // satt och nu är återställd skickas som explicit null (backend har // None-pop-skydd — nollställningsvägen är koordinerad OCH driftsatt 1/8: // `null` når databasen på riktigt, ghost-läget efter omladdning är korrekt). const knobs = {}; for (const f of (window.MODE_KNOB_FIELDS || [])){ const v = ui[f]; const wasSet = original[f] !== null && original[f] !== undefined && original[f] !== ''; if (v !== null && v !== undefined && v !== '') knobs[f] = Number(v); else if (wasSet) knobs[f] = null; } return { ...uiClean, ...knobs, // Master-gate: skickas alltid (bool, inget ghost-begrepp) user_mode_enabled: !!ui.user_mode_enabled, /* Backend trimmar och gemenar adressen (21/9). Vi skickar den redan normaliserad så fältet efter Spara visar det som faktiskt sparades — annars ser "Anna@…" ut att ligga i databasen fast inloggningen är "anna@…" (spec 21/9 E3). Tomt fält skickas som tomt: backend låter den sparade adressen ligga kvar. Ingen validering här — servern avgör vad som är en giltig adress. */ email: (ui.email || '').trim().toLowerCase(), /* Listan skickas alltid som array (B1): utelämnad eller gammal lista lämnade backend kvar i flerbatteriläge fast reglaget var AV, och listan fortsatte styra. Tom lista = fälten överst gäller igen. Talen tvättas till number — fälten kan bära '' medan man skriver, och Spara är spärrad så länge något är ofullständigt. */ batteries: (Array.isArray(ui.batteries) ? ui.batteries : []).map(b => ({ ...b, capacity_kwh: Number(b.capacity_kwh) || 0, charge_rate_kw: Number(b.charge_rate_kw) || 0, discharge_rate_kw: Number(b.discharge_rate_kw) || 0, soc_min: Number(b.soc_min) || 0, soc_max: Number(b.soc_max) || 100, battery_efficiency: Number(b.battery_efficiency) || 93, inverter_efficiency: Number(b.inverter_efficiency) || 97, })), // Position lat: ui.latitude, lon: ui.longitude, // SOC + moms soc_min: ui.battery_soc_min_pct, soc_max: ui.battery_soc_max_pct, vat_percent: ui.vat_pct, // Skickas tillbaka så backend kan se om någon annan hann skriva emellan. // Utelämnas när dokumentet inte bar något (äldre backend). ...(_rev != null ? { settings_rev: _rev } : {}), // Batteri-verkningsgrader battery_efficiency: ui.battery_efficiency_pct, inverter_efficiency: ui.inverter_efficiency_pct, // EV-pause ev_pause_battery: ui.pause_battery_on_ev_charging, // Enheter + teckenvändning under backendens namn (se GET-mappningen) power_unit: ui.homey_power_in_kw ? 'kw' : 'w', ev_power_unit: ui.ev_charger_power_in_kw ? 'kw' : 'w', charge_negative: !!ui.negate_charge, chargesolar_negative: !!ui.negate_chargesolar, selfconsumption_negative: !!ui.negate_selfconsumption, peakshaving_negative: !!ui.negate_peakshaving, export_negative: !!ui.negate_export, // Multi-EV (§5): skicka ev_chargers-array (backend läser den när den finns); // gamla singular ev_charging strippades ur payloaden ovan. id skickas // ALLTID när det finns — även för nya boxar: UI:t genererar ett id som är // unikt mot listan (evModel.nextChargerId) och det är kontraktsnyckeln. // Id-lösa rader skulle tvinga backend att koppla på positionsordning, // vilket går sönder vid borttagning/omordning. /* Identitetsläget (TILL_DESIGN_laddbox_bil_identitet 3/10, D5): _new följer med på nya boxar och uid på befintliga, slot skickas aldrig tillbaka och sidan skapar inget id. Utan identitet: som förut. */ ev_chargers: (Array.isArray(ui.ev_chargers) ? ui.ev_chargers : []) .map(c => { const rest = { ...(c || {}) }; const id = rest.id; delete rest.id; delete rest.slot; if (evIdent) { if (rest._new === true) delete rest.uid; else delete rest._new; } else delete rest._new; return (id != null && id !== '') ? { id, ...rest } : rest; }), // Bilprofiler (SPEC_BILPROFILER_PER_BOX): bilar ≠ boxar. Skickas bara när // användaren faktiskt har rört dem — härledda rader (som bara speglar // boxarnas battery_kwh) ska inte skapa profiler i backend i förtid. // SPEC_EV_FASER_PER_BIL §5.4: `phases` bärs bara när användaren valt // 1 eller 3 — vid "Som laddboxen" utelämnas nyckeln HELT (inte null, // inte 0, inte ""). Vakten här är sista ledet; UI:t deletar redan. ...(Array.isArray(ui.ev_cars) ? { ev_cars: ui.ev_cars .map(c => { const rest = { ...(c || {}) }; delete rest._derived; delete rest._new; if (Number(rest.phases) !== 1 && Number(rest.phases) !== 3) delete rest.phases; else rest.phases = Number(rest.phases); return rest; }) .filter(c => c.id != null && c.id !== '') } : {}), /* Skjutbara laster: id sätts av backend — skicka ALDRIG id för nya laster (tomt/saknat), behåll orört för befintliga. Radering = borta ur arrayen. lwToPayload är OBLIGATORISK här, inte en finess: en legacy-last utan `windows` visas som en fönsterrad i editorn, och spec §2 kräver att raden SKRIVS som `windows` vid nästa spar. Ändrade användaren bara namnet gick lasten tidigare tillbaka i legacy-form härifrån — tyst, eftersom V5 uppfylls av toppnivåns duration_hours. Rörde han ett fönster på en last men bara namnet på en annan migrerade den ena och inte den andra, i samma POST, och båda såg likadana ut i UI:t. Samma normalisering som gc-last.jsx och ev-loads.jsx. */ deferrable_loads: (Array.isArray(ui.deferrable_loads) ? ui.deferrable_loads : []) .map(l => { const rest = window.lwToPayload ? window.lwToPayload(l || {}) : { ...(l || {}) }; const id = rest.id; delete rest.id; return id != null && id !== '' ? { id, ...rest } : rest; }), // Ekonomi grid_fee_kwh: ui.grid_fee_ore, energy_tax_kwh: ui.energy_tax_ore, elhandel_margin_kwh: ui.retailer_margin_ore, grid_benefit_kwh: ui.grid_benefit_ore, sell_margin_kwh: ui.sale_margin_ore, cycle_cost_kwh: ui.cycle_cost_ore, // Sol — flata UI-fält → array solar_arrays: arrs, // Peak shaving — enabled/mode ingick inte i payloaden (kritiskt 31/8). peak_shaving_enabled: ui.peak_shaving_enabled, peak_shaving_mode: ui.peak_shaving_mode, /* SANERING vid varje Enkel-sparning (backend 1/9): fyra dolda legacy- fält (months/weekdays/start/end) gate:ar NÄR gränsen gäller. Två testanvändare hade gamla vinter/vardag/dagtid-värden kvar i dokumentet — "en gräns hela året" var i praktiken avstängd hela september utan att något syntes. simple ⇒ hela året/alla dagar/hela dygnet, exakt vad etiketten lovar (månader 1–12 som build 189, veckodagar 0=mån). Övriga lägen skickar dem INTE — de har egna periodfält, och medvetet sparade filter från gamla systemet får inte skrivas över. */ ...(ui.peak_shaving_mode === 'simple' ? { peak_shaving_months: [1,2,3,4,5,6,7,8,9,10,11,12], peak_shaving_weekdays: [0,1,2,3,4,5,6], peak_shaving_start_hour: 0, peak_shaving_end_hour: 24, } : {}), /* Dag/Natt sanerar samma months/weekdays (backend 1/9): SOC-reserven grindas på dem, så gamla vinter/vardag-värden tog bort beredskaps- bufferten sommartid och helger. start/end skickas INTE — Dag/Natt har egna dag-/nattfönster. */ ...(ui.peak_shaving_mode === 'day_night' ? { peak_shaving_months: [1,2,3,4,5,6,7,8,9,10,11,12], peak_shaving_weekdays: [0,1,2,3,4,5,6], } : {}), peak_shaving_max_grid_kw: ui.peak_max_grid_kw, peak_shaving_cost_kr_per_kw: ui.peak_cost_kr_per_kw, /* Reserv för toppar (kontrakt 1/9 punkt A): tomt fält ELLER 0 ⇒ null (automatik), ALDRIG 0 i payloaden. "0" skrevs tidigare om till 1 kWh, alltså raka motsatsen till vad användaren menade — den som skriver 0 vill inte reservera något själv och låter HEMS räkna. Talet klampas INTE längre tyst: ligger det utanför 1–50 står det rött vid fältet och sparningen stoppas innan den går i vägen, för ett tyst klamp gav användaren ett annat värde än det han lämnade. */ peak_reserve_kwh: peakReserveOut(ui.peak_reserve_kwh), peak_shaving_day_max_kw: ui.peak_day_max_kw, peak_shaving_night_max_kw: ui.peak_night_max_kw, peak_shaving_day_start: ui.peak_day_start_h, peak_shaving_day_end: ui.peak_day_end_h, // UI-namn → period1/period2 peak_shaving_period1_day_start: ui.peak_winter_high_start_h, peak_shaving_period1_day_end: ui.peak_winter_high_end_h, peak_shaving_period1_day_max_kw: ui.peak_winter_high_max_kw, peak_shaving_period1_day_cost_kr: ui.peak_winter_high_cost_kr_per_kw, peak_shaving_period1_night_max_kw: ui.peak_winter_low_max_kw, peak_shaving_period1_night_cost_kr: ui.peak_winter_low_cost_kr_per_kw, peak_shaving_period1_months: ui.peak_winter_months, peak_shaving_period2_max_kw: ui.peak_summer_max_kw, peak_shaving_period2_cost_kr: ui.peak_summer_cost_kr_per_kw, peak_shaving_period2_months: ui.peak_summer_months, }; }; // Save (POST full object back) const save = async () => { // Spärr: dubblett-id bland laddboxar skulle låta två bilar dela // sessionsnyckel i backend (SOC hoppar mellan bilarna). Blockera POST. const dupIds = window.evModel?.duplicateChargerIds ? window.evModel.duplicateChargerIds(s.ev_chargers) : []; if (dupIds.length) { setStatus({kind:'err', msg:`Kunde inte spara: två laddboxar har samma id (${dupIds.join(', ')}). Ta bort och lägg till bilen igen.`}); return; } // Samma spärr för bilprofiler — car_id är nyckeln sessionen pekar på. const dupCars = window.evModel?.duplicateChargerIds ? window.evModel.duplicateChargerIds(s.ev_cars) : []; if (dupCars.length) { setStatus({kind:'err', msg:`Kunde inte spara: två bilar har samma id (${dupCars.join(', ')}). Ta bort och lägg till bilen igen.`}); return; } /* Ofullständigt batteri stoppas här: servern kräver minst 0,1 per fält och svarade annars med pydantics engelska fältfel var 20:e sekund (B6). */ const battErr = batteriesErr(s.batteries); if (battErr) { setBattShowBad(true); setStatus({kind:'err', msg: `Kunde inte spara: ${battErr}`}); return; } setBattShowBad(false); /* Reserven stoppas HÄR i stället för att klampas tyst (kontrakt 1/9 A). Backend 422:ar utanför 1–50, och ett tyst klamp hade sparat ett annat värde än det som står i fältet. */ const resErr = peakReserveErr(s.peak_reserve_kwh); if (resErr) { setStatus({kind:'err', msg:`Kunde inte spara: ${resErr}`}); return; } /* Effekten på en skjutbar last måste vara > 0 — backend avvisar hela dokumentet annars, och då ser det ut som att inget alls fastnade (Andrés fall 2/9). Namnet på lasten pekas ut, inte fältnumret. */ const badLoad = (Array.isArray(s.deferrable_loads) ? s.deferrable_loads : []) .find(l => l && l.enabled !== false && !(Number(l.power_kw) > 0)); if (badLoad) { setStatus({kind:'err', msg:`Kunde inte spara: lasten "${badLoad.name || 'utan namn'}" har ingen effekt. Ange effekt i kW större än 0.`}); return; } /* Fönsterreglerna speglas också här: ett överlapp byggt på inställningssidan gick annars till backend och kom tillbaka som 422 — läsbart sedan settings422, men en onödig rundtur. */ if (window.lwValidate && window.lwFromLoad) { const loadsArr = Array.isArray(s.deferrable_loads) ? s.deferrable_loads : []; for (let i = 0; i < loadsArr.length; i++) { const e = window.lwValidate(window.lwFromLoad(loadsArr[i])); const k = Object.keys(e)[0]; if (k != null) { const nm = (loadsArr[i] && loadsArr[i].name) || `Last ${i + 1}`; setStatus({kind:'err', msg:`Kunde inte spara: ${nm}, fönster ${Number(k) + 1} — ${e[k]}`}); return; } } } /* Laddström (TILL_DESIGN_lagstrom_under_6A, 28/9): lägsta 1–32 A och max 1–63 A. Under 6 A stoppar inte sparningen; då gäller 6 A tills kunden bekräftat på boxen. */ const curBad = (Array.isArray(s.ev_chargers) ? s.ev_chargers : []).map((c, i) => { const e = window.evModel?.currentFieldErr ? window.evModel.currentFieldErr(c) : {}; const nm = (c && c.name && String(c.name).trim()) || `Laddbox ${i + 1}`; /* Utan effektstyrning (8/10) finns bara Laddström; lägsta kontrolleras då inte (currentFieldErr ger min: null). */ if (e.min) return `${nm}: lägsta laddström ${e.min === 'Krävs' ? 'saknas' : /^Högre/.test(e.min) ? 'är högre än max laddström' : 'ska vara hela ampere, 1–32 A'}.`; if (e.max) return `${nm}: ${c && c.variable_current === false ? 'laddström' : 'max laddström'} ${e.max === 'Krävs' ? 'saknas' : 'ska vara hela ampere, 1–63 A'}.`; return null; }).find(Boolean); if (curBad) { setStatus({kind:'err', msg:`Kunde inte spara: ${curBad}`}); return; } /* Laddbox utan bil (TILL_DESIGN_laddbox_enda_bilen_koppling 4/10, krav 3): fast bil utan koppling och med batteri under 1 kWh sparas inte tyst. Samma text står vid boxen. Gäller inte när planen saknar elbil: då är sektionen låst och kunden kan inte välja. */ const evOpen = !window.HemsEnt || window.HemsEnt.get().ev !== false; const carsNow = window.evModel?.normalizeCars ? window.evModel.normalizeCars(s) : []; const noCar = !evOpen || !window.evModel?.boxNeedsCar ? null : (Array.isArray(s.ev_chargers) ? s.ev_chargers : []).map((c, i) => { if (!window.evModel.boxNeedsCar(c, carsNow)) return null; const nm = (c && c.name && String(c.name).trim()) || `Laddbox ${i + 1}`; return carsNow.length ? `välj vilken bil som laddas i ${nm}.` : `lägg till bilen som laddas i ${nm} under Bilar.`; }).find(Boolean); if (noCar) { setStatus({kind:'err', msg:`Kunde inte spara: ${noCar}`}); return; } setSaving(true); setStatus({kind:'info', msg:'Sparar…'}); try { const url = `${SETTINGS_BASE_URL}/settings/api/${SETTINGS_API_KEY}`; const payload = toBackend(s); const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-HEMS-Source': 'installningar' }, body: JSON.stringify(payload), }); /* 402 = planspärr (kravlista 2026-09-15, K4). subscription_ended ⇒ utgången prenumeration, HemsEnt byter hela vyn; plan_bas/plan_standard ⇒ visa backends message som den är med Uppgradera. */ if (r.status === 402) { const b = await r.json().catch(() => null); const ended = window.HemsEnt ? window.HemsEnt.note402(b) : true; setStatus({kind:'err', msg: window.HemsEnt ? window.HemsEnt.msg402(b, ended ? undefined : 'Funktionen ingår inte i din plan.') : 'Funktionen ingår inte i din plan.', upgrade: true}); setSaving(false); return; } // #294: död session → rensa token + tillbaka till login if (r.status === 401) { localStorage.removeItem('hems_session_token'); window.location.href = 'login.html?expired=1'; return; } if (r.status === 409){ /* Bara revisionskonflikten finns kvar (backend 4/10: controller_box_list_locked är borttagen). Annan kod med färdig text visas som den står. */ const b409 = await r.json().catch(() => null); const d409 = b409 && b409.detail; if (d409 && typeof d409 === 'object' && !Array.isArray(d409) && d409.error && d409.error !== 'settings_rev_conflict' && d409.message){ setStatus({kind:'err', msg: String(d409.message)}); setSaving(false); return; } setStatus({kind:'err', msg:'Inställningarna ändrades på annat håll medan du redigerade. Läs in sidan igen så du inte skriver över den ändringen.'}); setSaving(false); return; } if (!r.ok){ /* 422 är ett fältsvar, inte ett tekniskt fel: läs body:n och peka ut fältet i klartext i stället för att klistra in rå JSON. */ if (r.status === 422){ const b = await r.json().catch(()=>null); /* Identitetsläget: charger_unknown, car_unknown, duplicate_charger och too_many_chargers bär färdig text i detail.message. */ const dm = b && b.detail && typeof b.detail === 'object' && !Array.isArray(b.detail) ? b.detail.message : null; if (dm) { setStatus({kind:'err', msg: String(dm)}); setSaving(false); return; } const txt = settings422(b); setStatus({kind:'err', msg: txt ? `Kunde inte spara — ${txt}` : 'Kunde inte spara: ett fält avvisades av HEMS.'}); setSaving(false); return; } /* 400 från den här rutten bär färdig svensk text i detail (backend 21/9: "Ogiltig e-postadress", "E-postadressen används redan av ett annat konto"). Utan denna gren föll den till throw nedan och kunden fick "HTTP 400 — {"detail":…}" med rå JSON i statusraden. Gäller alla 400, inte bara e-post. */ if (r.status === 400){ const b = await r.json().catch(()=>null); const det = b && b.detail; setStatus({kind:'err', msg: (typeof det === 'string' && det) ? `Kunde inte spara — ${det}` : 'Kunde inte spara: ett fält avvisades av HEMS.'}); setSaving(false); return; } /* Övriga fel med färdig text i detail.message (backend 4/10: 404 charger_not_found och feature_disabled, 503 sessions_unavailable) visas som de står. */ const t = await r.text().catch(()=>String(r.status)); let jt = null; try { jt = JSON.parse(t); } catch(_){} const dt = jt && jt.detail && typeof jt.detail === 'object' && !Array.isArray(jt.detail) ? jt.detail.message : null; if (dt) { setStatus({kind:'err', msg: String(dt)}); setSaving(false); return; } throw new Error(`HTTP ${r.status} — ${t.slice(0, 80)}`); } const result = await r.json(); // Nytt versionsnummer ur svaret — annars spar man bara en gång per // sidladdning innan numret är inaktuellt. /* Samma normalisering tillbaka i state: fältet i Ditt hem och initialEmail till Byt lösenord visar adressen som den sparades. */ const savedEmail = (s.email || '').trim().toLowerCase(); const patch = {}; /* Backend väger ihop batteriernas förluster efter kapacitet och skriver de gemensamma fälten (B18). Bär svaret dem tar vi emot dem — annars rör vi dem inte; sidan räknar aldrig om dem själv. */ const back = (result && typeof result === 'object') ? (result.settings && typeof result.settings === 'object' ? result.settings : result) : null; if (back && typeof back.battery_efficiency === 'number') patch.battery_efficiency_pct = back.battery_efficiency; if (back && typeof back.inverter_efficiency === 'number') patch.inverter_efficiency_pct = back.inverter_efficiency; if (result && result.settings_rev != null) patch.settings_rev = result.settings_rev; if (savedEmail !== s.email) patch.email = savedEmail; /* Identitetsläget (D5): svaret innehåller inte boxarna. Läs om GET så nya boxar får sitt uid och sin slot. Går det inte tas _new bort, så en ny sparning aldrig kan skapa boxen en gång till. */ let evReadFail = false; if (window.evModel?.identityMode && window.evModel.identityMode(s)) { try { const rr = await fetch(url, { headers: { 'Accept': 'application/json' } }); if (!rr.ok) throw new Error('GET ' + rr.status); const fresh = await rr.json(); patch.ev_chargers = window.evModel.normalize(fresh); if (Array.isArray(fresh.ev_cars)) patch.ev_cars = fresh.ev_cars; ['ev_sessions','charger_approval','ev_box_events','ev_retired_uids','ev_retired_car_ids'].forEach(k => { if (k in fresh) patch[k] = fresh[k]; }); if (fresh.settings_rev != null) patch.settings_rev = fresh.settings_rev; } catch (_) { patch.ev_chargers = (Array.isArray(s.ev_chargers) ? s.ev_chargers : []).map(c => { const x = { ...c }; delete x._new; return x; }); evReadFail = true; } } const next = Object.keys(patch).length ? { ...s, ...patch } : s; if (next !== s) setS(next); setOriginal(next); loadPeak(); /* v746: serverns gällande gräns efter Spara. */ /* v753: laddboxtestet hämtar om boxarnas godkännande (en ny box har fått sitt uid av servern). */ try { window.dispatchEvent(new Event('hems:settings-saved')); } catch (_) {} /* ev_identity_notes (3/10): t.ex. att en ny box sparades avstängd. Texten visas som den står. */ const evNotes = Array.isArray(result && result.ev_identity_notes) ? result.ev_identity_notes.filter(x => typeof x === 'string' && x.trim()) : []; if (evReadFail) setStatus({kind:'err', msg:'Sparat, men laddboxarna kunde inte läsas om. Läs in sidan igen innan du sparar på nytt.'}); else setStatus({kind:'ok', msg: [result.status === 'saved' ? 'Sparat' : 'OK', ...evNotes].join(' · ')}); setTimeout(()=>setStatus(null), (evNotes.length || evReadFail) ? 15000 : 4000); } catch(e){ setStatus({kind:'err', msg: `Kunde inte spara: ${e.message}`}); } setSaving(false); }; const reset = () => setS(original); // Flik-läge (2026-06-11): vald kategori i SNav visar BARA den sektionen. // Hash i URL:en så en sidladdning/delning landar på samma kategori. /* Modell 1 (André 26/9, mockup "Installningar modell 1 flode"): startsidan är en LISTA över de nio grupperna med nuvarande läge som undertext; tryck öppnar gruppen på egen sida med bakåtpil. activeSec === null ⇒ listan. Hash i URL:en öppnar en grupp direkt. */ const [activeSec, setActiveSec] = React.useState(() => { const h = (window.location.hash || '').slice(1); return SETTINGS_SECTIONS.some(sec => sec.id === h) ? h : null; }); const selectSec = (id) => { setActiveSec(id); try { history.replaceState(null, '', id ? '#' + id : window.location.pathname); } catch(e){} window.scrollTo(0, 0); try { window.dispatchEvent(new Event('hems:settings-sec')); } catch(e){} }; const activeMeta = SETTINGS_SECTIONS.find(sec => sec.id === activeSec) || null; /* Undertext per grupp: bara riktiga värden ur inställningarna (aldrig härledda). Saknas allt visas ingen rad. */ const secSummary = (id) => { const has = (v) => v !== null && v !== undefined && v !== ''; const num = (v, u) => has(v) ? String(v).replace('.', ',') + (u ? ' ' + u : '') : null; const parts = []; if (id === 'grp-hem'){ if (has(s.name)) parts.push(s.name); if (has(s.area)) parts.push(s.area); } else if (id === 'grp-drift'){ parts.push(s.user_mode_enabled ? 'Egna inställningar' : 'Automatisk drift'); } else if (id === 'grp-sol'){ const p = num(s.solar_array_1_kwp, 'kWp'); if (p) parts.push(p); const b = num(s.battery_capacity_kwh, 'kWh'); if (b) parts.push(b); const m = num(s.battery_soc_min_pct, '%'); if (m) parts.push('min ' + m); } else if (id === 'grp-install'){ const f = num(s.main_fuse_a, 'A'); if (f) parts.push('Huvudsäkring ' + f); } else if (id === 'grp-ekonomi'){ const g = num(s.grid_fee_ore, 'öre'); if (g) parts.push('Nätavgift ' + g); const t = num(s.energy_tax_ore, 'öre'); if (t) parts.push('Skatt ' + t); } else if (id === 'grp-peak'){ parts.push(s.peak_shaving_enabled ? 'På' : 'Av'); } else if (id === 'grp-konto'){ if (has(s.email)) parts.push(s.email); } return parts.length ? parts.join(' · ') : null; }; /* K1 på inställningssidan: utgången prenumeration ersätter hela sidan. Inga inställningsanrop görs; bara Återaktivera och logga ut. */ if (blocked) return ; if (loading){ return (
Laddar inställningar…
); } return (
{activeSec ? : }
{/* Logotypen går hem till Översikt (André 1/9): gc.page sätts före navigeringen, annars öppnar index senast besökta fliken. */} { try { localStorage.setItem('gc.page', 'oversikt'); } catch(e){} }}> Gravio Gravio HEMS

{activeMeta ? activeMeta.label : 'Inställningar'}

{activeMeta ? (secSummary(activeSec) || 'Inställningar') : 'Systemkonfiguration'}
{status && ( {status.msg} {status.link && ( {status.link.txt} )} {status.upgrade && ( )} )} {loadErr && !status && ( Mockdata · ej anslutet )} {isDirty && !status && ( Osparade ändringar )}
{/* v676: ingen rail på datorskärm heller, listan är navigeringen på alla bredder. */} {!activeSec && }
{/* ── Kontaktuppgifter ─────────────────────────────────────── */}
u('name', v)} help="Vad anläggningen ska kallas i appen"/> {/* Fältet sparas i samma fält som inloggningen läser — det ÄR inloggningsadressen, inte en valfri kontaktuppgift (spec 21/9 E2). */} u('email', v)} help="Din inloggningsadress. Byter du den loggar du in med den nya adressen nästa gång."/>
{/* ── Elområde ─────────────────────────────────────────────── */} u('area', v)} help="Styr vilka spotpriser HEMS planerar mot" options={[ {value:'SE1', label:'SE1 — Luleå'}, {value:'SE2', label:'SE2 — Sundsvall'}, {value:'SE3', label:'SE3 — Södra Mellansverige'}, {value:'SE4', label:'SE4 — Malmö'}, ]}/> {/* ── Position ─────────────────────────────────────────────── */} { u('latitude', +lat.toFixed(4)); u('longitude', +lng.toFixed(4)); }}/>
u('latitude', v)}/> u('longitude', v)}/>
Positionen används för att hämta väder och beräkna solvinklar.
{/* ── Solcellsanläggning ───────────────────────────────────── */}
{/* Driftläge (2026-07-30): automatisk drift vs egna inställningar */}
{[1, 2, 3].map((i) => (
Fält {i}
u(`solar_array_${i}_kwp`, v)}/> u(`solar_array_${i}_azimuth`, v)} help="0=N, 90=Ö, 180=S, 270=V"/> u(`solar_array_${i}_tilt`, v)}/>
))}
Riktning: Söder (180°) är optimalt i Sverige. Lutning: 30–40° är optimalt för årsproduktion.
{/* ── Batteri ──────────────────────────────────────────────── */} {/* ── Elinstallation ───────────────────────────────────────── */}
u('main_fuse_a', v)} help="16A ≈ 11 kW · 20A ≈ 14 kW · 25A ≈ 17 kW · 35A ≈ 24 kW · 50A ≈ 35 kW · 63A ≈ 44 kW"/>
Sätter taket för effekt vid elmätaren (import + export). Påverkar inte sol-laddning av batteriet — det går direkt i huset.
{/* ── Effektformat (Homey) ─────────────────────────────────── */} {/* ── Elbil & skjutbara laster (SPEC_LASTER_EV 2026-06-11) ── */}
Avancerat. Rör bara dessa om något beter sig fel — fel effekt-enhet, eller att batteriet gör tvärtom mot vad HEMS säger. Annars: lämna allt som det är.
Enheter & laddbox
u('homey_power_in_kw', v)}/> {/* EV-kryssrutorna (ev_charger_power_in_kw, pause_battery_on_ev_charging) har flyttat till "Elbil & skjutbara laster" (spec §1) — mappningen oförändrad. */}
Teckenvändning
Vissa växelriktare vill ha effekten som ett minus-tal. Bocka i ett kommando bara om din växelriktare gör tvärtom när HEMS skickar det — t.ex. laddar när HEMS säger "ladda ur", eller att effekten visas med fel tecken. Vet du inte? Lämna alla tomma. De allra flesta behöver inte detta.
u('negate_charge', v)}/> u('negate_chargesolar', v)}/> u('negate_selfconsumption', v)}/> u('negate_peakshaving', v)}/> u('negate_export', v)}/>
{/* ── Ekonomi - Köp ────────────────────────────────────────── */}
Köppris = (Spotpris + alla avgifter) × moms
Ange alla avgifter utan moms — momsen läggs på automatiskt.
u('grid_fee_ore', v)} help="Vad nätbolaget tar för att leverera elen"/> u('energy_tax_ore', v)} help="Statlig skatt"/> u('retailer_margin_ore', v)} help="Påslag från elhandlaren"/> u('vat_pct', v)} help="Normalt 25 %"/>
{/* ── Ekonomi - Försäljning ────────────────────────────────── */}
Säljpris = Spotpris + nätnytta − marginal
Ingen moms vid försäljning av el.
u('grid_benefit_ore', v)} help="Ersättning från nätbolaget för el du matar ut"/> u('sale_margin_ore', v)} help="Vad elhandlaren tar för att förmedla din försäljning"/> {/* Cykelkostnad (batterislitage) flyttad till Driftläge → "Gäller alltid" (SPEC_GRAVIO_USER_MODE_UI §3.2). Samma fält cycle_cost_ore → cycle_cost_kwh, mappningen oförändrad — inga värden nollställs. */}
{/* ── Effektavgift — EGEN meny (André 31/8): eget ämne med eget kort på Översikt, inte en prisparameter under Ekonomi. ── */}
} subtitle="Begränsa nätimporten under valda timmar för att hålla ner effekttariffen. Batteriet täcker det som överstiger gränsen."> Effektavgiftshanteringen är ny och ännu inte färdigtestad av oss. Skyddet kan missa en topp, och våra tal kan avvika från nätfakturan. Sätt gränsen med egen marginal till abonnemangets, och följ upp mot fakturan de första månaderna.
u('peak_shaving_enabled', v)}/>
{s.peak_shaving_enabled && (<> {/* Lägesvärdena är backendens EXAKTA strängar — seasonal_daynight gav 422 (rätt: seasonal_day_night). day_night tillagt: 4 prod- användare kör det. Okänt mode-värde från GET bevaras som eget val i listan i stället för att klubbas — annars skriver nästa spara sönder läget (punkt 5, backend 31/8). */} u('peak_shaving_mode', v)} options={[ {value:'simple', label:'Enkel — en gräns hela året'}, {value:'day_night', label:'Dag/Natt — olika gräns dag och natt'}, {value:'seasonal', label:'Säsong — vinter/sommar'}, {value:'seasonal_day_night',label:'Säsong + Dag/Natt (t.ex. Bjäre)'}, ...(['simple','day_night','seasonal','seasonal_day_night'].includes(s.peak_shaving_mode) ? [] : [{value: s.peak_shaving_mode, label:`Okänt läge (${s.peak_shaving_mode}) — bevaras`}]), ]}/> {/* v746 Aktiv gräns just nu, i alla fyra lägena: serverns gällande gräns (se loadPeak). Tidigare bara i Säsong + Dag/Natt, och där stod alltid vinterns höglastgräns (P5N-1). */} {peakNow && (
{peakNow.on ?
Aktiv gräns just nu
{peakNow.kw.toLocaleString('sv-SE', {maximumFractionDigits: 2})} kWh/h
:
Ingen gräns just nu
} {(peakNow.on || peakDirty) &&
{peakNow.on && gäller nu, kl {peakNow.hour}} {peakDirty && Visar sparade inställningar}
}
)} {/* Fältsektionerna följer VALT läge (punkt 4, backend 31/8) — tidigare visades Bjäre-layouten oavsett val. */} {s.peak_shaving_mode === 'simple' && (
u('peak_max_grid_kw', v)}/> u('peak_cost_kr_per_kw', v)}/>
)} {s.peak_shaving_mode === 'day_night' && (
{/* Egna etiketter: "höglast" är säsongslägenas begrepp och finns inte i Dag/Natt. */} u('peak_day_start_h', v)} onEnd={(v)=>u('peak_day_end_h', v)} startLabel="Dag börjar" endLabel="Dag slutar"/> u('peak_day_max_kw', v)}/> u('peak_night_max_kw', v)}/> {/* En gemensam avgift för båda lägena (peak_shaving_cost_kr_per_kw) — bara besparingsvisning, styr inget. */} u('peak_cost_kr_per_kw', v)}/>
)} {s.peak_shaving_mode === 'seasonal' && (
Vinter
u('peak_winter_high_max_kw', v)}/> u('peak_winter_high_cost_kr_per_kw', v)}/>
u('peak_winter_months', v)}/>
Sommar
u('peak_summer_max_kw', v)}/> u('peak_summer_cost_kr_per_kw', v)}/>
u('peak_summer_months', v)}/>
)} {s.peak_shaving_mode === 'seasonal_day_night' && (
För nätbolag där vintern har olika höglast/låglast-tariff och sommaren har en gräns dygnet runt.
{/* Winter */}
Vinter — höglast & låglast
u('peak_winter_high_start_h', v)} onEnd={(v)=>u('peak_winter_high_end_h', v)}/> u('peak_winter_high_max_kw', v)}/> u('peak_winter_low_max_kw', v)}/> u('peak_winter_high_cost_kr_per_kw', v)}/> u('peak_winter_low_cost_kr_per_kw', v)}/>
u('peak_winter_months', v)}/>
{/* Summer */}
Sommar — en gräns dygnet runt
u('peak_summer_max_kw', v)}/> u('peak_summer_cost_kr_per_kw', v)}/>
u('peak_summer_months', v)}/>
)} {/* Reserv för toppar — ALLA lägen, INNANFÖR enabled-gaten (kontrakt 1/9 punkt A). Tomt = automatiskt (null, aldrig 0); placeholdern visar det faktiska automatiska värdet. */}
u('peak_reserve_kwh', v)} placeholder={autoReserve ? `Automatiskt: ${(window.lwNum || window.gcNum) ? (window.lwNum || window.gcNum)(autoReserve.kwh, 1) : autoReserve.kwh} kWh (${Math.round(autoReserve.percent || 0)} %)` : 'Tomt = automatiskt'} help={peakReserveErr(s.peak_reserve_kwh) ? {peakReserveErr(s.peak_reserve_kwh)} : 'Minst 1 kWh. Tomt eller 0 = HEMS räknar själv utifrån gräns och erfarenhet.'}/>
)}
{/* ── Byt lösenord ─────────────────────────────────────────── */}
{/* ── Notiser (design 737): siffra på HEMS-ikonen och push på den här enheten, push.js ── */} {window.PushBox && } {/* ── Homey API-nyckel ─────────────────────────────────────── */} {/* Säkerhetsfix 2026-05-29: visa data.api_key (Homey-nyckeln per user) */} {/* från backend, INTE SETTINGS_API_KEY (sess_*-token för dashboard-auth). */} {/* Innan: hårdkodad Andrés hems_3694... → alla såg Andrés nyckel. */} {/* ── Logga ut ─────────────────────────────────────────────── */}
Det kan ta upp till 5 minuter innan ändrade inställningar slår igenom i systemets beräkningar och dashboard.
); } // ── Live Leaflet map with draggable pin + click-to-set ───────────────── function PositionMap({ lat, lng, onChange }){ const containerRef = React.useRef(null); const mapRef = React.useRef(null); const markerRef = React.useRef(null); // Init once React.useEffect(() => { if (mapRef.current || !containerRef.current || !window.L) return; const L = window.L; const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, maxZoom: 19, }).setView([lat || 59.33, lng || 18.06], (typeof lat === 'number' && typeof lng === 'number') ? 17 : 11); /* TILE-KÄLLA (13/9, återkommande 403). OSM:s publika tile-server blockerar appar som inte följer deras usage policy ("Access blocked", 403) — det slog till igen 13/9. CARTO kräver API-nyckel sedan 31/8. Esri:s World Dark Gray Canvas är nyckelfri, mörk från början (ingen CSS-invertering behövs) och avsedd för inbäddning. Faller ÄVEN den tar vi OSM:s tyska spegel som reserv och tänder inverteringsfiltret för mörkret — bättre en ljus karta med filter än rutor med feltext. */ /* v687 (André 27/9: "Kan man få den med grafik"): flygbild från Esri (nyckelfri, samma leverantör) med vägar och ortnamn ovanpå, som standard, så huset syns och går att märka ut. Knappen Karta byter till den mörka kartan. */ const ESRI = 'https://server.arcgisonline.com/ArcGIS/rest/services/'; const satImg = L.tileLayer(ESRI + 'World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: 'Tiles © Esri — Esri, Maxar, Earthstar Geographics', maxZoom: 19, }); const sat = L.layerGroup([satImg, L.tileLayer(ESRI + 'Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}', { maxZoom: 19 }), L.tileLayer(ESRI + 'Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', { maxZoom: 19 })]); const esri = L.tileLayer(ESRI + 'Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}', { attribution: 'Tiles © Esri — Esri, DeLorme, NAVTEQ', maxNativeZoom: 16, maxZoom: 19, }); let dark = esri, base = null, errs = 0, swapped = false, satErrs = 0; const wrap = () => containerRef.current && containerRef.current.parentElement; const setBase = (k) => { if (k === base) return; if (base === 'sat') map.removeLayer(sat); else if (base === 'map') map.removeLayer(dark); base = k; /* Kartans zoomgräns ändras inte vid byte: den mörka kartan sträcker sina rutor över zoom 16 (maxNativeZoom), så inget zoomhopp behövs. */ (k === 'sat' ? sat : dark).addTo(map); const w = wrap(); if (w) w.classList.toggle('sc-map-inverted', k === 'map' && swapped); if (tog) tog.querySelectorAll('button').forEach((b) => { const on = b.dataset.k === k; b.classList.toggle('on', on); b.setAttribute('aria-pressed', on ? 'true' : 'false'); }); }; esri.on('tileerror', () => { if (swapped || ++errs < 3) return; swapped = true; const wasOn = base === 'map'; if (wasOn) map.removeLayer(esri); dark = L.tileLayer('https://tile.openstreetmap.de/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxNativeZoom: 18, maxZoom: 19, }); if (wasOn){ base = null; setBase('map'); } }); satImg.on('tileerror', () => { if (++satErrs === 3 && base === 'sat') setBase('map'); }); let tog = null; const Tog = L.Control.extend({ options: { position: 'topright' }, onAdd(){ const el = L.DomUtil.create('div', 'sc-map-tog'); el.innerHTML = ''; L.DomEvent.disableClickPropagation(el); el.addEventListener('click', (e) => { const b = e.target.closest('button'); if (b) setBase(b.dataset.k); }); tog = el; return el; } }); new Tog().addTo(map); setBase('sat'); // Custom HTML pin so it picks up theme colour const pinIcon = L.divIcon({ className: 'sc-pin-icon', html: ` `, iconSize: [32, 32], iconAnchor: [16, 30], }); const marker = L.marker([lat || 59.33, lng || 18.06], { icon: pinIcon, draggable: true, }).addTo(map); marker.on('dragend', () => { const p = marker.getLatLng(); onChange(p.lat, p.lng); }); map.on('click', (e) => { marker.setLatLng(e.latlng); onChange(e.latlng.lat, e.latlng.lng); }); mapRef.current = map; markerRef.current = marker; // Help Leaflet pick up its real size after layout setTimeout(() => map.invalidateSize(), 60); /* v686 (André 27/9, foto: bara en bit av kartan ritades). Sedan Inställningar blev lista + undersidor (v669) skapas kartan medan gruppen är dold, så Leaflet mäter storleken 0 och hämtar bara några rutor. Kartan mäts om varje gång dess ruta byter storlek, till exempel när Hem & plats öppnas, och centreras på markören igen. */ /* Även utan ResizeObserver (dolda eller inbäddade ramar): mät om strax efter att en grupp öppnats. */ const onSec = () => setTimeout(() => { const el = containerRef.current; if (!el || !el.clientWidth) return; map.invalidateSize(); const m = markerRef.current; if (m) map.setView(m.getLatLng(), map.getZoom(), { animate: false }); }, 80); window.addEventListener('hems:settings-sec', onSec); let ro = null; if (typeof ResizeObserver !== 'undefined'){ let last = ''; ro = new ResizeObserver(() => { const el = containerRef.current; if (!el) return; const k = el.clientWidth + 'x' + el.clientHeight; if (k === last || !el.clientWidth || !el.clientHeight) return; last = k; map.invalidateSize(); const m = markerRef.current; if (m) map.setView(m.getLatLng(), map.getZoom(), { animate: false }); }); ro.observe(containerRef.current); } return () => { window.removeEventListener('hems:settings-sec', onSec); if (ro) ro.disconnect(); map.remove(); mapRef.current = null; markerRef.current = null; }; }, []); // External lat/lng changes (typed into fields, geolocation) → sync map React.useEffect(() => { if (!mapRef.current || !markerRef.current) return; if (typeof lat !== 'number' || typeof lng !== 'number') return; const p = markerRef.current.getLatLng(); if (Math.abs(p.lat - lat) > 1e-4 || Math.abs(p.lng - lng) > 1e-4){ markerRef.current.setLatLng([lat, lng]); mapRef.current.panTo([lat, lng]); } }, [lat, lng]); return (
{typeof lat === 'number' ? lat.toFixed(4) : '—'}, {typeof lng === 'number' ? lng.toFixed(4) : '—'}
); } // Driftläge-kortet bor nu i settings-mode.jsx (RunModeCard, 2026-07-30). // OperatingModeCard + self_sufficiency-valet är rivet enligt spec §2. // ── Byt lösenord (POST /auth/change-password) ───────────────────────── /* Kortet anropade tidigare ingen server — onSave visade bara en tillfällig text i 3,5 s, så ingen kund kunde byta lösenord (spec 21/9, K2). Endpointen finns i backend och tar e-post, nuvarande, nytt och bekräftelse som JSON — ingen api_key i anropet. */ const PW_FAIL = 'Det gick inte att byta lösenordet. Försök igen.'; const PwEye = ({ off }) => off ? ( ) : ( ); /* Lösenordsfält med visa/dölj — samma beteende som login.jsx (K11): ett gemensamt öga som växlar alla tre fälten. */ const PwField = ({label, placeholder, value, onChange, autoComplete, name, show, onToggle, disabled}) => (
onChange(e.target.value)}/>
); function PasswordCard({ initialEmail }){ /* E-posten kommer från inställningarna (inloggat konto) och är skrivskyddad (K10): bytet gäller alltid det inloggade kontot, och ett redigerbart fält kan bara ge 404 från servern. */ const [email, setEmail] = React.useState(initialEmail || ''); React.useEffect(() => { if (initialEmail) setEmail(initialEmail); }, [initialEmail]); const [pwCur, setPwCur] = React.useState(''); const [pw1, setPw1] = React.useState(''); const [pw2, setPw2] = React.useState(''); const [show, setShow] = React.useState(false); const [busy, setBusy] = React.useState(false); const [status, setStatus] = React.useState(null); // {kind:'ok'|'err'|'info', msg} /* Felet står kvar tills kunden ändrar ett fält eller sparar igen (K6) — 429-texten måste hinna läsas, så ingen timer rensar toasten. */ const edit = (set) => (v) => { set(v); setStatus(null); }; const onSave = async () => { if (busy) return; // dubbelklick = ett anrop (K7) if (!pwCur){ setStatus({kind:'err', msg:'Fyll i ditt nuvarande lösenord'}); return; } if (pw1.length < 8){ setStatus({kind:'err', msg:'Minst 8 tecken'}); return; } if (pw1 !== pw2){ setStatus({kind:'err', msg:'Lösenorden matchar inte'}); return; } setBusy(true); setStatus({kind:'info', msg:'Sparar…'}); try { const r = await fetch(`${SETTINGS_BASE_URL}/auth/change-password`, { method: 'POST', headers: { 'Content-Type':'application/json', 'Accept':'application/json' }, body: JSON.stringify({ email: (email || '').trim(), current_password: pwCur, new_password: pw1, new_password_confirm: pw2 }) }); let d = null; try { d = await r.json(); } catch (_) {} if (r.ok){ setPwCur(''); setPw1(''); setPw2(''); setStatus({kind:'ok', msg:'Lösenordet är bytt'}); } else { /* detail är färdig svensk text utom vid 422, där den är en lista. */ const det = d && d.detail; setStatus({kind:'err', msg: (typeof det === 'string' && det) ? det : PW_FAIL}); } } catch (_) { setStatus({kind:'err', msg: PW_FAIL}); } setBusy(false); }; return (
setShow(!show)}/>
setShow(!show)}/> setShow(!show)}/>
{status && {status.msg}}
); } ReactDOM.createRoot(document.getElementById('root')).render();