/******************************************************
* IONIQ 5 N – Bluelink Dashboard (HTML) for ioBroker
* Version: 1.6.6 (SVG-Chart responsive, volle Kartenbreite & ~Tabelle-Höhe)
* (c) 2025 Bernd / ilovegym – privat
******************************************************/
/* =================== KONFIG =================== */
const BLUELINK_INST = 'bluelink.0';
let VEHICLE_ID = ''; // leer = Auto-Detect
const OUT_HTML_DP = '0_userdata.0.vis.IoniqDashboardHTML';
// Eigene Zusatz-DPs
const DP_12V_LADEN = '0_userdata.0.Zaehler.Hyundai_12V'; // true = 12V lädt
const DP_KM_YESTER = '0_userdata.0.Zaehler.Hyundai_KM_'; // km gestern
// Wallbox (go-e)
const WB = {
energy: 'go-e.0.loaded_energy_kwh',
state: 'go-e.0.car', // 1 Standby, 2 Laden, 3 Warten auf Auto, 4 Fertig
power: 'go-e.0.energy.power',
temp1: 'go-e.0.temperatures.temperature1',
temp2: 'go-e.0.temperatures.temperature2',
allow: 'go-e.0.allow_charging',
amp: 'go-e.0.ampere'
};
const WB_STATE_TXT = {1:'Standby',2:'Laden',3:'Warte auf Auto',4:'Fertig'};
// Ladehistorie (Samples werden hier gespeichert)
const HIST_SAMPLES_DP = '0_userdata.0.Ioniq.History.samples'; // string (JSON Array: {t,soc,wb})
const HIST_LAST_TS_DP = '0_userdata.0.Ioniq.History.lastSample'; // number (ms)
const SAMPLE_INTERVAL_MS = 5 * 60 * 1000; // alle 5 Minuten
const MAX_SAMPLES = 3000; // ~7 Tage bei 5-Minuten-Samples
// Debug
const DEBUG = false;
/* =================== UTIL =================== */
function ensureState(id, def = '', common = {name:'IONIQ 5 N Dashboard HTML', type:'string', role:'html', read:true, write:false}) {
try { if (!existsObject(id)) createState(id, def, true, common); } catch (e) { log('ensureState error ' + e, 'warn'); }
}
function ensureDataPoint(id, def, common){ try{ if(!existsObject(id)) createState(id, def, true, common); }catch(e){} }
function JP(...parts){ return parts.filter(p => p !== '' && p != null).join('.'); }
function es(id){ try { return !!(id && existsState(id)); } catch(_) { return false; } }
function gs(id){ try { return es(id)? getState(id).val:undefined; } catch(_) { return undefined; } }
function ss(id, val){ try { setState(id, val, true); } catch(_){} }
function firstExisting(paths){ if(!Array.isArray(paths)) return {path:null,val:undefined}; for(const p of paths){ if(es(p)){ const v=gs(p); if(v!==undefined && v!==null) return {path:p,val:v}; } } return {path:null,val:undefined}; }
function P(...parts){ return JP(BLUELINK_INST, VEHICLE_ID, ...parts); }
/* =================== VIN-AUTODETECT =================== */
function detectVehicleId(){
try{
const rows = getObjectView('system','state',{ startkey: BLUELINK_INST+'.', endkey: BLUELINK_INST+'.\u9999' }).rows;
const seen = {};
for(const r of rows){
const id = r.id || '';
const seg = id.split('.');
if(seg.length>=3){
const veh = seg[2];
if(veh && !['info','remote','vehicles'].includes(veh)) seen[veh]=true;
}
}
const list = Object.keys(seen);
return list.length ? list[0] : '';
}catch(e){ log('VIN-Detect: '+e,'warn'); return ''; }
}
/* =================== KANDIDATEN =================== */
function candidates(){
return {
// Identität / Fahrt
carName: [ P('general.carName'), P('general.modelName') ],
vin: [ P('general.vin') ],
odometer_km: [ P('odometer.value') ],
speed: [ P('vehicleLocation.speed') ],
// Standort (mit Fallbacks)
lat: [ P('vehicleLocation.lat'), P('location.coord.lat') ],
lon: [ P('vehicleLocation.lon'), P('location.coord.lon') ],
position_text: [ P('vehicleLocation.position_text'), P('location.formattedAddress') ],
position_url: [ P('vehicleLocation.position_url') ],
// HV & 12V
soc_pct: [ P('vehicleStatus.battery.soc') ],
charge_active: [ P('vehicleStatus.battery.charge') ],
minutes_to_charged: [ P('vehicleStatus.battery.minutes_to_charged') ],
plugin_code: [ P('vehicleStatus.battery.plugin') ],
soc12v: [ P('vehicleStatus.battery.soc-12V') ],
state12v: [ P('vehicleStatus.battery.state-12V') ],
soh: [ P('vehicleStatus.battery.soh') ],
charge12v: [ DP_12V_LADEN ],
// Klima & Komfort
hvacOn: [ P('vehicleStatus.airCtrlOn') ],
insideTemp: [ P('vehicleStatus.airTemp') ],
airClean: [ P('vehicleStatusRaw.vehicleStatus.airCleaning.airPurifierStatus') ],
defrost: [ P('vehicleStatusRaw.vehicleStatus.defrost') ],
seatFL: [ P('vehicleStatusRaw.vehicleStatus.seatHeaterVentState.flSeatHeatState') ],
seatFR: [ P('vehicleStatusRaw.vehicleStatus.seatHeaterVentState.frSeatHeatState') ],
seatRL: [ P('vehicleStatusRaw.vehicleStatus.seatHeaterVentState.rlSeatHeatState') ],
seatRR: [ P('vehicleStatusRaw.vehicleStatus.seatHeaterVentState.rrSeatHeatState') ],
steerHeat: [ P('vehicleStatus.steerWheelHeat') ],
// Öffnungen / Fenster
doorFL: [ P('vehicleStatus.doorOpen.frontLeft') ],
doorFR: [ P('vehicleStatus.doorOpen.frontRight') ],
doorRL: [ P('vehicleStatus.doorOpen.backLeft') ],
doorRR: [ P('vehicleStatus.doorOpen.backRight') ],
trunk: [ P('vehicleStatus.trunkOpen') ],
frunk: [ P('vehicleStatus.hoodOpen') ],
winFL: [ P('vehicleStatusRaw.vehicleStatus.windowOpen.frontLeft') ],
winFR: [ P('vehicleStatusRaw.vehicleStatus.windowOpen.frontRight') ],
winRL: [ P('vehicleStatusRaw.vehicleStatus.windowOpen.backLeft') ],
winRR: [ P('vehicleStatusRaw.vehicleStatus.windowOpen.backRight') ],
// Reifen-Warnlampen
tireFL: [ P('vehicleStatusRaw.vehicleStatus.tirePressureLamp.tirePressureLampFL') ],
tireFR: [ P('vehicleStatusRaw.vehicleStatus.tirePressureLamp.tirePressureLampFR') ],
tireRL: [ P('vehicleStatusRaw.vehicleStatus.tirePressureLamp.tirePressureLampRL') ],
tireRR: [ P('vehicleStatusRaw.vehicleStatus.tirePressureLamp.tirePressureLampRR') ],
// Flüssigkeiten & Warnungen
dte: [ P('vehicleStatusRaw.vehicleStatus.dte.value') ],
washer: [ P('vehicleStatus.washerFluidStatus') ], // false=OK, true=leer
smartKeyBat:[ P('vehicleStatus.smartKeyBatteryWarning') ], // true=Warnung
breakOil: [ P('vehicleStatus.breakOilStatus') ], // false=OK, true=Niedrig
// Wallbox
wb_energy: [ WB.energy ],
wb_state: [ WB.state ],
wb_power: [ WB.power ],
wb_temp1: [ WB.temp1 ],
wb_temp2: [ WB.temp2 ],
wb_allow: [ WB.allow ],
wb_amp: [ WB.amp ],
// Zeit / extra
lastUpdate: [ P('info.lastUpdate'), P('vehicleStatus.updatedAt') ],
kmYesterday: [ DP_KM_YESTER ]
};
}
/* =================== CSS =================== */
function css(){ return `
<style>
:root{
--bg:#0a0c10; --card:rgba(18,21,28,0.88); --muted:#8a93a6; --text:#e7ecf6;
--ok:#33d17a; --warn:#ffbf3c; --err:#ff5c5c; --accent:#60a5fa; --chip:#1b2030; --chipText:#cfe0ff;
}
*{box-sizing:border-box}
.wrap{
font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,sans-serif;
color:var(--text); padding:16px; min-height:100vh;
background:url('http://10.1.1.2:8081/files/0_userdata.0/background/IMG_2721.jpeg') center/cover no-repeat fixed;
}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px;align-items:stretch}
.card{background:var(--card);border-radius:12px;padding:12px;box-shadow:0 4px 14px rgba(0,0,0,.25);height:100%;display:flex;flex-direction:column}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.title{display:flex;align-items:center;gap:10px;font-weight:700;font-size:18px;margin-bottom:10px}
.kpi{font-size:26px;font-weight:800}
.sub{color:var(--muted);font-size:13px}
.badge{background:#1b2030;color:#cfe0ff;padding:6px 12px;border-radius:10px;font-size:14px;display:inline-block;max-width:100%;white-space:normal;word-break:break-word;text-align:center}
.stat{display:flex;justify-content:space-between;margin:4px 0;font-size:13px}
.meter{height:10px;background:#0f1220;border-radius:8px;overflow:hidden}
.meter>span{display:block;height:100%}
.kv{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:8px}
.tile{background:#0f1220;border-radius:8px;padding:6px;font-size:12px;display:flex;align-items:center;gap:6px}
.ok{color:var(--ok)} .warn{color:var(--warn)} .err{color:var(--err)} .acc{color:var(--accent)}
.footer{margin-top:8px;color:var(--muted);font-size:12px;text-align:right}
table{width:100%;border-collapse:collapse;font-size:12px}
th,td{padding:6px 8px;border-bottom:1px solid #222}
th{text-align:left;color:#cfe0ff}
/* Chart Box: gleiche Höhe wie Tabelle (~280px), passt sich Breite an */
.chartBox{width:100%;height:280px;display:block}
.chartBox svg{width:100%;height:100%;display:block}
</style>`; }
/* =================== SVG CHART BUILDER =================== */
function buildHistorySVG(labels, soc, kwh){
// Basisgröße für viewBox (skaliert über CSS auf 100%x100% in .chartBox)
const W=720, H=280; // größer als vorher
const padL=44, padR=14, padT=14, padB=34;
const x0=padL, y0=padT, x1=W-padR, y1=H-padB;
const w=x1-x0, h=y1-y0;
const n = Math.max(1, labels.length||1);
const xAt = (i)=> x0 + (n<=1 ? 0 : (w * i/(n-1)));
const ySoc = (v)=> y0 + (1 - (Math.max(0,Math.min(100, +v||0))/100)) * h;
// Bars scale
let maxK=0; for (let i=0;i<kwh.length;i++){ const v=+kwh[i]||0; if (v>maxK) maxK=v; }
if (maxK<1) maxK=1;
const barW = Math.max(8, Math.min(28, w / (n*1.8)));
const bars = [];
for (let i=0;i<n;i++){
const xx = xAt(i);
const kh = ( ( (+kwh[i]||0)/maxK ) * h );
const x = xx - barW/2;
const y = y1 - kh;
const bw = barW;
const bh = kh;
bars.push(`<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${bw.toFixed(1)}" height="${bh.toFixed(1)}" rx="2" fill="#60a5fa" />`);
}
// SOC path
let d='';
for (let i=0;i<n;i++){
const xx = xAt(i), yy = ySoc(soc[i]||0);
d += (i===0? 'M':' L') + xx.toFixed(1) + ' ' + yy.toFixed(1);
}
const points = soc.map((v,i)=>{
const xx=xAt(i), yy=ySoc(v||0);
return `<circle cx="${xx.toFixed(1)}" cy="${yy.toFixed(1)}" r="3" fill="#33d17a"/>`;
}).join('');
// grid & labels
const grid=[];
for (let gy=0; gy<=5; gy++){
const yy = y0 + h*(gy/5);
grid.push(`<line x1="${x0}" y1="${yy.toFixed(1)}" x2="${x1}" y2="${yy.toFixed(1)}" stroke="rgba(255,255,255,0.15)" stroke-width="1"/>`);
}
const ylab=[];
for (let gy=0; gy<=5; gy++){
const val = 100 - gy*20;
const yy = y0 + h*(gy/5);
ylab.push(`<text x="${x0-8}" y="${yy+4}" fill="#cfe0ff" font-size="12" text-anchor="end">${val}</text>`);
}
const xlab = labels.map((t,i)=>{
const xx = xAt(i);
return `<text x="${xx}" y="${y1+18}" fill="#cfe0ff" font-size="12" text-anchor="middle">${String(t)}</text>`;
}).join('');
return `
<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Ladehistorie">
<rect x="0" y="0" width="${W}" height="${H}" fill="transparent"/>
<rect x="${x0}" y="${y0}" width="${w}" height="${h}" rx="6" fill="rgba(255,255,255,0.04)"/>
${grid.join('')}
${ylab.join('')}
${xlab}
${bars.join('')}
<path d="${d}" fill="none" stroke="#33d17a" stroke-width="2.5"/>
${points}
<!-- Legende -->
<g>
<rect x="${x0+6}" y="${y0+6}" width="12" height="3" fill="#33d17a"/><text x="${x0+24}" y="${y0+14}" font-size="13" fill="#cfe0ff">SOC %</text>
<rect x="${x0+92}" y="${y0+6}" width="12" height="12" fill="#60a5fa"/><text x="${x0+110}" y="${y0+16}" font-size="13" fill="#cfe0ff">kWh/Tag</text>
</g>
</svg>`;
}
/* =================== RENDER =================== */
function renderHTML(d, hist, lastSamples){
if (!d || d.__noVin){
return `${css()}<div class="wrap"><div class="card"><div class="kpi">Bluelink</div><div class="sub">Bitte VIN (VEHICLE_ID) setzen oder Auto-Detect abwarten.</div></div></div>`;
}
const hvColor = p=>p<75?'#ff5c5c':(p<80?'#ffbf3c':'#33d17a');
const socBar = p => (p!=null ? `
<div class="meter" style="height:14px;border-radius:7px;overflow:hidden">
<span style="width:${Math.max(0,Math.min(100,p))}%;background:${hvColor(p)}"></span>
</div>
<div style="font-size:12px;text-align:right;margin-top:2px">${p}%</div>` : '–');
const seatTxt = v => (v===1?'<span class="heatAnim">🔥 Heizen</span>':(v===0?'<span class="coolAnim">❄️ Kühlen</span>':'Aus'));
const flag = (v, ok='geschlossen', bad='offen') => v===true?`<b class="err">${bad}</b>`:(v===false?`<b class="ok">${ok}</b>`:'–');
const tireTxt = v => (v===true ? '<b class="warn">Warn</b>' : (v===false ? '<b class="ok">OK</b>' : '–'));
const locStr = d.address || '–';
const coords = (d.lat!=null && d.lon!=null) ? `${(+d.lat).toFixed(5)}, ${(+d.lon).toFixed(5)}` : '–';
const wbStateTxt = (d.wb_stateNum!=null? (WB_STATE_TXT[d.wb_stateNum]||String(d.wb_stateNum)) : (d.wb_stateText||'–'));
// Historie-Daten (letzte 7 Tage)
const labels = hist.labels || ['Mo','Di','Mi','Do','Fr','Sa','So'];
const socLine = hist.dailySoc || [0,0,0,0,0,0,0];
const kwhBars = hist.dailyKwh || [0,0,0,0,0,0,0];
// Tabelle der letzten 10 Einträge
let tableRows = '';
if (lastSamples && lastSamples.length){
const last10 = lastSamples.slice(-10);
tableRows = last10.map(s=>{
const dt = new Date(s.t).toLocaleString();
const soc = (typeof s.soc==='number') ? (Math.round(s.soc*10)/10)+' %' : '–';
const wb = (typeof s.wb ==='number') ? (Math.round(s.wb*100)/100).toFixed(2)+' kWh' : '–';
return `<tr><td>${dt}</td><td>${soc}</td><td>${wb}</td></tr>`;
}).join('');
}
const svgChart = buildHistorySVG(labels, socLine, kwhBars);
return `
${css()}
<div class="wrap">
<div class="title"><span class="kpi">${d.carName||'IONIQ 5 N'}</span> <span class="badge">${d.vin||''}</span></div>
<div class="grid">
<!-- HV Akku -->
<div class="card">
<div class="row">🔋 <b>State of Charge</b></div>
<div class="kpi" style="margin:6px 0">${d.soc!=null? d.soc+' %' : '–'}</div>
${socBar(d.soc)}
<div class="stat"><span>Laden</span><span class="${d.charging?'chargeAnim':''}"><b>${d.charging===true?'aktiv':(d.charging===false?'nein':'–')}</b></span></div>
<div class="stat"><span>Min. bis voll</span><span><b>${d.minToFull!=null? d.minToFull : '–'}</b></span></div>
<div class="stat"><span>Reichweite</span><span><b>${d.dte!=null? d.dte+' km':'–'}</b></span></div>
</div>
<!-- 12V / SOH -->
<div class="card">
<div class="row">🔋 <b>12V & SOH</b></div>
<div class="stat"><span>12V SoC</span><span style="flex:1;margin-left:10px">${socBar(d.soc12v)}</span></div>
<div class="stat"><span>12V Status</span><span><b>${d.state12v ?? '–'}</b></span></div>
<div class="stat"><span>12V lädt</span><span><b>${d.charge12v===true?'Ja':(d.charge12v===false?'Nein':'–')}</b></span></div>
<div class="stat"><span>SOH (HV)</span><span><b>${d.soh!=null? d.soh+' %':'–'}</b></span></div>
</div>
<!-- Fahrzeug -->
<div class="card">
<div class="row">🚗 <b>Fahrzeug</b></div>
<div class="stat"><span>Odometer</span><span><b>${d.odoKm!=null? d.odoKm+' km':'–'}</b></span></div>
<div class="stat"><span>Gestern gefahren</span><span><b>${d.kmYesterday!=null? d.kmYesterday+' km':'–'}</b></span></div>
</div>
<!-- Standort -->
<div class="card">
<div class="row">📍 <b>Standort</b></div>
<div class="badge">${locStr}</div>
<div class="sub" style="margin-top:8px">Koordinaten: <b>${coords}</b></div>
</div>
<!-- Türen -->
<div class="card">
<div class="row">🚪 <b>Öffnungen</b></div>
<div class="kv">
<div class="tile">VL: ${flag(d.doorFL)}</div>
<div class="tile">VR: ${flag(d.doorFR)}</div>
<div class="tile">HL: ${flag(d.doorRL)}</div>
<div class="tile">HR: ${flag(d.doorRR)}</div>
<div class="tile">Kofferraum: ${flag(d.trunk)}</div>
<div class="tile">Frunk: ${flag(d.frunk)}</div>
</div>
<div class="sub" style="margin-top:6px">Gesamt: ${ [d.doorFL,d.doorFR,d.doorRL,d.doorRR,d.trunk,d.frunk].some(v=>v===true) ? '<b class="err">offen</b>' : '<b class="ok">alle zu</b>' }</div>
</div>
<!-- Fenster -->
<div class="card">
<div class="row">🪟 <b>Fenster</b></div>
<div class="kv">
<div class="tile">VL: ${flag(d.winFL,'zu','offen')}</div>
<div class="tile">VR: ${flag(d.winFR,'zu','offen')}</div>
<div class="tile">HL: ${flag(d.winRL,'zu','offen')}</div>
<div class="tile">HR: ${flag(d.winRR,'zu','offen')}</div>
</div>
</div>
<!-- Reifen -->
<div class="card">
<div class="row">🛞 <b>Reifen</b></div>
<div class="kv">
<div class="tile">VL: ${tireTxt(d.tireFL)}</div>
<div class="tile">VR: ${tireTxt(d.tireFR)}</div>
<div class="tile">HL: ${tireTxt(d.tireRL)}</div>
<div class="tile">HR: ${tireTxt(d.tireRR)}</div>
</div>
</div>
<!-- Klima & Komfort -->
<div class="card">
<div class="row">❄️ <b>Klima & Komfort</b></div>
<div class="stat"><span>Klima</span><span class="${d.hvacOn?'fanAnim':''}"><b>${d.hvacOn===true?'AN':(d.hvacOn===false?'AUS':'–')}</b></span></div>
<div class="stat"><span>Innen</span><span><b>${d.tIn!=null? d.tIn+' °C':'–'}</b></span></div>
<div class="stat"><span>Luftreiniger</span><span><b>${d.airClean===true?'AN':(d.airClean===false?'AUS':'–')}</b></span></div>
<div class="stat"><span>Defrost</span><span><b>${d.defrost===true?'AN':(d.defrost===false?'AUS':'–')}</b></span></div>
<div class="stat"><span>Lenkrad</span><span>${d.steerHeat===true?'<span class="heatAnim">🔥</span>':'AUS'}</span></div>
<div class="sub" style="margin-top:6px">Sitze:</div>
<div class="kv">
<div class="tile">VL: <b>${seatTxt(d.seatFL)}</b></div>
<div class="tile">VR: <b>${seatTxt(d.seatFR)}</b></div>
<div class="tile">HL: <b>${seatTxt(d.seatRL)}</b></div>
<div class="tile">HR: <b>${seatTxt(d.seatRR)}</b></div>
</div>
</div>
<!-- Fahrzeugstatus -->
<div class="card">
<div class="row">ℹ️ <b>Fahrzeugstatus</b></div>
<div class="kv">
<div class="tile">Wischerwasser: ${d.washer===true?'<b class="err">LEER</b>':(d.washer===false?'<b class="ok">OK</b>':'–')}</div>
<div class="tile">Bremsöl: ${d.breakOil===true?'<b class="err">NIEDRIG</b>':(d.breakOil===false?'<b class="ok">OK</b>':'–')}</div>
<div class="tile">Smartkey: ${d.smartKeyBat===true?'<b class="warn">WARNUNG</b>':(d.smartKeyBat===false?'<b class="ok">OK</b>':'–')}</div>
</div>
</div>
<!-- Wallbox -->
<div class="card">
<div class="row">🔌 <b>Wallbox</b></div>
<div class="kv">
<div class="tile">Status: <b>${wbStateTxt}</b></div>
<div class="tile">Leistung: <b>${d.wb_powerFmt || '–'}</b></div>
<div class="tile">Energie: <b>${d.wb_energyFmt || '–'} kWh</b></div>
<div class="tile">Ampere: <b>${d.wb_ampere!=null? d.wb_ampere+' A':'–'}</b></div>
<div class="tile">Freigabe: <b>${d.wb_allow===true?'Ja':(d.wb_allow===false?'Nein':'–')}</b></div>
<div class="tile">Temp1: <b>${d.wb_temp1!=null? d.wb_temp1+' °C':'–'}</b></div>
<div class="tile">Temp2: <b>${d.wb_temp2!=null? d.wb_temp2+' °C':'–'}</b></div>
</div>
</div>
<!-- Ladehistorie (7 Tage) – reines SVG, volle Breite/Höhe -->
<div class="card">
<div class="row">📈 <b>Ladehistorie (7 Tage)</b></div>
<div class="chartBox">
${svgChart}
</div>
</div>
<!-- Ladehistorie Tabelle (letzte 10 Samples) -->
<div class="card">
<div class="row">⚡ <b>Ladehistorie – letzte 10 Werte</b></div>
${ (lastSamples && lastSamples.length)
? `<table><tr><th>Zeitpunkt</th><th>SOC</th><th>Wallbox</th></tr>${tableRows}</table>`
: `<div class="sub">Keine Daten vorhanden.</div>` }
</div>
</div>
<div class="footer">Zuletzt aktualisiert: ${d.lastUpdate || new Date().toLocaleString()}</div>
</div>`;
}
/* =================== HISTORIE: DPs + Verarbeitung =================== */
function ensureHistoryDPs(){
ensureDataPoint(HIST_SAMPLES_DP, '[]', {name:'Ioniq History Samples', type:'string', role:'json'});
ensureDataPoint(HIST_LAST_TS_DP, 0, {name:'Ioniq History Last Sample', type:'number', role:'value.time'});
}
function _normalizeSamples(arr){
const now = Date.now();
const twelveH = 12*3600*1000;
return arr.map(s=>{
if(!s) return null;
let t = Number(s.t);
if (!isFinite(t)) return null;
if (t < 1e12) t = t * 1000; // Sekunden -> ms
if (t > now + twelveH) return null; // Zukunft verwerfen
const out = { t };
if (typeof s.soc === 'number' && isFinite(s.soc)) out.soc = s.soc;
if (typeof s.wb === 'number' && isFinite(s.wb )) out.wb = s.wb;
return out;
}).filter(Boolean);
}
function loadSamples(){
try{
const txt=gs(HIST_SAMPLES_DP);
if(!txt) return [];
const parsed = JSON.parse(txt);
const arr = Array.isArray(parsed)? parsed : [];
return _normalizeSamples(arr);
}catch(_){ return []; }
}
function saveSamples(arr){
try{
if (arr.length>MAX_SAMPLES) arr = arr.slice(arr.length-MAX_SAMPLES);
ss(HIST_SAMPLES_DP, JSON.stringify(arr));
}catch(_){}
}
function trySample(nowMs, data){
const lastTs = Number(gs(HIST_LAST_TS_DP) || 0);
if (nowMs - lastTs < SAMPLE_INTERVAL_MS) return;
const soc = data._hist_soc;
const wb = data._hist_wb_energy;
if (soc==null && wb==null) {
ss(HIST_LAST_TS_DP, nowMs);
if (DEBUG) log('[IONIQ5N] Sample SKIP: keine Werte (soc/wb beide leer)', 'info');
return;
}
let arr = loadSamples();
arr.push({ t: nowMs, soc: soc, wb: wb });
saveSamples(arr);
ss(HIST_LAST_TS_DP, nowMs);
if (DEBUG) {
const socTxt = (soc==null)?'—':String(soc);
const wbTxt = (wb==null)?'—':String(wb);
log(`[IONIQ5N] Sample OK @ ${new Date(nowMs).toLocaleString()} | SOC=${socTxt} | WB=${wbTxt}`, 'info');
}
}
function computeDailyFromSamples(nowMs){
const arr = loadSamples();
// Labels & Tageskeys (letzte 7 Tage inkl. heute)
const labels=[], dayKeys=[];
for(let i=6;i>=0;i--){
const d=new Date(nowMs - i*24*3600*1000);
labels.push(d.toLocaleDateString(undefined,{weekday:'short'}));
dayKeys.push(`${d.getFullYear()}-${('0'+(d.getMonth()+1)).slice(-2)}-${('0'+d.getDate()).slice(-2)}`);
}
if(!arr.length) return {labels, dailySoc:[0,0,0,0,0,0,0], dailyKwh:[0,0,0,0,0,0,0]};
const byDay={};
for(const s of arr){
const d=new Date(s.t);
const key=`${d.getFullYear()}-${('0'+(d.getMonth()+1)).slice(-2)}-${('0'+d.getDate()).slice(-2)}`;
if(!byDay[key]) byDay[key]={soc:[], wb:[]};
if(typeof s.soc==='number') byDay[key].soc.push(s.soc);
if(typeof s.wb ==='number') byDay[key].wb.push(s.wb);
}
const dailySoc=[], dailyKwh=[];
for(const key of dayKeys){
const g=byDay[key];
if(!g){ dailySoc.push(0); dailyKwh.push(0); continue; }
const avgSoc = g.soc.length ? Math.round(g.soc.reduce((a,b)=>a+b,0)/g.soc.length) : 0;
let kwh=0;
if(g.wb.length){
const mn=Math.min.apply(null,g.wb), mx=Math.max.apply(null,g.wb);
kwh = mx-mn; if(!isFinite(kwh)||kwh<0) kwh=0;
kwh = Math.round(kwh*100)/100;
}
dailySoc.push(avgSoc);
dailyKwh.push(kwh);
}
return {labels, dailySoc, dailyKwh};
}
/* =================== DATEN SAMMELN =================== */
function readAll(){
const cand = candidates();
const pick = (name, map=v=>v)=>{
const {val} = firstExisting(cand[name]||[]);
if (val===undefined) return undefined;
try{
if (typeof val === 'string' && !isNaN(val)) return map(Number(val));
return map(val);
}catch(_){ return val; }
};
const toBool = (v) => (typeof v==='boolean') ? v : (v!=null ? Number(v)>0 : undefined);
// Fahrwerte
const odoKm = (function(){
const v = pick('odometer_km', x=>x);
if (v===undefined) return undefined;
const num = typeof v==='string' ? parseFloat(v.replace(/[^\d.,]/g,'').replace(',','.')) : Number(v);
return isNaN(num) ? undefined : Math.round(num);
})();
// Wallbox Zahlen
const wb_stateNum = pick('wb_state', Number);
const wb_powerNum = pick('wb_power', Number);
const wb_energyNum= pick('wb_energy', Number);
const data = {
// Kopf
carName: pick('carName'),
vin: pick('vin'),
// HV / 12V
soc: pick('soc_pct', v => Math.round(Number(v))),
charging: pick('charge_active', toBool),
minToFull: pick('minutes_to_charged', Number),
soc12v: pick('soc12v', v => Math.round(Number(v))),
state12v: pick('state12v'),
soh: pick('soh', v => Math.round(Number(v))),
charge12v: pick('charge12v', v => v === true || v === 'true' || Number(v) === 1),
// Reichweite & Klima
dte: pick('dte', v => Math.round(Number(v))),
hvacOn: pick('hvacOn', toBool),
tIn: pick('insideTemp', v => Math.round(Number(v)*10)/10),
airClean: pick('airClean', toBool),
defrost: pick('defrost', toBool),
seatFL: pick('seatFL', Number),
seatFR: pick('seatFR', Number),
seatRL: pick('seatRL', Number),
seatRR: pick('seatRR', Number),
steerHeat: pick('steerHeat', toBool),
// Öffnungen / Fenster
doorFL: pick('doorFL', toBool),
doorFR: pick('doorFR', toBool),
doorRL: pick('doorRL', toBool),
doorRR: pick('doorRR', toBool),
trunk: pick('trunk', toBool),
frunk: pick('frunk', toBool),
winFL: pick('winFL', toBool),
winFR: pick('winFR', toBool),
winRL: pick('winRL', toBool),
winRR: pick('winRR', toBool),
// Reifenlampen
tireFL: pick('tireFL', toBool),
tireFR: pick('tireFR', toBool),
tireRL: pick('tireRL', toBool),
tireRR: pick('tireRR', toBool),
// Flüssigkeiten & Warnungen
washer: pick('washer', toBool),
breakOil: pick('breakOil', toBool),
smartKeyBat: pick('smartKeyBat', toBool),
// Standort
lat: pick('lat', Number),
lon: pick('lon', Number),
address: pick('position_text', String),
positionUrl: pick('position_url', String),
// Wallbox – formatierte Strings
wb_stateNum,
wb_stateText: (wb_stateNum!=null ? (WB_STATE_TXT[wb_stateNum] || String(wb_stateNum)) : undefined),
wb_powerFmt: (wb_powerNum!=null ? (wb_powerNum >= 1000 ? (Math.round(wb_powerNum/100)/10)+' kW' : Math.round(wb_powerNum)+' W') : undefined),
wb_energyFmt: (wb_energyNum!=null ? wb_energyNum.toFixed(2) : undefined),
wb_ampere: pick('wb_amp', v => Math.round(Number(v))),
wb_allow: pick('wb_allow', toBool),
wb_temp1: pick('wb_temp1', v => Math.round(Number(v))),
wb_temp2: pick('wb_temp2', v => Math.round(Number(v))),
// Zeit / extra
lastUpdate: (function(){
const raw = pick('lastUpdate');
if (!raw) return '';
try{ const d=new Date(raw); return isNaN(d)? String(raw) : d.toLocaleString(); }catch(_){ return String(raw); }
})(),
kmYesterday: pick('kmYesterday', v=> (v==null? undefined : Math.round(Number(v)))),
// Rohwerte für Historie-Sampling
_hist_soc: pick('soc_pct', Number),
_hist_wb_energy: wb_energyNum,
// Odometer
odoKm
};
return data;
}
/* =================== MAIN =================== */
ensureState(OUT_HTML_DP);
ensureHistoryDPs();
let vinAnnounced = false;
function update(){
try{
const nowMs = Date.now();
if (!VEHICLE_ID){
VEHICLE_ID = detectVehicleId();
if (VEHICLE_ID && !vinAnnounced){ log('[IONIQ5N] Auto-Detected VIN: '+VEHICLE_ID,'info'); vinAnnounced=true; }
}
if (!VEHICLE_ID){
ss(OUT_HTML_DP, renderHTML({__noVin:true}, {labels:[],dailySoc:[],dailyKwh:[]}, []));
return;
}
const data = readAll();
trySample(nowMs, data);
const hist = computeDailyFromSamples(nowMs);
const lastSamples = loadSamples();
const html = renderHTML(data, hist, lastSamples);
ss(OUT_HTML_DP, html);
}catch(e){ log('Update error: '+e, 'error'); }
}
// Initial + Intervall
update();
schedule('*/30 * * * * *', update);