Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,17 @@

## [Unreleased]

### Fixed
- Skoda: AC-Zieltemperatur ab 15,5 °C in 0,5°-Schritten wählbar (der Elroq
meldet eine Untergrenze von 15,5 °C, die die bisherige Validierung ablehnte).

### Added
- Skoda: read-only Details-Block (TP4a) in Admin und Portal — Stammdaten
(Modell, Baujahr, Leistung, Batterie, max. Ladeleistung), Ausstattung,
Verbindungs-/Zündungsstatus und Fahrstil-Score (Woche/Monat), lazy beim
Aufklappen geladen, owner-gegatet, VIN im Portal maskiert. (Ladehistorie,
Fahrstatistiken und OTA-Status liefert die Skoda-API für diese Fahrzeuge
derzeit nicht — 500/403 — und sind bewusst nicht enthalten.)
- Skoda Connect portal widget (TP2): logged-in portal users see their own
vehicles (lock, battery/charging, climate, position with address, health/
maintenance) read-only, owner-gated, with reverse-geocoded location.
Expand Down
3 changes: 3 additions & 0 deletions public/css/portal.css
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,9 @@ body::before{
.skoda-dot.off { background: var(--faint); }
.skoda-details { margin-top: 10px; font-size: .88em; color: var(--muted); }
.skoda-details summary { cursor: pointer; }
.skoda-enrich { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.skoda-details-equipment { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; }
.skoda-details-error { opacity: .7; font-style: italic; color: var(--muted); }

/* ============================================================
SMART HOME WIDGET
Expand Down
6 changes: 6 additions & 0 deletions public/css/skoda.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@
.skoda-cmds label { display: inline-flex; align-items: center; gap: 4px; font-size: .85em; }
.skoda-cmds input[type="number"] { width: 60px; }
.skoda-cmds .btn-danger, .skoda-cmds button.btn-danger { background: var(--danger, #da3633); color: #fff; border-color: var(--danger, #da3633); }
.skoda-details-block { margin-top: 12px; font-size: .85em; }
.skoda-details-block summary { cursor: pointer; opacity: .8; }
.skoda-enrich { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.skoda-details-equipment { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; }
.skoda-chip { font-size: .82em; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border-color, rgba(128,128,128,0.25)); opacity: .85; }
.skoda-details-error { opacity: .7; font-style: italic; }
84 changes: 82 additions & 2 deletions public/js/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,8 @@
}

function skodaEl() { return document.getElementById('skoda-list'); }
var skodaEnrich = {}; // vehId -> rendered details HTML, cached only on success/unavailable
var skodaPending = {}; // vehId currently being fetched (in-flight guard)

function skodaRingSvg(soc, charging) {
var r = 52, C = 2 * Math.PI * r;
Expand Down Expand Up @@ -912,6 +914,70 @@
.then(function () { clearTimeout(watchdog); setTimeout(reset, 3000); });
}

// Read-only enrichment (meta/equipment/connection/drivingScore) — same four
// groups as the admin side, redacted (masked VIN). Every value is escHtml'd
// before it reaches innerHTML, including numbers, per the XSS guard convention
// already used throughout this file (see numOr above).
function skodaDetailsHtml(d) {
var meta = d.meta || {};
var rows = [];
var title = meta.title || meta.model;
if (title != null) rows.push('<div>' + escHtml(PT.skodaDetailsModel) + ': ' + escHtml(title) + '</div>');
if (meta.modelYear != null) rows.push('<div>' + escHtml(PT.skodaDetailsYear) + ': ' + escHtml(meta.modelYear) + '</div>');
if (meta.manufacturingDate != null) rows.push('<div>' + escHtml(PT.skodaDetailsMade) + ': ' + escHtml(meta.manufacturingDate) + '</div>');
if (meta.body != null) rows.push('<div>' + escHtml(PT.skodaDetailsBody) + ': ' + escHtml(meta.body) + '</div>');
if (meta.trimLevel != null) rows.push('<div>' + escHtml(PT.skodaDetailsTrim) + ': ' + escHtml(meta.trimLevel) + '</div>');
if (meta.powerKw != null) rows.push('<div>' + escHtml(PT.skodaDetailsPower) + ': ' + numOr(meta.powerKw, ' kW') + '</div>');
if (meta.batteryKwh != null) rows.push('<div>' + escHtml(PT.skodaDetailsBattery) + ': ' + numOr(meta.batteryKwh, ' kWh') + '</div>');
if (meta.maxChargingKw != null) rows.push('<div>' + escHtml(PT.skodaDetailsMaxCharging) + ': ' + numOr(meta.maxChargingKw, ' kW') + '</div>');
if (meta.vin) rows.push('<div class="skoda-details-vin">' + escHtml(meta.vin) + '</div>');
var html = rows.length ? '<div class="skoda-details-meta">' + rows.join('') + '</div>' : '';

var equipment = Array.isArray(d.equipment) ? d.equipment : [];
if (equipment.length) {
html += '<div class="skoda-details-equipment"><strong>' + escHtml(PT.skodaDetailsEquipment) + '</strong> '
+ equipment.map(function (e) { return '<span class="skoda-chip">' + escHtml(e) + '</span>'; }).join('') + '</div>';
}

var conn = d.connection;
if (conn) {
var parts = [];
if (conn.online != null) parts.push(conn.online ? PT.skodaDetailsOnline : PT.skodaDetailsOffline);
if (conn.ignitionOn != null) parts.push(conn.ignitionOn ? PT.skodaDetailsIgnitionOn : PT.skodaDetailsIgnitionOff);
if (conn.inMotion) parts.push(PT.skodaDetailsInMotion);
if (parts.length) html += '<div class="skoda-details-connection"><strong>' + escHtml(PT.skodaDetailsConnection) + '</strong>: ' + escHtml(parts.join(', ')) + '</div>';
}

var score = d.drivingScore;
if (score) {
var sparts = [];
if (score.weekly != null) sparts.push(escHtml(PT.skodaDetailsScoreWeekly) + ': ' + numOr(score.weekly));
if (score.monthly != null) sparts.push(escHtml(PT.skodaDetailsScoreMonthly) + ': ' + numOr(score.monthly));
if (score.lastCalculationDate != null) sparts.push(escHtml(PT.skodaDetailsScoreAsOf) + ': ' + escHtml(score.lastCalculationDate));
if (sparts.length) html += '<div class="skoda-details-score"><strong>' + escHtml(PT.skodaDetailsScore) + '</strong>: ' + sparts.join(' · ') + '</div>';
}
return html;
}

function loadSkodaDetails(vehId, container) {
if (skodaEnrich[vehId] != null) { container.innerHTML = skodaEnrich[vehId]; return; }
if (skodaPending[vehId]) return; // a fetch is already in flight for this vehicle
skodaPending[vehId] = true;
fetch('/api/v1/portal/skoda/vehicles/' + vehId + '/details')
.then(function (r) { return r.json().catch(function () { return {}; }).then(function (body) { return { status: r.status, body: body }; }); })
.then(function (res) {
var body = res.body;
if (body && body.ok && body.data === null) { skodaEnrich[vehId] = ''; container.innerHTML = ''; return; } // unavailable → stable, cache empty
if (body && body.ok && body.data) { var html = skodaDetailsHtml(body.data); skodaEnrich[vehId] = html; container.innerHTML = html; return; }
// Error (esp. 429) is transient — show it but do NOT cache, so reopening retries.
container.innerHTML = '<p class="skoda-details-error">' + escHtml(res.status === 429 ? PT.skodaDetailsRateLimited : PT.skodaDetailsLoadError) + '</p>';
})
.catch(function () {
container.innerHTML = '<p class="skoda-details-error">' + escHtml(PT.skodaDetailsLoadError) + '</p>';
})
.finally(function () { delete skodaPending[vehId]; });
}

var _skodaCmdBound = false;
function bindSkodaCommands() {
if (_skodaCmdBound) return; _skodaCmdBound = true;
Expand Down Expand Up @@ -977,11 +1043,12 @@
+ (mt.dueInKm != null ? ' · ' + numOr(mt.dueInKm, ' km') : '') + '</div>'
+ (mt.partner ? '<div>' + escHtml(PT.skodaPartner) + ': ' + escHtml(mt.partner) + '</div>' : '')
+ (hl.warnings && hl.warnings.length ? '<div>' + escHtml(PT.skodaWarnings) + ': ' + escHtml(hl.warnings.join(', ')) + '</div>' : '')
+ '<div class="skoda-enrich" data-veh="' + escHtml(v.id) + '"></div>'
+ '</details>'
+ (loggedIn ? '<div class="skoda-cmds" data-veh="' + escHtml(v.id) + '">'
+ '<button data-cmd="ac_start" data-temp="21">' + escHtml(PT.skodaCmdAcOn) + '</button>'
+ '<button data-cmd="ac_stop">' + escHtml(PT.skodaCmdAcOff) + '</button>'
+ '<label>' + escHtml(PT.skodaCmdSetTemp) + ' <input type="number" min="16" max="30" step="1" value="21" data-temp-input></label>'
+ '<label>' + escHtml(PT.skodaCmdSetTemp) + ' <input type="number" min="15.5" max="30" step="0.5" value="21" data-temp-input></label>'
+ '<button data-cmd="ac_temp">' + escHtml(PT.skodaCmdSetTemp) + '</button>'
+ '<button data-cmd="charge_start">' + escHtml(PT.skodaCmdChargeOn) + '</button>'
+ '<button data-cmd="charge_stop">' + escHtml(PT.skodaCmdChargeOff) + '</button>'
Expand Down Expand Up @@ -1009,7 +1076,20 @@
el.innerHTML = vehicles.map(function (v) { return renderSkodaCard(v, loggedIn); }).join('');
var newCards = el.querySelectorAll('.skoda-card');
for (var j = 0; j < newCards.length; j++) {
if (wasOpen[j]) { var nd = newCards[j].querySelector('details'); if (nd) nd.open = true; }
var nd = newCards[j].querySelector('details'); if (!nd) continue;
// toggle never bubbles — (re)bind on every rebuilt <details> instance.
nd.addEventListener('toggle', function (ev) {
if (!ev.target.open) return;
var box = ev.target.querySelector('.skoda-enrich'); if (!box) return;
loadSkodaDetails(Number(box.dataset.veh), box);
});
if (wasOpen[j]) {
nd.open = true;
// 120s poll rebuild would otherwise blank an already-fetched block.
var box = nd.querySelector('.skoda-enrich');
var vehId = box ? Number(box.dataset.veh) : NaN;
if (box && skodaEnrich[vehId] != null) box.innerHTML = skodaEnrich[vehId];
}
}
}

Expand Down
93 changes: 91 additions & 2 deletions public/js/skoda.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
let current = { accounts: [], vehicles: [] };
let ownerVehicleId = null;
let spinAccountId = null;
const skodaEnrich = {}; // vehId -> rendered details HTML, cached only on success
const skodaPending = new Set(); // vehId currently being fetched (in-flight guard)

function accountRow(a) {
const statusKey = `skoda.accounts.status.${a.status}`;
Expand Down Expand Up @@ -64,7 +66,7 @@
<div class="skoda-cmds" data-veh="${v.id}">
<button class="btn btn-sm" data-cmd="ac_start" data-temp="21">${T('skoda.cmd.ac_on')}</button>
<button class="btn btn-sm" data-cmd="ac_stop">${T('skoda.cmd.ac_off')}</button>
<label>${T('skoda.cmd.set_temp')} <input type="number" min="16" max="30" step="1" value="21" data-temp-input></label>
<label>${T('skoda.cmd.set_temp')} <input type="number" min="15.5" max="30" step="0.5" value="21" data-temp-input></label>
<button class="btn btn-sm" data-cmd="ac_temp">${T('skoda.cmd.set_temp')}</button>
<button class="btn btn-sm" data-cmd="charge_start">${T('skoda.cmd.charge_on')}</button>
<button class="btn btn-sm" data-cmd="charge_stop">${T('skoda.cmd.charge_off')}</button>
Expand All @@ -74,9 +76,75 @@
<button class="btn btn-sm btn-danger" data-cmd="unlock">${T('skoda.cmd.unlock')}</button>
<label>${T('skoda.cmd.set_limit')} <select data-cmd="charge_limit"><option>50</option><option>60</option><option>70</option><option>80</option><option>90</option><option>100</option></select></label>
</div>
<details class="skoda-details-block"><summary>${T('skoda.details.title')}</summary><div class="skoda-enrich" data-veh="${v.id}"></div></details>
</div>`;
}

// Read-only enrichment (meta/equipment/connection/drivingScore) — fetched once
// per vehicle on first <details> open, cached in skodaEnrich so re-opening or a
// subsequent load() rebuild never re-fetches.
function detailsHtml(d) {
const meta = d.meta || {};
const rows = [];
const title = meta.title || meta.model;
if (title != null) rows.push(`<div>${T('skoda.details.model')}: ${esc(title)}</div>`);
if (meta.modelYear != null) rows.push(`<div>${T('skoda.details.year')}: ${esc(meta.modelYear)}</div>`);
if (meta.manufacturingDate != null) rows.push(`<div>${T('skoda.details.made')}: ${esc(meta.manufacturingDate)}</div>`);
if (meta.body != null) rows.push(`<div>${T('skoda.details.body')}: ${esc(meta.body)}</div>`);
if (meta.trimLevel != null) rows.push(`<div>${T('skoda.details.trim')}: ${esc(meta.trimLevel)}</div>`);
if (meta.powerKw != null) rows.push(`<div>${T('skoda.details.power')}: ${esc(meta.powerKw)} kW</div>`);
if (meta.batteryKwh != null) rows.push(`<div>${T('skoda.details.battery')}: ${esc(meta.batteryKwh)} kWh</div>`);
if (meta.maxChargingKw != null) rows.push(`<div>${T('skoda.details.max_charging')}: ${esc(meta.maxChargingKw)} kW</div>`);
let html = rows.length ? `<div class="skoda-details-meta">${rows.join('')}</div>` : '';

const equipment = Array.isArray(d.equipment) ? d.equipment : [];
if (equipment.length) {
html += `<div class="skoda-details-equipment"><strong>${T('skoda.details.equipment')}</strong> `
+ equipment.map((e) => `<span class="skoda-chip">${esc(e)}</span>`).join('') + `</div>`;
}

const conn = d.connection;
if (conn) {
const parts = [];
if (conn.online != null) parts.push(conn.online ? T('skoda.details.online') : T('skoda.details.offline'));
if (conn.ignitionOn != null) parts.push(conn.ignitionOn ? T('skoda.details.ignition_on') : T('skoda.details.ignition_off'));
if (conn.inMotion) parts.push(T('skoda.details.in_motion'));
if (parts.length) html += `<div class="skoda-details-connection"><strong>${T('skoda.details.connection')}</strong>: ${esc(parts.join(', '))}</div>`;
}

const score = d.drivingScore;
if (score) {
const parts = [];
if (score.weekly != null) parts.push(`${T('skoda.details.score_weekly')}: ${esc(score.weekly)}`);
if (score.monthly != null) parts.push(`${T('skoda.details.score_monthly')}: ${esc(score.monthly)}`);
if (score.lastCalculationDate != null) parts.push(`${T('skoda.details.score_as_of')}: ${esc(score.lastCalculationDate)}`);
if (parts.length) html += `<div class="skoda-details-score"><strong>${T('skoda.details.score')}</strong>: ${parts.join(' · ')}</div>`;
}
return html;
}

async function loadDetails(vehId, container) {
if (skodaEnrich[vehId] != null) { container.innerHTML = skodaEnrich[vehId]; return; }
if (skodaPending.has(vehId)) return; // a fetch is already in flight for this vehicle
skodaPending.add(vehId);
try {
const res = await fetch(`/api/v1/skoda/vehicles/${vehId}/details`, { headers });
const json = await res.json().catch(() => ({}));
if (res.ok && json.ok) {
const html = detailsHtml(json.details || {});
skodaEnrich[vehId] = html; // cache success only
container.innerHTML = html;
} else {
// Error (esp. 429) is transient — show it but do NOT cache, so reopening retries.
container.innerHTML = `<p class="skoda-details-error">${esc(res.status === 429 ? T('skoda.details.rate_limited') : T('skoda.details.load_error'))}</p>`;
}
} catch (e) {
container.innerHTML = `<p class="skoda-details-error">${esc(T('skoda.details.load_error'))}</p>`;
} finally {
skodaPending.delete(vehId);
}
}

async function command(vehicleId, action, args, el) {
if (action === 'unlock' && !confirm(T('skoda.cmd.confirm_unlock'))) return;
if (el && el.disabled) return; // already in flight → no command storm
Expand All @@ -97,10 +165,22 @@
}

async function load() {
const vehiclesEl = el('skoda-vehicles');
// Preserve open <details> across the rebuild (position-keyed, same order as
// current.vehicles) and reinject any already-fetched enrichment so an
// in-flight refresh never blanks a block the user has open.
const wasOpen = [...vehiclesEl.querySelectorAll('.skoda-details-block')].map((d) => d.open);
current = await api('GET', '');
el('skoda-poll-interval').value = current.poll_interval_min;
el('skoda-accounts').innerHTML = current.accounts.map(accountRow).join('') || '';
el('skoda-vehicles').innerHTML = current.vehicles.map(vehicleCard).join('') || `<p>${T('skoda.vehicles.empty')}</p>`;
vehiclesEl.innerHTML = current.vehicles.map(vehicleCard).join('') || `<p>${T('skoda.vehicles.empty')}</p>`;
vehiclesEl.querySelectorAll('.skoda-details-block').forEach((d, i) => {
if (!wasOpen[i]) return;
d.open = true;
const box = d.querySelector('.skoda-enrich');
const vehId = Number(box.dataset.veh);
if (skodaEnrich[vehId] != null) box.innerHTML = skodaEnrich[vehId];
});
}

function fail(e) { alert(e.code === 'SKODA_REFRESH_COOLDOWN' ? T('skoda.error.cooldown') : (e.message || T('skoda.error.generic'))); }
Expand Down Expand Up @@ -162,6 +242,15 @@
} catch (e) { fail(e); }
});

// 'toggle' does not bubble, so bind on the capture phase to catch it from any
// <details class="skoda-details-block"> inside the container.
el('skoda-vehicles').addEventListener('toggle', (ev) => {
const d = ev.target;
if (!d.classList || !d.classList.contains('skoda-details-block') || !d.open) return;
const box = d.querySelector('.skoda-enrich');
loadDetails(Number(box.dataset.veh), box);
}, true);

el('skoda-vehicles').addEventListener('click', (ev) => {
const b = ev.target.closest('button[data-cmd]'); if (!b) return;
const box = b.closest('.skoda-cmds'); const veh = Number(box.dataset.veh);
Expand Down
44 changes: 44 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2241,6 +2241,28 @@
"skoda.cmd.failed": "Befehl fehlgeschlagen",
"skoda.cmd.spin": "S-PIN",
"skoda.cmd.spin_set": "S-PIN speichern",
"skoda.details.title": "Details",
"skoda.details.model": "Modell",
"skoda.details.year": "Baujahr",
"skoda.details.made": "Baudatum",
"skoda.details.body": "Karosserie",
"skoda.details.trim": "Ausstattungslinie",
"skoda.details.power": "Leistung",
"skoda.details.battery": "Batterie",
"skoda.details.max_charging": "Max. Ladeleistung",
"skoda.details.equipment": "Ausstattung",
"skoda.details.connection": "Verbindung",
"skoda.details.online": "Online",
"skoda.details.offline": "Offline",
"skoda.details.ignition_on": "Zündung an",
"skoda.details.ignition_off": "Zündung aus",
"skoda.details.in_motion": "In Fahrt",
"skoda.details.score": "Fahrstil-Score",
"skoda.details.score_weekly": "Woche",
"skoda.details.score_monthly": "Monat",
"skoda.details.score_as_of": "Stand",
"skoda.details.load_error": "Details derzeit nicht verfügbar",
"skoda.details.rate_limited": "Cloud aktuell ausgelastet",
"portal.midea.fan": "Lüfter",
"portal.midea.fan_auto": "Auto",
"portal.midea.fan_silent": "Silent",
Expand Down Expand Up @@ -2288,6 +2310,28 @@
"portal.skoda.cmd_confirm_unlock": "Fahrzeug wirklich entriegeln?",
"portal.skoda.cmd_running": "läuft…",
"portal.skoda.cmd_failed": "Befehl fehlgeschlagen",
"portal.skoda.details.title": "Details",
"portal.skoda.details.model": "Modell",
"portal.skoda.details.year": "Baujahr",
"portal.skoda.details.made": "Baudatum",
"portal.skoda.details.body": "Karosserie",
"portal.skoda.details.trim": "Ausstattungslinie",
"portal.skoda.details.power": "Leistung",
"portal.skoda.details.battery": "Batterie",
"portal.skoda.details.max_charging": "Max. Ladeleistung",
"portal.skoda.details.equipment": "Ausstattung",
"portal.skoda.details.connection": "Verbindung",
"portal.skoda.details.online": "Online",
"portal.skoda.details.offline": "Offline",
"portal.skoda.details.ignition_on": "Zündung an",
"portal.skoda.details.ignition_off": "Zündung aus",
"portal.skoda.details.in_motion": "In Fahrt",
"portal.skoda.details.score": "Fahrstil-Score",
"portal.skoda.details.score_weekly": "Woche",
"portal.skoda.details.score_monthly": "Monat",
"portal.skoda.details.score_as_of": "Stand",
"portal.skoda.details.load_error": "Details derzeit nicht verfügbar",
"portal.skoda.details.rate_limited": "Cloud aktuell ausgelastet",
"portal.smarthome.title": "Smart Home",
"portal.smarthome.power": "Ein/Aus",
"portal.smarthome.brightness": "Helligkeit",
Expand Down
Loading
Loading