/**************************************************************
* 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();