/**************************************************************
* IONIQ 5 / Bluelink – HPC Nearby + Energy Integration + Dashboard
* ioBroker JavaScript-Adapter >= 8.9.2 (Node 18+)
* Version: 1.3.0 (selftest map centering, energy tiles, vis path)
* (c) by ilovegym66
**************************************************************/
'use strict';
const https = require('https');
/*** ===== KONFIG ===== ***/
const CFG = {
 bluelink: {
   power:    'bluelink.0.KMHKRxxxx.vehicleStatusRaw.ccs2Status.state.Vehicle.Green.Electric.SmartGrid.RealTimePower',
   charging: 'bluelink.0.KMHKRxxxx.vehicleStatus.battery.charge',
   soc:      'bluelink.0.KMHKRxxxx.vehicleStatus.battery.soc',
   lat:      'bluelink.0.KMHKRxxxx.vehicleLocation.lat',
   lon:      'bluelink.0.KMHKRxxxx.vehicleLocation.lon',
   latAlt:   'bluelink.0.KMHKRxxxx.vehicleLocation.latitude',
   lonAlt:   'bluelink.0.KMHKRxxxx.vehicleLocation.longitude'
 },
 hpc: {
   thresholdKW: 0,        // Suche auch ohne hohe Ladeleistung erlauben
   hpcMinKW:    149,      // Client-Filter Mindestleistung (kaskadiert runter falls 0 Treffer)
   radiusKm:    20,
   maxResults:  200,
   coordMinMoveM: 30,
   minCheckIntervalSec: 30,
   preferredOperators: ['ionity','enbw','aral pulse','fastned','shell recharge','allego','mer','ewe go','totalenergies','eviny','maingau','entega','pfalzwerke','tesla'],
   ocmEndpoint: 'https://api.openchargemap.io/v3/poi/',
   ocmApiKey:   'DEIN-OCM-KEY-HIER', // <— eintragen oder per State lesen (siehe unten)
   requireChargingForSearch: false,
   // Serverseitige Filter (erste Stufe „strict“)
   apiFilterFastDC: true,     // Level 3 + DC
   apiMinPowerKW:   120,
   // Kaskade bei 0 Treffern
   cascadeIfZero: true,
   cascadeMinKWClient: 120
 },
 energy: {
   powerIsWattAuto:    true,
   sessionPowerMinKW:  0.5,
   integTickSec:       10,
   sessionIdleEndSec:  180,
   usableCapacityKWh:  84
 },
 // Dashboard-Ausgabe (zurück in vis-Pfad)
 dash: {
   htmlState: '0_userdata.0.vis.Dashboards.HPC.HTML', // <- hier landet das fertige HTML
   allowScroll: false,    // MinuVis: kein Scroll/Wheel
   heightPx: 420          // Kartenhöhe
 },
 // Ausgabe-Roots (States)
 outHpcRoot:    '0_userdata.0.Cars.HPCNearby',
 outEnergyRoot: '0_userdata.0.Cars.Energy',
 debug: true
};
// OPTIONAL: OCM-Key aus State laden (wenn gewünscht)
// try { const s=getState('0_userdata.0.secrets.ocmApiKey'); if (s && s.val) CFG.hpc.ocmApiKey = String(s.val); } catch(e){}
/*** ===== Utils ===== ***/
const idJ = (...a)=>a.join('.');
const logI = m => log(`[Ioniq-HPC+Energy] ${m}`, 'info');
const logD = m => CFG.debug && log(`[Ioniq-HPC+Energy] ${m}`, 'debug');
const nowSec = ()=>Math.floor(Date.now()/1000);
function exObj(id){ try{ return existsObject(id); }catch(e){ return false; } }
function exState(id){ try{ return existsState(id); }catch(e){ return false; } }
function g(id){
 try{
   if (!exState(id)) return undefined;
   const s = getState(id);
   return s ? s.val : undefined;
 }catch(e){ return undefined; }
}
async function es(id, common, init){ try{ if (!exObj(id)) await createStateAsync(id, common, init ?? null); }catch(e){} }
async function ss(id, val){ try{ await setStateAsync(id, {val, ack:true}); }catch(e){} }
function toNum(x, d=0){ const n = Number(x); return Number.isFinite(n) ? n : d; }
function toBool(x){ return x===true || x===1 || x==='1' || String(x).toLowerCase()==='true'; }
/*** ===== Haversine (m) ===== ***/
function haversineMeters(lat1, lon1, lat2, lon2){
 const R=6371000, rad=d=>d*Math.PI/180;
 const dLat=rad(lat2-lat1), dLon=rad(lon2-lon1);
 const a=Math.sin(dLat/2)**2+Math.cos(rad(lat1))*Math.cos(rad(lat2))*Math.sin(dLon/2)**2;
 return 2*R*Math.atan2(Math.sqrt(a),Math.sqrt(1-a));
}
/*** ====== OUTPUT STATES: HPC ====== ***/
const HPC = {
 ROOT:     CFG.outHpcRoot,
 HAS:      idJ(CFG.outHpcRoot,'hasHPCNearby'),
 COUNT:    idJ(CFG.outHpcRoot,'count'),
 NAME:     idJ(CFG.outHpcRoot,'nearest.name'),
 DISTM:    idJ(CFG.outHpcRoot,'nearest.distance_m'),
 KW:       idJ(CFG.outHpcRoot,'nearest.maxPower_kW'),
 OP:       idJ(CFG.outHpcRoot,'nearest.operator'),
 LASTJSON: idJ(CFG.outHpcRoot,'lastResultJson'),
 LASTCHK:  idJ(CFG.outHpcRoot,'lastCheck'),
 LASTWHY:  idJ(CFG.outHpcRoot,'lastReason'),
 LASTERR:  idJ(CFG.outHpcRoot,'lastError'),
 MISSING:  idJ(CFG.outHpcRoot,'debug.missingStates'),
 // Debug
 DBG_URL:   idJ(CFG.outHpcRoot,'debug.lastQueryUrl'),
 DBG_URL2:  idJ(CFG.outHpcRoot,'debug.lastQueryUrl_swapped'),
 DBG_URL3:  idJ(CFG.outHpcRoot,'debug.lastQueryUrl_wide'),
 DBG_RAW:   idJ(CFG.outHpcRoot,'debug.rawCount'),
 DBG_FIL:   idJ(CFG.outHpcRoot,'debug.filteredCount'),
 DBG_SAMPLE:idJ(CFG.outHpcRoot,'debug.sampleJson'),
 DBG_COORD_LAT: idJ(CFG.outHpcRoot,'debug.lastLat'),
 DBG_COORD_LON: idJ(CFG.outHpcRoot,'debug.lastLon'),
 DBG_HTTP:      idJ(CFG.outHpcRoot,'debug.lastHttpStatus'),
 DBG_ERRSHORT:  idJ(CFG.outHpcRoot,'debug.lastErrorShort'),
 // Commands
 CMD_TEST:      idJ(CFG.outHpcRoot,'cmd.TestSearch'),
 TEST_RADIUS:   idJ(CFG.outHpcRoot,'cmd.TestRadiusKm'),
 CMD_TEST_FFM:  idJ(CFG.outHpcRoot,'cmd.SelfTest_Frankfurt'),
 CMD_TEST_CGN:  idJ(CFG.outHpcRoot,'cmd.SelfTest_Koeln'),
 CMD_TEST_MUC:  idJ(CFG.outHpcRoot,'cmd.SelfTest_Muenchen')
};
async function ensureHpcStates(){
 await es(HPC.HAS,{type:'boolean',role:'indicator'},false);
 await es(HPC.COUNT,{type:'number',role:'value'},0);
 await es(HPC.NAME,{type:'string',role:'text'},'');
 await es(HPC.DISTM,{type:'number',role:'value'},0);
 await es(HPC.KW,{type:'number',role:'value'},0);
 await es(HPC.OP,{type:'string',role:'text'},'');
 await es(HPC.LASTJSON,{type:'string',role:'json'},'[]');
 await es(HPC.LASTCHK,{type:'string',role:'text'},'');
 await es(HPC.LASTWHY,{type:'string',role:'text'},'');
 await es(HPC.LASTERR,{type:'string',role:'text'},'');
 await es(HPC.MISSING,{type:'string',role:'text'},'');
 await es(HPC.DBG_URL,{type:'string',role:'text'},'');
 await es(HPC.DBG_URL2,{type:'string',role:'text'},'');
 await es(HPC.DBG_URL3,{type:'string',role:'text'},'');
 await es(HPC.DBG_RAW,{type:'number',role:'value'},0);
 await es(HPC.DBG_FIL,{type:'number',role:'value'},0);
 await es(HPC.DBG_SAMPLE,{type:'string',role:'json'},'');
 await es(HPC.DBG_COORD_LAT,{type:'number',role:'value.gps'},0);
 await es(HPC.DBG_COORD_LON,{type:'number',role:'value.gps'},0);
 await es(HPC.DBG_HTTP,{type:'string',role:'text'},'');
 await es(HPC.DBG_ERRSHORT,{type:'string',role:'text'},'');
 await es(HPC.CMD_TEST,{type:'boolean',role:'button'},false);
 await es(HPC.TEST_RADIUS,{type:'number',role:'value'},NaN);
 await es(HPC.CMD_TEST_FFM,{type:'boolean',role:'button'},false);
 await es(HPC.CMD_TEST_CGN,{type:'boolean',role:'button'},false);
 await es(HPC.CMD_TEST_MUC,{type:'boolean',role:'button'},false);
}
/*** ====== OUTPUT STATES: ENERGY ====== ***/
const EN = {
 ROOT:          CFG.outEnergyRoot,
 ACTIVE:        idJ(CFG.outEnergyRoot,'session.active'),
 START_TS:      idJ(CFG.outEnergyRoot,'session.startTs'),
 END_TS:        idJ(CFG.outEnergyRoot,'session.endTs'),
 START_SOC:     idJ(CFG.outEnergyRoot,'session.startSoC'),
 END_SOC:       idJ(CFG.outEnergyRoot,'session.endSoC'),
 ENERGY_KWH:    idJ(CFG.outEnergyRoot,'session.energy_kWh'),
 ENERGY_SOC_KWH:idJ(CFG.outEnergyRoot,'session.energySoc_kWh'),
 LAST_ENERGY:   idJ(CFG.outEnergyRoot,'lastSession.energy_kWh'),
 LAST_START:    idJ(CFG.outEnergyRoot,'lastSession.startTs'),
 LAST_END:      idJ(CFG.outEnergyRoot,'lastSession.endTs'),
 TODAY_KWH:     idJ(CFG.outEnergyRoot,'today.energy_kWh'),
 TOTAL_KWH:     idJ(CFG.outEnergyRoot,'total.energy_kWh'),
 LAST_REASON:   idJ(CFG.outEnergyRoot,'debug.lastReason'),
 LAST_ERR:      idJ(CFG.outEnergyRoot,'debug.lastError')
};
async function ensureEnergyStates(){
 await es(EN.ACTIVE,        {type:'boolean', role:'indicator'}, false);
 await es(EN.START_TS,      {type:'string',  role:'text'}, '');
 await es(EN.END_TS,        {type:'string',  role:'text'}, '');
 await es(EN.START_SOC,     {type:'number',  role:'value'}, null);
 await es(EN.END_SOC,       {type:'number',  role:'value'}, null);
 await es(EN.ENERGY_KWH,    {type:'number',  role:'value.energy'}, 0);
 await es(EN.ENERGY_SOC_KWH,{type:'number',  role:'value.energy'}, 0);
 await es(EN.LAST_ENERGY,   {type:'number',  role:'value.energy'}, 0);
 await es(EN.LAST_START,    {type:'string',  role:'text'}, '');
 await es(EN.LAST_END,      {type:'string',  role:'text'}, '');
 await es(EN.TODAY_KWH,     {type:'number',  role:'value.energy'}, 0);
 await es(EN.TOTAL_KWH,     {type:'number',  role:'value.energy'}, 0);
 await es(EN.LAST_REASON,   {type:'string',  role:'text'}, '');
 await es(EN.LAST_ERR,      {type:'string',  role:'text'}, '');
}
/*** ===== OCM Helper ===== ***/
function isPreferredOperator(op){
 if (!op) return false;
 const t = String(op).toLowerCase();
 return CFG.hpc.preferredOperators.some(x => t.includes(String(x).toLowerCase()));
}
function powerFromConn(c){
 if (!c) return 0;
 let pk = toNum(c.PowerKW, NaN);
 if (!Number.isFinite(pk)) pk = toNum(c.RatedPowerKW, NaN);
 if (!Number.isFinite(pk)) pk = toNum(c.Power, NaN);
 if (Number.isFinite(pk) && pk > 0) return pk;
 let v = toNum(c.Voltage, NaN); if (!Number.isFinite(v)) v = toNum(c.Volts, NaN);
 let a = toNum(c.Amps, NaN);    if (!Number.isFinite(a)) a = toNum(c.Current, NaN);
 if (Number.isFinite(v) && Number.isFinite(a) && v>0 && a>0) return (v*a)/1000;
 const levelId  = toNum(c.LevelID, NaN) || toNum(c?.Level?.ID, NaN) || 0;
 const currentId= toNum(c.CurrentTypeID, NaN) || toNum(c?.CurrentType?.ID, NaN) || 0;
 const lvlTitle = String(c?.Level?.Title||'').toLowerCase();
 const curTitle = String(c?.CurrentType?.Title||'').toLowerCase();
 const lvlFast  = levelId >= 3 || lvlTitle.includes('3');
 const isDC     = currentId === 30 || curTitle.includes('dc');
 if (lvlFast && isDC) return 150; // Fallback
 return 0;
}
function extractMaxPowerKW(poi){
 const arr = Array.isArray(poi?.Connections) ? poi.Connections : [];
 let max = 0;
 for (const c of arr){ const p = powerFromConn(c); if (p > max) max = p; }
 return max;
}
/*** HTTP ***/
function buildOcmUrlStr(lat, lon, radiusKm, maxResults, opts){
 opts = opts || {}; // { fastDC?:boolean, minPowerKW?:number }
 function add(q, k, v){ if (v===undefined||v===null||v==='') return q; q.push(encodeURIComponent(k)+'='+encodeURIComponent(String(v))); return q; }
 const base = String(CFG.hpc.ocmEndpoint||'https://api.openchargemap.io/v3/poi/').replace(/\?+.*/, '');
 const params = [];
 add(params,'output','json');
 add(params,'latitude', lat);
 add(params,'longitude', lon);
 add(params,'distance', radiusKm);
 add(params,'distanceunit','KM');
 add(params,'maxresults', maxResults || CFG.hpc.maxResults);
 add(params,'compact','false'); add(params,'verbose','true');
 if (opts.fastDC){ add(params,'levelid',3); add(params,'currenttypeid',30); if (opts.minPowerKW!=null) add(params,'minpowerkw', opts.minPowerKW); }
 if (CFG.hpc.ocmApiKey && CFG.hpc.ocmApiKey.trim()){ add(params,'key', CFG.hpc.ocmApiKey.trim()); }
 return base + '?' + params.join('&');
}
function httpGetJson(urlStr){
 return new Promise((resolve, reject) => {
   const headers = { 'User-Agent':'ioBroker-HPC-Nearby/1.3 (+contact:local)' };
   // keinen X-API-Key Header verwenden – Key steckt in URL
   const req = https.get(urlStr, { headers, timeout: 12000 }, (res) => {
     let data=''; res.on('data', c=>data+=c);
     res.on('end', ()=>{ if (res.statusCode>=200 && res.statusCode<300) { try{ resolve({json:JSON.parse(data), status:res.statusCode}); } catch(e){ reject(new Error('OCM JSON parse error: '+e.message)); } } else reject(new Error('OCM HTTP '+res.statusCode)); });
   });
   req.on('timeout', ()=>req.destroy(new Error('timeout')));
   req.on('error', reject);
 });
}
async function fetchOCM(lat, lon, radiusKm, maxResults, mode){
 let url;
 if (mode==='strict') url = buildOcmUrlStr(lat,lon,radiusKm,maxResults,{fastDC:true,minPowerKW:CFG.hpc.apiMinPowerKW});
 else if (mode==='dcOnly') url = buildOcmUrlStr(lat,lon,radiusKm,maxResults,{fastDC:true});
 else url = buildOcmUrlStr(lat,lon,radiusKm,maxResults,{});
 const r = await httpGetJson(url);
 return { mode, url, status:r.status, json:r.json };
}
/*** ===== HPC intern ===== ***/
let lastCheckSec = 0, lastLat = null, lastLon = null;
function readLatLon(){
 let lat = g(CFG.bluelink.lat), lon = g(CFG.bluelink.lon);
 if ((lat===undefined||lat===null) && exState(CFG.bluelink.latAlt)) lat = g(CFG.bluelink.latAlt);
 if ((lon===undefined||lon===null) && exState(CFG.bluelink.lonAlt)) lon = g(CFG.bluelink.lonAlt);
 return {lat: toNum(lat, 0), lon: toNum(lon, 0)};
}
function listMissing(ids){ return ids.filter(id => !exState(id)); }
async function hpcNo(reason){
 await ss(HPC.HAS,false); await ss(HPC.COUNT,0);
 await ss(HPC.NAME,''); await ss(HPC.DISTM,0); await ss(HPC.KW,0); await ss(HPC.OP,'');
 await ss(HPC.LASTJSON,'[]'); await ss(HPC.LASTCHK,new Date().toISOString());
 await ss(HPC.LASTERR,''); await ss(HPC.LASTWHY,reason||'');
 await ss(HPC.DBG_RAW,0); await ss(HPC.DBG_FIL,0);
 await ss(HPC.DBG_URL,''); await ss(HPC.DBG_URL2,''); await ss(HPC.DBG_URL3,''); await ss(HPC.DBG_HTTP,''); await ss(HPC.DBG_ERRSHORT,'');
}
function enrichAndFilter(json, lat, lon){
 const enriched=[];
 for (const poi of Array.isArray(json)?json:[]){
   const pMax = extractMaxPowerKW(poi);
   const conns = Array.isArray(poi?.Connections)?poi.Connections:[];
   const isDCfast = conns.some(c=>{
     const lvl = toNum(c?.LevelID, NaN) || toNum(c?.Level?.ID, NaN) || 0;
     const cur = toNum(c?.CurrentTypeID, NaN) || toNum(c?.CurrentType?.ID, NaN) || 0;
     const lvlTitle = String(c?.Level?.Title||'').toLowerCase();
     const curTitle = String(c?.CurrentType?.Title||'').toLowerCase();
     const lvlFast = lvl >= 3 || lvlTitle.includes('3'); const isDC = cur === 30 || curTitle.includes('dc');
     return lvlFast && isDC;
   });
   if (!(pMax >= CFG.hpc.hpcMinKW || (pMax === 0 && isDCfast))) continue;
   const op   = poi?.OperatorInfo?.Title || '';
   const name = poi?.AddressInfo?.Title || '';
   const plat = toNum(poi?.AddressInfo?.Latitude, NaN);
   const plon = toNum(poi?.AddressInfo?.Longitude, NaN);
   const distM = (Number.isFinite(plat)&&Number.isFinite(plon))?Math.round(haversineMeters(lat,lon,plat,plon)):null;
   enriched.push({
     id: poi?.ID, operator: op, preferred: isPreferredOperator(op), name,
     maxPower_kW: Math.round(pMax), distance_m: distM,
     lat: plat, lon: plon
   });
 }
 enriched.sort((a,b)=>{
   if (a.preferred!==b.preferred) return a.preferred?-1:1;
   const da=a.distance_m??9e9, db=b.distance_m??9e9;
   if (da!==db) return da-db;
   return (b.maxPower_kW||0)-(a.maxPower_kW||0);
 });
 return enriched;
}
async function maybeCheckHPC(reason, opts){
 opts = opts||{};
 await ss(HPC.LASTWHY, reason||'');
 await ss(HPC.DBG_ERRSHORT, '');
 try{
   const reqIds=[CFG.bluelink.power, CFG.bluelink.charging];
   const missing=listMissing(reqIds); if(missing.length){ await ss(HPC.MISSING,missing.join(', ')); return; } else await ss(HPC.MISSING,'');
   let powerKW = toNum(g(CFG.bluelink.power), 0);
   if (CFG.energy.powerIsWattAuto && Math.abs(powerKW)>1000) powerKW/=1000; powerKW=Math.abs(powerKW);
   const charging = toBool(g(CFG.bluelink.charging));
   const {lat,lon}=readLatLon();
   await ss(HPC.DBG_COORD_LAT, lat); await ss(HPC.DBG_COORD_LON, lon);
   if (!opts.force){
     if (CFG.hpc.requireChargingForSearch && !charging) return hpcNo('charging=false');
     if (!Number.isFinite(powerKW) || powerKW <= CFG.hpc.thresholdKW) return hpcNo(`power ${powerKW.toFixed(1)} <= ${CFG.hpc.thresholdKW}`);
   }
   if (!Number.isFinite(lat)||!Number.isFinite(lon)||lat===0||lon===0) return hpcNo('invalid coords');
   const tNow=nowSec(), since=tNow-lastCheckSec;
   if (!opts.force && lastLat!=null && lastLon!=null){
     const dist=haversineMeters(lastLat,lastLon,lat,lon);
     if (dist<CFG.hpc.coordMinMoveM && since<CFG.hpc.minCheckIntervalSec){ logD(`HPC skip: moved ${Math.round(dist)}m, since ${since}s`); return; }
   }
   // Stufe 1: strict
   let q = await fetchOCM(lat,lon,(opts.radiusKmOverride&&isFinite(opts.radiusKmOverride))?Number(opts.radiusKmOverride):CFG.hpc.radiusKm, CFG.hpc.maxResults, 'strict');
   lastCheckSec=tNow; lastLat=lat; lastLon=lon;
   await ss(HPC.DBG_URL, q.url); await ss(HPC.DBG_HTTP, `strict:${q.status}`);
   await ss(HPC.DBG_RAW, Array.isArray(q.json)?q.json.length:0);
   let enriched = enrichAndFilter(q.json, lat, lon);
   await ss(HPC.DBG_FIL, enriched.length);
   // Stufe 2: dcOnly
   if (CFG.hpc.cascadeIfZero && enriched.length===0){
     q = await fetchOCM(lat,lon,CFG.hpc.radiusKm,CFG.hpc.maxResults,'dcOnly');
     await ss(HPC.DBG_URL2, q.url); await ss(HPC.DBG_HTTP, `dcOnly:${q.status}`);
     await ss(HPC.DBG_RAW, Array.isArray(q.json)?q.json.length:0);
     enriched = enrichAndFilter(q.json, lat, lon);
     await ss(HPC.DBG_FIL, enriched.length);
   }
   // Stufe 3: all (client ≥ 120 kW)
   if (CFG.hpc.cascadeIfZero && enriched.length===0){
     const old=CFG.hpc.hpcMinKW; CFG.hpc.hpcMinKW=CFG.hpc.cascadeMinKWClient||120;
     q = await fetchOCM(lat,lon,CFG.hpc.radiusKm,Math.max(CFG.hpc.maxResults,120),'all');
     await ss(HPC.DBG_URL3, q.url); await ss(HPC.DBG_HTTP, `all:${q.status}`);
     await ss(HPC.DBG_RAW, Array.isArray(q.json)?q.json.length:0);
     enriched = enrichAndFilter(q.json, lat, lon);
     await ss(HPC.DBG_FIL, enriched.length);
     CFG.hpc.hpcMinKW=old;
   }
   await ss(HPC.COUNT, enriched.length);
   await ss(HPC.HAS, enriched.length>0);
   await ss(HPC.LASTJSON, JSON.stringify(enriched));
   await ss(HPC.LASTCHK, new Date().toISOString());
   await ss(HPC.LASTERR, '');
   if (enriched.length){
     const n=enriched[0];
     await ss(HPC.NAME, n.name||'');
     await ss(HPC.DISTM, n.distance_m||0);
     await ss(HPC.KW, n.maxPower_kW||0);
     await ss(HPC.OP, n.operator||'');
     logI(`HPC nearby: ${n.name} (${n.operator||'–'}), ${n.maxPower_kW} kW, ~${n.distance_m} m`);
   } else {
     await ss(HPC.NAME,''); await ss(HPC.DISTM,0); await ss(HPC.KW,0); await ss(HPC.OP,'');
     logD('HPC: none in radius');
   }
   // Dashboard neu rendern
   renderDashboard();
 } catch(err){
   await ss(HPC.LASTERR, String(err?.message||err));
   await ss(HPC.DBG_ERRSHORT, String(err?.message||err));
   await ss(HPC.LASTCHK, new Date().toISOString());
   renderDashboard();
 }
}
/*** ===== ENERGY intern ===== ***/
let lastTickTs = Date.now(); let idleSince = null;
function readPowerKW(){ let p=toNum(g(CFG.bluelink.power),0); if (CFG.energy.powerIsWattAuto && Math.abs(p)>1000) p/=1000; return Math.abs(p); }
function readSocPct(){ const s=g(CFG.bluelink.soc); return s===undefined?null:toNum(s,null); }
function readChargingActive(){ return toBool(g(CFG.bluelink.charging)); }
async function sessionStart(){
 await ss(EN.ACTIVE,true); await ss(EN.START_TS,new Date().toISOString());
 await ss(EN.END_TS,''); await ss(EN.START_SOC, readSocPct());
 await ss(EN.END_SOC,null); await ss(EN.ENERGY_KWH,0); await ss(EN.ENERGY_SOC_KWH,0);
 renderDashboard();
}
async function sessionFinish(reason){
 const nowIso=new Date().toISOString(); const energy=toNum(g(EN.ENERGY_KWH),0);
 await ss(EN.LAST_ENERGY,energy); await ss(EN.LAST_START, g(EN.START_TS)||''); await ss(EN.LAST_END,nowIso);
 await ss(EN.END_TS,nowIso); await ss(EN.ACTIVE,false); await ss(EN.LAST_REASON,`finish:${reason||''}`);
 const today=new Date().toISOString().slice(0,10); const keyToday=idJ(EN.ROOT,'daily',today);
 if (!exObj(keyToday)) await es(keyToday,{type:'number',role:'value.energy'},0);
 const curDay=toNum(g(keyToday),0)+energy;
 await ss(keyToday,curDay); await ss(EN.TODAY_KWH,curDay);
 await ss(EN.TOTAL_KWH, toNum(g(EN.TOTAL_KWH),0)+energy);
 renderDashboard();
}
async function updateSocEstimate(){
 if (CFG.energy.usableCapacityKWh<=0) return;
 const s0=g(EN.START_SOC), s1=readSocPct(); if (s0==null || s1==null) return;
 const est=((toNum(s1,0)-toNum(s0,0))/100)*CFG.energy.usableCapacityKWh;
 await ss(EN.END_SOC,s1); await ss(EN.ENERGY_SOC_KWH, Math.max(0,est));
}
async function integrationTick(){
 try{
   const need=[CFG.bluelink.power, CFG.bluelink.charging]; if (listMissing(need).length) return;
   const active=toBool(g(EN.ACTIVE)), charging=readChargingActive(), powerKW=readPowerKW();
   const now=Date.now(); const dt_h=Math.max(0,(now-lastTickTs)/3600000); lastTickTs=now;
   if (!active && charging && powerKW>=CFG.energy.sessionPowerMinKW){ idleSince=null; await sessionStart(); }
   if (active){
     const eAdd=powerKW*dt_h; const eNow=Math.max(0, toNum(g(EN.ENERGY_KWH),0)+eAdd);
     await ss(EN.ENERGY_KWH, eNow); await updateSocEstimate();
     if (powerKW < CFG.energy.sessionPowerMinKW || !charging){
       if (idleSince===null) idleSince=now; const idleSec=(now-idleSince)/1000;
       if (idleSec>=CFG.energy.sessionIdleEndSec){ await sessionFinish(!charging?'charging=false':'power<min'); idleSince=null; }
     } else idleSince=null;
   }
   const todayKey=idJ(EN.ROOT,'daily', new Date().toISOString().slice(0,10));
   if (exObj(todayKey)) await ss(EN.TODAY_KWH, toNum(g(todayKey),0));
 } catch(err){ await ss(EN.LAST_ERR, String(err?.message||err)); }
 renderDashboard();
}
/*** ===== Dashboard ===== ***/
async function ensureDashState(){ await es(CFG.dash.htmlState, {type:'string', role:'html'}, ''); }
// Mini Map-Engine (OSM Tiles im Iframe; kein Scroll/Wheel)
function buildMapIframeHTML(points, centerHint){
 // points: [{lat,lon,label,type:'car'|'hpc'}]
 // centerHint: {lat,lon} optional
 const W=100, H=CFG.dash.heightPx; // Breite 100% via CSS
 const payload = { points: points||[], centerHint: centerHint||null, lockScroll: !CFG.dash.allowScroll };
 const css =
   'html,body{margin:0;height:100%;background:#0b1020}#root{position:relative;width:100%;height:100%}'+
   '.tile{position:absolute}canvas{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}'+
   '.mk{position:absolute;transform:translate(-50%,-100%);padding:3px 6px;border-radius:8px;border:1px solid rgba(148,163,184,.5);background:rgba(13,23,45,.9);color:#e5e7eb;font:12px system-ui,sans-serif;white-space:nowrap}'+
   '.dot{position:absolute;width:10px;height:10px;border-radius:50%;box-shadow:0 0 0 2px rgba(96,165,250,.35)}'+
   '.dot.car{background:#34d399} .dot.hpc{background:#60a5fa} .attr{position:absolute;right:6px;bottom:4px;font:11px system-ui;color:#93a3b8;background:rgba(0,0,0,.35);padding:2px 6px;border-radius:6px}';
 const js =
   '(function(){var DATA=window.__PAYLOAD__||{points:[],centerHint:null,lockScroll:true};'+
   'var root=document.getElementById("root");var W=root.clientWidth,H=root.clientHeight;'+
   'function rad(d){return d*Math.PI/180;} function lat2y(lat){var s=Math.sin(rad(lat));return 0.5-Math.log((1+s)/(1-s))/(4*Math.PI);} function lon2x(lon){return lon/360+0.5;}'+
   'function project(lat,lon,z){var s=256*Math.pow(2,z);return {x:lon2x(lon)*s,y:lat2y(lat)*s};} function deproject(x,y,z){var s=256*Math.pow(2,z);var lon=(x/s-0.5)*360;var n=Math.PI-2*Math.PI*(y/s-0.5);var lat=180/Math.PI*Math.atan(0.5*(Math.exp(n)-Math.exp(-n)));return {lat:lat,lon:lon};}'+
   'function fitBounds(pts){ if(!pts||!pts.length){ return {c:(DATA.centerHint||{lat:51,lon:10}), z:8}; } var minLat=90,maxLat=-90,minLon=180,maxLon=-180; pts.forEach(function(p){if(p.lat<minLat)minLat=p.lat;if(p.lat>maxLat)maxLat=p.lat;if(p.lon<minLon)minLon=p.lon;if(p.lon>maxLon)maxLon=p.lon;}); var c={lat:(minLat+maxLat)/2,lon:(minLon+maxLon)/2}; var z=6; for(var zz=6;zz<=19;zz++){var a=project(maxLat,minLon,zz), b=project(minLat,maxLon,zz); var w=Math.abs(b.x-a.x), h=Math.abs(b.y-a.y); if(w<=W*0.85 && h<=H*0.85) z=zz; else break;} return {c:c,z:z}; }'+
   'var pts=DATA.points||[]; var f=fitBounds(pts.length?pts:(DATA.centerHint?[DATA.centerHint]:[])); var center=f.c, zoom=f.z;'+
   'var world=project(center.lat,center.lon,zoom); var originX=world.x-W/2, originY=world.y-H/2;'+
   'var tiles=document.createElement("div"); tiles.style.position="absolute"; root.appendChild(tiles); var ctx=document.createElement("canvas"); ctx.width=W; ctx.height=H; root.appendChild(ctx); var g=ctx.getContext("2d");'+
   'function wrapX(x,n){return ((x%n)+n)%n;} function toXY(lat,lon){var p=project(lat,lon,zoom); return {x:p.x-originX,y:p.y-originY};}'+
   'function draw(){ tiles.innerHTML=""; var n=Math.pow(2,zoom); var x0=Math.floor(originX/256), y0=Math.floor(originY/256); var x1=Math.floor((originX+W)/256), y1=Math.floor((originY+H)/256); for(var ty=y0;ty<=y1;ty++){ if(ty<0||ty>=n) continue; for(var tx=x0;tx<=x1;tx++){ var wx=wrapX(tx,n); var img=new Image(); img.className="tile"; img.src="https://tile.openstreetmap.org/"+zoom+"/"+wx+"/"+ty+".png"; img.width=256; img.height=256; img.style.left=(tx*256-originX)+"px"; img.style.top=(ty*256-originY)+"px"; tiles.appendChild(img);} } g.clearRect(0,0,W,H);'+
   '  (DATA.points||[]).forEach(function(p){ var q=toXY(p.lat,p.lon); var d=document.createElement("div"); d.className="dot "+(p.type||"hpc"); d.style.left=q.x+"px"; d.style.top=q.y+"px"; root.appendChild(d); var m=document.createElement("div"); m.className="mk"; m.style.left=q.x+"px"; m.style.top=(q.y-12)+"px"; m.textContent=p.label||""; root.appendChild(m); });'+
   '  var attr=document.createElement("div"); attr.className="attr"; attr.textContent="© OpenStreetMap-Mitwirkende"; root.appendChild(attr);'+
   '} draw();'+
   (CFG.dash.allowScroll ? '' : ' root.addEventListener("wheel", function(ev){ev.preventDefault();}, {passive:false}); ') +
   '})();';
 const srcdoc = '<!doctype html><html><head><meta charset="utf-8"><style>'+css+'</style></head><body><div id="root" style="width:100%;height:100%"></div><script>window.__PAYLOAD__='+JSON.stringify(payload)+'<\/script><script>'+js+'<\/script></body></html>';
 return '<iframe style="width:100%;height:'+H+'px;border:0;border-radius:12px;overflow:hidden;background:#0b1020" srcdoc="'+
        String(srcdoc).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')+'"></iframe>';
}
function fmtEnergy(n){ return (n==null||isNaN(n)) ? '0.0' : Number(n).toFixed(2); }
function fmtDist(m){ if (m==null||isNaN(m)) return '-'; return (m>=1000)?(m/1000).toFixed(2)+' km': Math.round(m)+' m'; }
// Render: liest aktuelle States + erzeugt HTML in CFG.dash.htmlState
async function renderDashboard(centerOverride){
 try{
   await ensureDashState();
   const car = readLatLon();
   const nearestName = String(g(HPC.NAME)||'–');
   const nearestOp   = String(g(HPC.OP)||'');
   const nearestKW   = toNum(g(HPC.KW),0);
   const nearestDist = toNum(g(HPC.DISTM),0);
   const count       = toNum(g(HPC.COUNT),0);
   const sessActive  = toBool(g(EN.ACTIVE));
   const eSess       = fmtEnergy(toNum(g(EN.ENERGY_KWH),0));
   const eSoc        = fmtEnergy(toNum(g(EN.ENERGY_SOC_KWH),0));
   const eToday      = fmtEnergy(toNum(g(EN.TODAY_KWH),0));
   const eTotal      = fmtEnergy(toNum(g(EN.TOTAL_KWH),0));
   // Punkte für Map: Auto + bis zu 12 HPC
   let list=[]; try{ list = JSON.parse(String(g(HPC.LASTJSON)||'[]')); }catch(e){ list=[]; }
   const top = (Array.isArray(list)?list:[]).slice(0,12);
   const points = [];
   if (Number.isFinite(car.lat) && Number.isFinite(car.lon) && car.lat && car.lon){
     points.push({lat:car.lat, lon:car.lon, label:'Car', type:'car'});
   }
   for (const x of top){
     if (!Number.isFinite(x.lat)||!Number.isFinite(x.lon)) continue;
     const lbl = (x.name||'HPC') + ' · ' + (x.maxPower_kW||'?') + ' kW';
     points.push({lat:x.lat, lon:x.lon, label:lbl, type:'hpc'});
   }
   // SelfTest-Fix: falls centerOverride gesetzt -> nehmen. Sonst fitBounds macht’s automatisch auf Car+HPC
   const map = buildMapIframeHTML(points, centerOverride || null);
   const css =
     '.wrap{color:#e5e7eb;font:14px system-ui,-apple-system,Segoe UI,Roboto;line-height:1.4;background:#0b1020;padding:10px;border-radius:14px;border:1px solid #334155}'+
     '.row{display:flex;flex-wrap:wrap;gap:10px;margin:0 0 10px}'+
     '.chip{background:#111827;border:1px solid #374151;border-radius:999px;padding:6px 10px;font-weight:700}'+
     '.muted{opacity:.8;font-weight:600} .title{font-weight:800;font-size:14px}'+
     '.list{margin-top:10px;border-top:1px solid #334155;padding-top:6px} .item{padding:6px 0;border-bottom:1px dashed #334155}'+
     '.item:last-child{border-bottom:0} .badge{font-weight:700} .ok{color:#34d399} .warn{color:#f59e0b}';
   const listHtml = top.map(o=>{
     const pref = o.preferred ? ' • ★' : '';
     return `<div class="item">• <span class="title">${o.name||'-'}</span> <span class="muted">(${o.operator||'–'}${pref})</span><br/>
       <span class="muted">${o.maxPower_kW||'?'} kW · ${fmtDist(o.distance_m)}</span></div>`;
   }).join('');
   const headChips =
     `<div class="row">
       <span class="chip">Nearest: <span class="badge">${nearestName}</span> <span class="muted">(${nearestOp||'–'})</span></span>
       <span class="chip">Power: ${nearestKW||0} kW</span>
       <span class="chip">Distance: ${fmtDist(nearestDist)}</span>
       <span class="chip">Spots: ${count}</span>
       <span class="chip">Session: ${eSess} kWh</span>
       <span class="chip">Today: ${eToday} kWh</span>
       <span class="chip">Total: ${eTotal} kWh</span>
       <span class="chip">Car Pos: ${Number(car.lat||0).toFixed(5)}, ${Number(car.lon||0).toFixed(5)}</span>
       <span class="chip">Active: <span class="${sessActive?'ok':'warn'}">${sessActive?'yes':'no'}</span></span>
     </div>`;
   const html = `<div class="wrap">${headChips}${map}<div class="list">${listHtml||'<div class="muted">Keine Stationen.</div>'}</div></div>`;
   await ss(CFG.dash.htmlState, `<style>${css}</style>` + html);
 } catch(e){ /* noop */ }
}
/*** ===== Subscriptions / Scheduler ===== ***/
function attachTriggers(){
 // HPC triggers
 on({id: CFG.bluelink.power,    change:'ne'}, ()=> maybeCheckHPC('power_changed'));
 on({id: CFG.bluelink.charging, change:'ne'}, ()=> maybeCheckHPC('charging_changed'));
 on({id: CFG.bluelink.lat,      change:'ne'}, ()=> maybeCheckHPC('lat_changed'));
 on({id: CFG.bluelink.lon,      change:'ne'}, ()=> maybeCheckHPC('lon_changed'));
 on({id: CFG.bluelink.latAlt,   change:'ne'}, ()=> maybeCheckHPC('lat_changed_alt'));
 on({id: CFG.bluelink.lonAlt,   change:'ne'}, ()=> maybeCheckHPC('lon_changed_alt'));
 // Energy ticker
 schedule(`*/${CFG.energy.integTickSec} * * * * *`, integrationTick);
 // Periodische HPC-Abfrage
 schedule('*/5 * * * *', ()=> maybeCheckHPC('scheduled'));
 // Test-Button
 on({id: HPC.CMD_TEST, change:'ne'}, async (s)=>{
   if (s && s.state && s.state.val===true){
     await ss(HPC.CMD_TEST,false);
     const rOverride = toNum(g(HPC.TEST_RADIUS), NaN);
     await maybeCheckHPC('manual_test', { force:true, radiusKmOverride: isFinite(rOverride)?rOverride:undefined });
   }
 });
 // SelfTests – **fixes map centering**: wir übergeben centerOverride
 function selfTest(lat, lon, label){
   return async (s)=>{
     if (s && s.state && s.state.val===true){
       await ss(s.id,false);
       // Debug-Anzeige der Test-Geo
       await ss(HPC.DBG_COORD_LAT, lat); await ss(HPC.DBG_COORD_LON, lon);
       // Direkte OCM-Abfrage (wie maybeCheckHPC, aber ohne Rate-Limit & mit Center-Override)
       try{
         let q = await fetchOCM(lat,lon,Math.max(20,CFG.hpc.radiusKm),Math.max(80,CFG.hpc.maxResults),'strict');
         await ss(HPC.DBG_URL, q.url); await ss(HPC.DBG_HTTP, `self:${q.status}`); await ss(HPC.DBG_RAW, Array.isArray(q.json)?q.json.length:0);
         let enriched = enrichAndFilter(q.json, lat, lon);
         if (enriched.length===0){
           q = await fetchOCM(lat,lon,CFG.hpc.radiusKm,CFG.hpc.maxResults,'dcOnly');
           enriched = enrichAndFilter(q.json, lat, lon);
         }
         if (enriched.length===0){
           const old=CFG.hpc.hpcMinKW; CFG.hpc.hpcMinKW=CFG.hpc.cascadeMinKWClient||120;
           q = await fetchOCM(lat,lon,CFG.hpc.radiusKm,Math.max(CFG.hpc.maxResults,120),'all');
           enriched = enrichAndFilter(q.json, lat, lon);
           CFG.hpc.hpcMinKW=old;
         }
         await ss(HPC.COUNT, enriched.length);
         await ss(HPC.HAS, enriched.length>0);
         await ss(HPC.LASTJSON, JSON.stringify(enriched));
         if (enriched.length){
           const n=enriched[0];
           await ss(HPC.NAME, n.name||''); await ss(HPC.DISTM, n.distance_m||0);
           await ss(HPC.KW, n.maxPower_kW||0); await ss(HPC.OP, n.operator||'');
         } else {
           await ss(HPC.NAME,''); await ss(HPC.DISTM,0); await ss(HPC.KW,0); await ss(HPC.OP,'');
         }
         await ss(HPC.LASTCHK, new Date().toISOString()); await ss(HPC.LASTERR,''); await ss(HPC.LASTWHY, 'selftest_'+label);
         // **WICHTIG**: Karte explizit um die Test-Geo zentrieren
         renderDashboard({lat:lat, lon:lon});
       } catch(err){
         await ss(HPC.LASTERR, String(err?.message||err));
         await ss(HPC.DBG_ERRSHORT, String(err?.message||err));
         renderDashboard({lat:lat, lon:lon});
       }
     }
   };
 }
 on({id:HPC.CMD_TEST_FFM,change:'ne'}, selfTest(50.1109, 8.6821, 'FFM'));
 on({id:HPC.CMD_TEST_CGN,change:'ne'}, selfTest(50.9375, 6.9603, 'CGN'));
 on({id:HPC.CMD_TEST_MUC,change:'ne'}, selfTest(48.1372,11.5756, 'MUC'));
}
/*** ===== Bootstrap ===== ***/
async function init(){
 await ensureHpcStates();
 await ensureEnergyStates();
 await ensureDashState();
 // kleiner Delay, dann initial laden + rendern
 setTimeout(()=>{ maybeCheckHPC('startup'); integrationTick(); }, 1200);
 attachTriggers();
 logI('Init: ready');
}
init();