diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9251c402..b5d64d9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/public/css/portal.css b/public/css/portal.css
index 414f1fdd..de971ac4 100644
--- a/public/css/portal.css
+++ b/public/css/portal.css
@@ -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
diff --git a/public/css/skoda.css b/public/css/skoda.css
index fa1b686e..4ac44c05 100644
--- a/public/css/skoda.css
+++ b/public/css/skoda.css
@@ -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; }
diff --git a/public/js/portal.js b/public/js/portal.js
index a802258d..1354f98b 100644
--- a/public/js/portal.js
+++ b/public/js/portal.js
@@ -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;
@@ -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('
' + escHtml(PT.skodaDetailsModel) + ': ' + escHtml(title) + '
');
+ if (meta.modelYear != null) rows.push('' + escHtml(PT.skodaDetailsYear) + ': ' + escHtml(meta.modelYear) + '
');
+ if (meta.manufacturingDate != null) rows.push('' + escHtml(PT.skodaDetailsMade) + ': ' + escHtml(meta.manufacturingDate) + '
');
+ if (meta.body != null) rows.push('' + escHtml(PT.skodaDetailsBody) + ': ' + escHtml(meta.body) + '
');
+ if (meta.trimLevel != null) rows.push('' + escHtml(PT.skodaDetailsTrim) + ': ' + escHtml(meta.trimLevel) + '
');
+ if (meta.powerKw != null) rows.push('' + escHtml(PT.skodaDetailsPower) + ': ' + numOr(meta.powerKw, ' kW') + '
');
+ if (meta.batteryKwh != null) rows.push('' + escHtml(PT.skodaDetailsBattery) + ': ' + numOr(meta.batteryKwh, ' kWh') + '
');
+ if (meta.maxChargingKw != null) rows.push('' + escHtml(PT.skodaDetailsMaxCharging) + ': ' + numOr(meta.maxChargingKw, ' kW') + '
');
+ if (meta.vin) rows.push('' + escHtml(meta.vin) + '
');
+ var html = rows.length ? '' + rows.join('') + '
' : '';
+
+ var equipment = Array.isArray(d.equipment) ? d.equipment : [];
+ if (equipment.length) {
+ html += '' + escHtml(PT.skodaDetailsEquipment) + ' '
+ + equipment.map(function (e) { return '' + escHtml(e) + ' '; }).join('') + '
';
+ }
+
+ 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 += '' + escHtml(PT.skodaDetailsConnection) + ' : ' + escHtml(parts.join(', ')) + '
';
+ }
+
+ 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 += '' + escHtml(PT.skodaDetailsScore) + ' : ' + sparts.join(' · ') + '
';
+ }
+ 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 = '' + escHtml(res.status === 429 ? PT.skodaDetailsRateLimited : PT.skodaDetailsLoadError) + '
';
+ })
+ .catch(function () {
+ container.innerHTML = '' + escHtml(PT.skodaDetailsLoadError) + '
';
+ })
+ .finally(function () { delete skodaPending[vehId]; });
+ }
+
var _skodaCmdBound = false;
function bindSkodaCommands() {
if (_skodaCmdBound) return; _skodaCmdBound = true;
@@ -977,11 +1043,12 @@
+ (mt.dueInKm != null ? ' · ' + numOr(mt.dueInKm, ' km') : '') + ''
+ (mt.partner ? '' + escHtml(PT.skodaPartner) + ': ' + escHtml(mt.partner) + '
' : '')
+ (hl.warnings && hl.warnings.length ? '' + escHtml(PT.skodaWarnings) + ': ' + escHtml(hl.warnings.join(', ')) + '
' : '')
+ + '
'
+ ''
+ (loggedIn ? ''
+ '
' + escHtml(PT.skodaCmdAcOn) + ' '
+ '
' + escHtml(PT.skodaCmdAcOff) + ' '
- + '
' + escHtml(PT.skodaCmdSetTemp) + ' '
+ + '
' + escHtml(PT.skodaCmdSetTemp) + ' '
+ '
' + escHtml(PT.skodaCmdSetTemp) + ' '
+ '
' + escHtml(PT.skodaCmdChargeOn) + ' '
+ '
' + escHtml(PT.skodaCmdChargeOff) + ' '
@@ -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
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];
+ }
}
}
diff --git a/public/js/skoda.js b/public/js/skoda.js
index a46f39af..fd8060dc 100644
--- a/public/js/skoda.js
+++ b/public/js/skoda.js
@@ -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}`;
@@ -64,7 +66,7 @@
${T('skoda.cmd.ac_on')}
${T('skoda.cmd.ac_off')}
- ${T('skoda.cmd.set_temp')}
+ ${T('skoda.cmd.set_temp')}
${T('skoda.cmd.set_temp')}
${T('skoda.cmd.charge_on')}
${T('skoda.cmd.charge_off')}
@@ -74,9 +76,75 @@
${T('skoda.cmd.unlock')}
${T('skoda.cmd.set_limit')} 50 60 70 80 90 100
+ ${T('skoda.details.title')}
`;
}
+ // Read-only enrichment (meta/equipment/connection/drivingScore) — fetched once
+ // per vehicle on first 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(`${T('skoda.details.model')}: ${esc(title)}
`);
+ if (meta.modelYear != null) rows.push(`${T('skoda.details.year')}: ${esc(meta.modelYear)}
`);
+ if (meta.manufacturingDate != null) rows.push(`${T('skoda.details.made')}: ${esc(meta.manufacturingDate)}
`);
+ if (meta.body != null) rows.push(`${T('skoda.details.body')}: ${esc(meta.body)}
`);
+ if (meta.trimLevel != null) rows.push(`${T('skoda.details.trim')}: ${esc(meta.trimLevel)}
`);
+ if (meta.powerKw != null) rows.push(`${T('skoda.details.power')}: ${esc(meta.powerKw)} kW
`);
+ if (meta.batteryKwh != null) rows.push(`${T('skoda.details.battery')}: ${esc(meta.batteryKwh)} kWh
`);
+ if (meta.maxChargingKw != null) rows.push(`${T('skoda.details.max_charging')}: ${esc(meta.maxChargingKw)} kW
`);
+ let html = rows.length ? `${rows.join('')}
` : '';
+
+ const equipment = Array.isArray(d.equipment) ? d.equipment : [];
+ if (equipment.length) {
+ html += `${T('skoda.details.equipment')} `
+ + equipment.map((e) => `${esc(e)} `).join('') + `
`;
+ }
+
+ 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 += `${T('skoda.details.connection')} : ${esc(parts.join(', '))}
`;
+ }
+
+ 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 += `${T('skoda.details.score')} : ${parts.join(' · ')}
`;
+ }
+ 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 = `${esc(res.status === 429 ? T('skoda.details.rate_limited') : T('skoda.details.load_error'))}
`;
+ }
+ } catch (e) {
+ container.innerHTML = `${esc(T('skoda.details.load_error'))}
`;
+ } 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
@@ -97,10 +165,22 @@
}
async function load() {
+ const vehiclesEl = el('skoda-vehicles');
+ // Preserve open 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('') || `${T('skoda.vehicles.empty')}
`;
+ vehiclesEl.innerHTML = current.vehicles.map(vehicleCard).join('') || `${T('skoda.vehicles.empty')}
`;
+ 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'))); }
@@ -162,6 +242,15 @@
} catch (e) { fail(e); }
});
+ // 'toggle' does not bubble, so bind on the capture phase to catch it from any
+ // 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);
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 05fdf0ce..1aec797f 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -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",
@@ -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",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index a9e43f77..a4ec066c 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -2297,6 +2297,28 @@
"skoda.cmd.failed": "Command failed",
"skoda.cmd.spin": "S-PIN",
"skoda.cmd.spin_set": "Save S-PIN",
+ "skoda.details.title": "Details",
+ "skoda.details.model": "Model",
+ "skoda.details.year": "Model year",
+ "skoda.details.made": "Manufactured",
+ "skoda.details.body": "Body",
+ "skoda.details.trim": "Trim level",
+ "skoda.details.power": "Power",
+ "skoda.details.battery": "Battery",
+ "skoda.details.max_charging": "Max. charging power",
+ "skoda.details.equipment": "Equipment",
+ "skoda.details.connection": "Connection",
+ "skoda.details.online": "Online",
+ "skoda.details.offline": "Offline",
+ "skoda.details.ignition_on": "Ignition on",
+ "skoda.details.ignition_off": "Ignition off",
+ "skoda.details.in_motion": "Moving",
+ "skoda.details.score": "Driving score",
+ "skoda.details.score_weekly": "Week",
+ "skoda.details.score_monthly": "Month",
+ "skoda.details.score_as_of": "As of",
+ "skoda.details.load_error": "Details currently unavailable",
+ "skoda.details.rate_limited": "Cloud is currently busy",
"portal.midea.fan": "Fan",
"portal.midea.fan_auto": "Auto",
"portal.midea.fan_silent": "Silent",
@@ -2344,6 +2366,28 @@
"portal.skoda.cmd_confirm_unlock": "Really unlock the vehicle?",
"portal.skoda.cmd_running": "running…",
"portal.skoda.cmd_failed": "Command failed",
+ "portal.skoda.details.title": "Details",
+ "portal.skoda.details.model": "Model",
+ "portal.skoda.details.year": "Model year",
+ "portal.skoda.details.made": "Manufactured",
+ "portal.skoda.details.body": "Body",
+ "portal.skoda.details.trim": "Trim level",
+ "portal.skoda.details.power": "Power",
+ "portal.skoda.details.battery": "Battery",
+ "portal.skoda.details.max_charging": "Max. charging power",
+ "portal.skoda.details.equipment": "Equipment",
+ "portal.skoda.details.connection": "Connection",
+ "portal.skoda.details.online": "Online",
+ "portal.skoda.details.offline": "Offline",
+ "portal.skoda.details.ignition_on": "Ignition on",
+ "portal.skoda.details.ignition_off": "Ignition off",
+ "portal.skoda.details.in_motion": "Moving",
+ "portal.skoda.details.score": "Driving score",
+ "portal.skoda.details.score_weekly": "Week",
+ "portal.skoda.details.score_monthly": "Month",
+ "portal.skoda.details.score_as_of": "As of",
+ "portal.skoda.details.load_error": "Details currently unavailable",
+ "portal.skoda.details.rate_limited": "Cloud is currently busy",
"portal.smarthome.title": "Smart Home",
"portal.smarthome.power": "Power",
"portal.smarthome.brightness": "Brightness",
diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js
index 1bd36722..8917dce6 100644
--- a/src/routes/api/portal.js
+++ b/src/routes/api/portal.js
@@ -19,6 +19,7 @@ const skodaOwners = require('../../services/skoda/skodaOwners');
const skodaVehicles = require('../../services/skoda/skodaVehicles');
const skodaPortal = require('../../services/skoda/skodaPortal');
const skodaControl = require('../../services/skoda/skodaControl');
+const skodaDetails = require('../../services/skoda/skodaDetails');
const router = Router();
@@ -344,6 +345,24 @@ router.get('/skoda/vehicles/:id/image', (req, res) => {
}
});
+// GET /skoda/vehicles/:id/details — owner-gated, redacted (masked VIN), lazy.
+const SKODA_DETAILS_STATUS = { SKODA_NO_SESSION: 409, SKODA_RATE_LIMITED: 429, SKODA_VEHICLE_NOT_FOUND: 404 };
+router.get('/skoda/vehicles/:id/details', async (req, res) => {
+ try {
+ if (!portalConfig().widgets.skoda) return res.status(404).json({ ok: false });
+ if (skodaUnavailable()) return res.json({ ok: true, data: null, reason: 'unavailable' });
+ const id = Number(req.params.id);
+ if (req.portalOwnerId == null || !skodaOwners.isOwner(id, req.portalOwnerId)) {
+ return res.status(403).json({ ok: false, error: 'SKODA_NOT_OWNER' });
+ }
+ const d = await skodaDetails.getDetails(id, { forAdmin: false });
+ res.json({ ok: true, data: d });
+ } catch (err) {
+ const status = SKODA_DETAILS_STATUS[err.code] || 502;
+ res.status(status).json({ ok: false, error: err.code || 'details failed' }); // kein Secret, nur Code
+ }
+});
+
// POST /skoda/vehicles/:id/command — control, login-required + owner-gated.
const SKODA_CMD_STATUS = { SKODA_UNKNOWN_COMMAND: 400, SKODA_VALIDATION: 400, SKODA_SPIN_REQUIRED: 409, SKODA_NO_SESSION: 409, SKODA_COMMAND_RATE_LIMIT: 429, SKODA_RATE_LIMITED: 429, SKODA_VEHICLE_NOT_FOUND: 404 };
router.post('/skoda/vehicles/:id/command', async (req, res) => {
diff --git a/src/routes/api/skoda.js b/src/routes/api/skoda.js
index fde1b975..bcb4d99e 100644
--- a/src/routes/api/skoda.js
+++ b/src/routes/api/skoda.js
@@ -8,6 +8,7 @@ const accounts = require('../../services/skoda/skodaAccounts');
const owners = require('../../services/skoda/skodaOwners');
const settings = require('../../services/settings');
const control = require('../../services/skoda/skodaControl');
+const details = require('../../services/skoda/skodaDetails');
const router = Router();
@@ -47,6 +48,12 @@ router.get('/', wrap(async (req, res) => {
res.json({ ok: true, ...skoda.getStatus(), poll_interval_min: skoda.pollIntervalMs() / 60000 });
}));
+// Lazy read-only enrichment (loaded when the card's details block is opened).
+router.get('/vehicles/:id/details', wrap(async (req, res) => {
+ const d = await details.getDetails(Number(req.params.id), { forAdmin: true });
+ res.json({ ok: true, details: d });
+}));
+
router.post('/accounts', wrap(async (req, res) => {
// No implicit sync here: the UI calls POST /accounts/:id/sync afterwards.
// Keeps unit tests free of real network login attempts.
diff --git a/src/services/skoda/skodaClient.js b/src/services/skoda/skodaClient.js
index 4262cee7..b7f9a5f7 100644
--- a/src/services/skoda/skodaClient.js
+++ b/src/services/skoda/skodaClient.js
@@ -56,6 +56,14 @@ class SkodaClient {
health(vin) { return this._get(`/api/v1/vehicle-health-report/warning-lights/${vin}`); }
maintenance(vin) { return this._get(`/api/v3/vehicle-maintenance/vehicles/${vin}`); }
+ // TP4a read-only enrichment. Live-verified against both real cars (2026-07-23).
+ // NOTE: software-version/update-status, charging/history and trip-statistics
+ // return 500/403 for these vehicles — deliberately not wired up.
+ vehicleInformation(vin) { return this._get(`/api/v1/vehicle-information/${vin}`); }
+ equipment(vin) { return this._get(`/api/v1/vehicle-information/${vin}/equipment`); }
+ connectionStatus(vin) { return this._get(`/api/v2/connection-status/${vin}/readiness`); }
+ drivingScore(vin) { return this._get(`/api/v2/vehicle-status/${vin}/driving-score`); }
+
async renderImage(url) {
// The url comes from the Skoda API response — never fetch it unvalidated,
// and never send our bearer token to an arbitrary host (SSRF/token leak).
@@ -93,12 +101,12 @@ class SkodaClient {
startAc(vin, temp) {
return this._request('POST', `/api/v2/air-conditioning/${vin}/start`,
- { heaterSource: 'ELECTRIC', targetTemperature: { temperatureValue: Math.round(temp), unitInCar: 'CELSIUS' } });
+ { heaterSource: 'ELECTRIC', targetTemperature: { temperatureValue: Math.round(temp * 2) / 2, unitInCar: 'CELSIUS' } });
}
stopAc(vin) { return this._request('POST', `/api/v2/air-conditioning/${vin}/stop`); }
setAcTemp(vin, temp) {
return this._request('POST', `/api/v2/air-conditioning/${vin}/settings/target-temperature`,
- { temperatureValue: Math.round(temp), unitInCar: 'CELSIUS' });
+ { temperatureValue: Math.round(temp * 2) / 2, unitInCar: 'CELSIUS' });
}
startWindowHeating(vin) { return this._request('POST', `/api/v2/air-conditioning/${vin}/start-window-heating`); }
stopWindowHeating(vin) { return this._request('POST', `/api/v2/air-conditioning/${vin}/stop-window-heating`); }
diff --git a/src/services/skoda/skodaControl.js b/src/services/skoda/skodaControl.js
index 17217837..61001b82 100644
--- a/src/services/skoda/skodaControl.js
+++ b/src/services/skoda/skodaControl.js
@@ -4,7 +4,7 @@ const accounts = require('./skodaAccounts');
const vehicles = require('./skodaVehicles');
const skoda = require('./index');
-const TEMP_MIN = 16, TEMP_MAX = 30;
+const TEMP_MIN = 15.5, TEMP_MAX = 30;
const CHARGE_STEPS = [50, 60, 70, 80, 90, 100];
const LOCK_LIMIT = 5, LOCK_WINDOW_MS = 15 * 60 * 1000;
diff --git a/src/services/skoda/skodaDetails.js b/src/services/skoda/skodaDetails.js
new file mode 100644
index 00000000..2f31e127
--- /dev/null
+++ b/src/services/skoda/skodaDetails.js
@@ -0,0 +1,132 @@
+'use strict';
+
+// On-demand, read-only enrichment aggregator for the Skoda vehicle cards.
+// Fetched lazily (on card expand), NOT part of the 15-min poller sync. Runs the
+// cloud calls under the per-account lock (no refresh-token race with the poller)
+// and dedupes concurrent expands so two tabs share one roundtrip.
+//
+// Live-verified field shapes (2026-07-23, both real cars). software/OTA,
+// charging history and trip statistics return 500/403 for these vehicles and are
+// deliberately NOT fetched here.
+
+const accounts = require('./skodaAccounts');
+const vehicles = require('./skodaVehicles');
+const skoda = require('./index');
+
+const TTL_MS = 5 * 60 * 1000;
+const cache = new Map(); // vehicleId -> { at, value?, errCode? }
+const inflight = new Map(); // vehicleId -> Promise
+
+function err(message, code) { const e = new Error(message); e.code = code; return e; }
+function maskVin(vin) { return vin && vin.length >= 4 ? '***' + vin.slice(-4) : null; }
+const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : (Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : null));
+
+// Single endpoint failure → null, but account-level errors abort the whole call
+// (same contract as skodaClient.fetchFullState).
+async function tryPart(job) {
+ try { return await job(); } catch (e) {
+ if (e.code === 'SKODA_RATE_LIMITED' || e.code === 'SKODA_UNAUTHORIZED') throw e;
+ return null;
+ }
+}
+
+function normMeta(info, vin, forAdmin) {
+ const spec = (info && info.vehicleSpecification) || null;
+ if (!spec) return { model: null, title: null, modelYear: null, manufacturingDate: null, body: null, trimLevel: null, powerKw: null, batteryKwh: null, maxChargingKw: null, vin: forAdmin ? (vin || null) : maskVin(vin) };
+ return {
+ model: spec.model || null,
+ title: spec.title || null,
+ modelYear: spec.modelYear || null,
+ manufacturingDate: spec.manufacturingDate || null,
+ body: spec.body || null,
+ trimLevel: spec.trimLevel || null,
+ powerKw: num(spec.engine && spec.engine.powerInKW),
+ batteryKwh: num(spec.battery && spec.battery.capacityInKWh),
+ maxChargingKw: num(spec.maxChargingPowerInKW),
+ vin: forAdmin ? (vin || null) : maskVin(vin),
+ };
+}
+
+function normEquipment(equip) {
+ const list = equip && Array.isArray(equip.equipment) ? equip.equipment : [];
+ return list.map((e) => e && e.name).filter(Boolean).map(String).slice(0, 40);
+}
+
+function normConnection(conn) {
+ if (!conn) return null;
+ return {
+ online: conn.unreachable != null ? !conn.unreachable : null,
+ ignitionOn: conn.ignitionOn != null ? !!conn.ignitionOn : null,
+ inMotion: conn.inMotion != null ? !!conn.inMotion : null,
+ };
+}
+
+function normScore(score) {
+ if (!score) return null;
+ const pick = (p) => (p && p.main != null ? num(p.main) : null);
+ const weekly = pick(score.weeklyScore), monthly = pick(score.monthlyScore);
+ if (weekly == null && monthly == null) return null;
+ return { weekly, monthly, lastCalculationDate: score.lastCalculationDate || null };
+}
+
+// Always fetch/cache the full ADMIN form (full VIN). Callers get redacted via serve().
+async function fetchDetails(vehicleId, fetchImpl) {
+ const accountId = vehicles.accountIdOf(vehicleId);
+ if (!accountId) throw err('vehicle not found', 'SKODA_VEHICLE_NOT_FOUND');
+ const row = vehicles.listRedacted().find((v) => v.id === vehicleId);
+ const vin = row && row.vin;
+ if (!vin) throw err('vehicle not found', 'SKODA_VEHICLE_NOT_FOUND');
+
+ const account = accounts.getAccountWithSecrets(accountId);
+ if (!account || !account.session || !account.session.accessToken) {
+ throw err('account has no active session — re-sync/re-login required', 'SKODA_NO_SESSION');
+ }
+
+ return skoda.withAccountLock(accountId, async () => {
+ const c = skoda.clientForAccount(accountId, fetchImpl);
+ const info = await tryPart(() => c.vehicleInformation(vin));
+ const equip = await tryPart(() => c.equipment(vin));
+ const conn = await tryPart(() => c.connectionStatus(vin));
+ const score = await tryPart(() => c.drivingScore(vin));
+ return {
+ meta: normMeta(info, vin, true),
+ equipment: normEquipment(equip),
+ connection: normConnection(conn),
+ drivingScore: normScore(score),
+ };
+ });
+}
+
+// Redact to portal form. Returns a CLONE — never a live cache reference — so a
+// downstream consumer can never mutate the cached admin entry.
+function redactForPortal(full) {
+ if (!full) return full;
+ const meta = full.meta ? { ...full.meta, vin: maskVin(full.meta.vin) } : null;
+ const equipment = Array.isArray(full.equipment) ? full.equipment.slice() : full.equipment;
+ return { ...full, meta, equipment };
+}
+
+function serve(full, forAdmin) { return forAdmin ? full : redactForPortal(full); }
+
+async function getDetails(vehicleId, { fetchImpl, forAdmin = false } = {}) {
+ const hit = cache.get(vehicleId);
+ if (hit && Date.now() - hit.at < TTL_MS) {
+ if (hit.errCode) throw err('rate limited', hit.errCode);
+ return serve(hit.value, forAdmin);
+ }
+ if (inflight.has(vehicleId)) return serve(await inflight.get(vehicleId), forAdmin);
+
+ const p = fetchDetails(vehicleId, fetchImpl)
+ .then((value) => { cache.set(vehicleId, { at: Date.now(), value }); return value; })
+ .catch((e) => {
+ if (e.code === 'SKODA_RATE_LIMITED') cache.set(vehicleId, { at: Date.now(), errCode: e.code });
+ throw e;
+ })
+ .finally(() => { inflight.delete(vehicleId); });
+ inflight.set(vehicleId, p);
+ return serve(await p, forAdmin);
+}
+
+function _resetForTest() { cache.clear(); inflight.clear(); }
+
+module.exports = { getDetails, _resetForTest };
diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk
index 1c6cb60d..98c40693 100644
--- a/templates/aurora/layout.njk
+++ b/templates/aurora/layout.njk
@@ -576,7 +576,29 @@
'skoda.cmd.running': {{ t('skoda.cmd.running') | dump | safe }},
'skoda.cmd.failed': {{ t('skoda.cmd.failed') | dump | safe }},
'skoda.cmd.spin': {{ t('skoda.cmd.spin') | dump | safe }},
- 'skoda.cmd.spin_set': {{ t('skoda.cmd.spin_set') | dump | safe }}
+ 'skoda.cmd.spin_set': {{ t('skoda.cmd.spin_set') | dump | safe }},
+ 'skoda.details.title': {{ t('skoda.details.title') | dump | safe }},
+ 'skoda.details.model': {{ t('skoda.details.model') | dump | safe }},
+ 'skoda.details.year': {{ t('skoda.details.year') | dump | safe }},
+ 'skoda.details.made': {{ t('skoda.details.made') | dump | safe }},
+ 'skoda.details.body': {{ t('skoda.details.body') | dump | safe }},
+ 'skoda.details.trim': {{ t('skoda.details.trim') | dump | safe }},
+ 'skoda.details.power': {{ t('skoda.details.power') | dump | safe }},
+ 'skoda.details.battery': {{ t('skoda.details.battery') | dump | safe }},
+ 'skoda.details.max_charging': {{ t('skoda.details.max_charging') | dump | safe }},
+ 'skoda.details.equipment': {{ t('skoda.details.equipment') | dump | safe }},
+ 'skoda.details.connection': {{ t('skoda.details.connection') | dump | safe }},
+ 'skoda.details.online': {{ t('skoda.details.online') | dump | safe }},
+ 'skoda.details.offline': {{ t('skoda.details.offline') | dump | safe }},
+ 'skoda.details.ignition_on': {{ t('skoda.details.ignition_on') | dump | safe }},
+ 'skoda.details.ignition_off': {{ t('skoda.details.ignition_off') | dump | safe }},
+ 'skoda.details.in_motion': {{ t('skoda.details.in_motion') | dump | safe }},
+ 'skoda.details.score': {{ t('skoda.details.score') | dump | safe }},
+ 'skoda.details.score_weekly': {{ t('skoda.details.score_weekly') | dump | safe }},
+ 'skoda.details.score_monthly': {{ t('skoda.details.score_monthly') | dump | safe }},
+ 'skoda.details.score_as_of': {{ t('skoda.details.score_as_of') | dump | safe }},
+ 'skoda.details.load_error': {{ t('skoda.details.load_error') | dump | safe }},
+ 'skoda.details.rate_limited': {{ t('skoda.details.rate_limited') | dump | safe }}
}
};
diff --git a/templates/default/layout.njk b/templates/default/layout.njk
index 558ee627..71bf00e5 100644
--- a/templates/default/layout.njk
+++ b/templates/default/layout.njk
@@ -569,7 +569,29 @@
'skoda.cmd.running': {{ t('skoda.cmd.running') | dump | safe }},
'skoda.cmd.failed': {{ t('skoda.cmd.failed') | dump | safe }},
'skoda.cmd.spin': {{ t('skoda.cmd.spin') | dump | safe }},
- 'skoda.cmd.spin_set': {{ t('skoda.cmd.spin_set') | dump | safe }}
+ 'skoda.cmd.spin_set': {{ t('skoda.cmd.spin_set') | dump | safe }},
+ 'skoda.details.title': {{ t('skoda.details.title') | dump | safe }},
+ 'skoda.details.model': {{ t('skoda.details.model') | dump | safe }},
+ 'skoda.details.year': {{ t('skoda.details.year') | dump | safe }},
+ 'skoda.details.made': {{ t('skoda.details.made') | dump | safe }},
+ 'skoda.details.body': {{ t('skoda.details.body') | dump | safe }},
+ 'skoda.details.trim': {{ t('skoda.details.trim') | dump | safe }},
+ 'skoda.details.power': {{ t('skoda.details.power') | dump | safe }},
+ 'skoda.details.battery': {{ t('skoda.details.battery') | dump | safe }},
+ 'skoda.details.max_charging': {{ t('skoda.details.max_charging') | dump | safe }},
+ 'skoda.details.equipment': {{ t('skoda.details.equipment') | dump | safe }},
+ 'skoda.details.connection': {{ t('skoda.details.connection') | dump | safe }},
+ 'skoda.details.online': {{ t('skoda.details.online') | dump | safe }},
+ 'skoda.details.offline': {{ t('skoda.details.offline') | dump | safe }},
+ 'skoda.details.ignition_on': {{ t('skoda.details.ignition_on') | dump | safe }},
+ 'skoda.details.ignition_off': {{ t('skoda.details.ignition_off') | dump | safe }},
+ 'skoda.details.in_motion': {{ t('skoda.details.in_motion') | dump | safe }},
+ 'skoda.details.score': {{ t('skoda.details.score') | dump | safe }},
+ 'skoda.details.score_weekly': {{ t('skoda.details.score_weekly') | dump | safe }},
+ 'skoda.details.score_monthly': {{ t('skoda.details.score_monthly') | dump | safe }},
+ 'skoda.details.score_as_of': {{ t('skoda.details.score_as_of') | dump | safe }},
+ 'skoda.details.load_error': {{ t('skoda.details.load_error') | dump | safe }},
+ 'skoda.details.rate_limited': {{ t('skoda.details.rate_limited') | dump | safe }}
}
};
diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk
index a39014cc..1b504ef6 100644
--- a/templates/portal/portal.njk
+++ b/templates/portal/portal.njk
@@ -98,6 +98,28 @@
skodaCmdConfirmUnlock: t('portal.skoda.cmd_confirm_unlock'),
skodaCmdRunning: t('portal.skoda.cmd_running'),
skodaCmdFailed: t('portal.skoda.cmd_failed'),
+ skodaDetailsTitle: t('portal.skoda.details.title'),
+ skodaDetailsModel: t('portal.skoda.details.model'),
+ skodaDetailsYear: t('portal.skoda.details.year'),
+ skodaDetailsMade: t('portal.skoda.details.made'),
+ skodaDetailsBody: t('portal.skoda.details.body'),
+ skodaDetailsTrim: t('portal.skoda.details.trim'),
+ skodaDetailsPower: t('portal.skoda.details.power'),
+ skodaDetailsBattery: t('portal.skoda.details.battery'),
+ skodaDetailsMaxCharging: t('portal.skoda.details.max_charging'),
+ skodaDetailsEquipment: t('portal.skoda.details.equipment'),
+ skodaDetailsConnection: t('portal.skoda.details.connection'),
+ skodaDetailsOnline: t('portal.skoda.details.online'),
+ skodaDetailsOffline: t('portal.skoda.details.offline'),
+ skodaDetailsIgnitionOn: t('portal.skoda.details.ignition_on'),
+ skodaDetailsIgnitionOff: t('portal.skoda.details.ignition_off'),
+ skodaDetailsInMotion: t('portal.skoda.details.in_motion'),
+ skodaDetailsScore: t('portal.skoda.details.score'),
+ skodaDetailsScoreWeekly: t('portal.skoda.details.score_weekly'),
+ skodaDetailsScoreMonthly: t('portal.skoda.details.score_monthly'),
+ skodaDetailsScoreAsOf: t('portal.skoda.details.score_as_of'),
+ skodaDetailsLoadError: t('portal.skoda.details.load_error'),
+ skodaDetailsRateLimited: t('portal.skoda.details.rate_limited'),
smarthomePower: t('portal.smarthome.power'),
smarthomeBrightness: t('portal.smarthome.brightness'),
smarthomeActivate: t('portal.smarthome.activate'),
diff --git a/templates/pro/layout.njk b/templates/pro/layout.njk
index 78cd616c..44b13acc 100644
--- a/templates/pro/layout.njk
+++ b/templates/pro/layout.njk
@@ -571,7 +571,29 @@
'skoda.cmd.running': {{ t('skoda.cmd.running') | dump | safe }},
'skoda.cmd.failed': {{ t('skoda.cmd.failed') | dump | safe }},
'skoda.cmd.spin': {{ t('skoda.cmd.spin') | dump | safe }},
- 'skoda.cmd.spin_set': {{ t('skoda.cmd.spin_set') | dump | safe }}
+ 'skoda.cmd.spin_set': {{ t('skoda.cmd.spin_set') | dump | safe }},
+ 'skoda.details.title': {{ t('skoda.details.title') | dump | safe }},
+ 'skoda.details.model': {{ t('skoda.details.model') | dump | safe }},
+ 'skoda.details.year': {{ t('skoda.details.year') | dump | safe }},
+ 'skoda.details.made': {{ t('skoda.details.made') | dump | safe }},
+ 'skoda.details.body': {{ t('skoda.details.body') | dump | safe }},
+ 'skoda.details.trim': {{ t('skoda.details.trim') | dump | safe }},
+ 'skoda.details.power': {{ t('skoda.details.power') | dump | safe }},
+ 'skoda.details.battery': {{ t('skoda.details.battery') | dump | safe }},
+ 'skoda.details.max_charging': {{ t('skoda.details.max_charging') | dump | safe }},
+ 'skoda.details.equipment': {{ t('skoda.details.equipment') | dump | safe }},
+ 'skoda.details.connection': {{ t('skoda.details.connection') | dump | safe }},
+ 'skoda.details.online': {{ t('skoda.details.online') | dump | safe }},
+ 'skoda.details.offline': {{ t('skoda.details.offline') | dump | safe }},
+ 'skoda.details.ignition_on': {{ t('skoda.details.ignition_on') | dump | safe }},
+ 'skoda.details.ignition_off': {{ t('skoda.details.ignition_off') | dump | safe }},
+ 'skoda.details.in_motion': {{ t('skoda.details.in_motion') | dump | safe }},
+ 'skoda.details.score': {{ t('skoda.details.score') | dump | safe }},
+ 'skoda.details.score_weekly': {{ t('skoda.details.score_weekly') | dump | safe }},
+ 'skoda.details.score_monthly': {{ t('skoda.details.score_monthly') | dump | safe }},
+ 'skoda.details.score_as_of': {{ t('skoda.details.score_as_of') | dump | safe }},
+ 'skoda.details.load_error': {{ t('skoda.details.load_error') | dump | safe }},
+ 'skoda.details.rate_limited': {{ t('skoda.details.rate_limited') | dump | safe }}
}
};
diff --git a/tests/skoda_client_control.test.js b/tests/skoda_client_control.test.js
index f72ad6e3..7d09eaba 100644
--- a/tests/skoda_client_control.test.js
+++ b/tests/skoda_client_control.test.js
@@ -27,10 +27,16 @@ test('startAc POSTs the correct path and body with bearer + content-type', async
assert.equal(c.ct, 'application/json');
const body = JSON.parse(c.body);
assert.equal(body.heaterSource, 'ELECTRIC');
- assert.equal(body.targetTemperature.temperatureValue, 21); // rounded
+ assert.equal(body.targetTemperature.temperatureValue, 21.5); // rounded to nearest 0.5
assert.equal(body.targetTemperature.unitInCar, 'CELSIUS');
});
+test('setAcTemp rounds to nearest 0.5 (16.3 -> 16.5)', async () => {
+ const { client, calls } = makeClient([['/settings/target-temperature', okRes()]]);
+ await client.setAcTemp('V', 16.3);
+ assert.equal(JSON.parse(calls[0].body).temperatureValue, 16.5);
+});
+
test('stopAc and window heating POST with no body', async () => {
const { client, calls } = makeClient([
['/air-conditioning/V/stop', okRes()],
diff --git a/tests/skoda_client_details.test.js b/tests/skoda_client_details.test.js
new file mode 100644
index 00000000..8657b177
--- /dev/null
+++ b/tests/skoda_client_details.test.js
@@ -0,0 +1,33 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { SkodaClient } = require('../src/services/skoda/skodaClient');
+const { API_BASE } = require('../src/services/skoda/skodaAuth');
+
+function okJson(body) { return { status: 200, ok: true, headers: new Headers(), json: async () => body, text: async () => '' }; }
+function makeClient(routes) {
+ const calls = [];
+ const fetchImpl = async (url, opts = {}) => {
+ calls.push({ url, method: opts.method });
+ // most-specific route first: caller orders `routes` accordingly
+ for (const [m, r] of routes) if (url.includes(m)) return typeof r === 'function' ? r(url, opts) : r;
+ throw new Error('unexpected ' + url);
+ };
+ return { client: new SkodaClient({ getSession: () => ({ accessToken: 'AT', refreshToken: 'RT' }), saveSession: () => {}, fetchImpl }), calls };
+}
+
+test('TP4a enrichment GET paths', async () => {
+ const { client, calls } = makeClient([
+ ['/vehicle-information/V/equipment', okJson({ equipment: [] })], // before the bare one
+ ['/vehicle-information/V', okJson({ vehicleSpecification: {} })],
+ ['/connection-status/V/readiness', okJson({ unreachable: false })],
+ ['/vehicle-status/V/driving-score', okJson({ weeklyScore: { main: 90 } })],
+ ]);
+ assert.deepEqual(await client.equipment('V'), { equipment: [] });
+ assert.deepEqual(await client.vehicleInformation('V'), { vehicleSpecification: {} });
+ assert.deepEqual(await client.connectionStatus('V'), { unreachable: false });
+ assert.deepEqual(await client.drivingScore('V'), { weeklyScore: { main: 90 } });
+ assert.equal(calls[1].url, API_BASE + '/api/v1/vehicle-information/V');
+ assert.equal(calls[2].url, API_BASE + '/api/v2/connection-status/V/readiness');
+ assert.equal(calls[3].url, API_BASE + '/api/v2/vehicle-status/V/driving-score');
+});
diff --git a/tests/skoda_control.test.js b/tests/skoda_control.test.js
index 57ec5647..4b7c88f5 100644
--- a/tests/skoda_control.test.js
+++ b/tests/skoda_control.test.js
@@ -72,7 +72,15 @@ test('ac_start reaches the cloud with rounded temp', async () => {
const r = await control.runCommand(vehId, 'ac_start', { temp: 21.6 }, { fetchImpl: apiFetch(spy) });
assert.equal(r.ok, true);
assert.match(spy[0].url, /\/air-conditioning\/VINCTL\/start$/);
- assert.equal(JSON.parse(spy[0].body).targetTemperature.temperatureValue, 22);
+ assert.equal(JSON.parse(spy[0].body).targetTemperature.temperatureValue, 21.5); // rounded to nearest 0.5
+});
+
+test('ac_temp accepts 15.5 and rejects 15.0 (widened range)', async () => {
+ const spy = [];
+ await assert.rejects(control.runCommand(vehId, 'ac_temp', { temp: 15.0 }, { fetchImpl: apiFetch(spy) }),
+ (e) => e.code === 'SKODA_VALIDATION');
+ const r = await control.runCommand(vehId, 'ac_temp', { temp: 15.5 }, { fetchImpl: apiFetch(spy) });
+ assert.equal(r.ok, true); // 15.5 is now in range
});
test('lock without a set S-PIN is rejected before any cloud call', async () => {
diff --git a/tests/skoda_details.test.js b/tests/skoda_details.test.js
new file mode 100644
index 00000000..1cd19cfd
--- /dev/null
+++ b/tests/skoda_details.test.js
@@ -0,0 +1,88 @@
+'use strict';
+const { test, before, after, beforeEach, mock } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+let details, accounts, skoda, getDb, vehId, accId;
+
+const VIN = 'TMBTESTVIN01234567';
+
+// Raw shapes taken verbatim from the 2026-07-23 live spike (both real cars).
+function apiFetch(spy = []) {
+ return async (url, opts = {}) => {
+ spy.push({ url });
+ const j = (body) => ({ status: 200, ok: true, headers: new Headers(), json: async () => body, text: async () => '' });
+ if (url.includes(`/vehicle-information/${VIN}/equipment`)) return j({ equipment: [{ name: 'Braking Assists' }, { name: 'Kessy Advanced' }, { name: null }] });
+ if (url.includes(`/vehicle-information/${VIN}`)) return j({ vehicleSpecification: { title: 'Škoda Elroq', model: 'Elroq', modelYear: '2026', manufacturingDate: '2025-10-04', body: 'SUV', trimLevel: '85', engine: { powerInKW: 210 }, battery: { capacityInKWh: 77 }, maxChargingPowerInKW: 125 } });
+ if (url.includes(`/connection-status/${VIN}/readiness`)) return j({ unreachable: false, ignitionOn: false, inMotion: false });
+ if (url.includes(`/vehicle-status/${VIN}/driving-score`)) return j({ weeklyScore: { main: 96 }, monthlyScore: { main: 93 }, lastCalculationDate: '2026-07-23' });
+ throw new Error('unexpected ' + url);
+ };
+}
+
+before(async () => {
+ await setup();
+ details = require('../src/services/skoda/skodaDetails');
+ accounts = require('../src/services/skoda/skodaAccounts');
+ skoda = require('../src/services/skoda');
+ ({ getDb } = require('../src/db/connection'));
+});
+after(async () => { skoda.stopPolling(); await teardown(); });
+beforeEach(() => {
+ details._resetForTest();
+ for (const a of accounts.listAccounts()) accounts.removeAccount(a.id);
+ const acc = accounts.createAccount({ email: 'd@x.y', password: 'pw' });
+ accounts.saveSession(acc.id, { accessToken: 'AT', refreshToken: 'RT' });
+ accId = acc.id;
+ getDb().prepare("INSERT INTO skoda_vehicles (account_id, vin, name, state_json, fetched_at) VALUES (?, ?, 'Elroq', '{}', datetime('now'))").run(acc.id, VIN);
+ vehId = getDb().prepare('SELECT id FROM skoda_vehicles WHERE vin = ?').get(VIN).id;
+});
+
+test('getDetails normalizes the live shapes (admin form, full vin)', async () => {
+ const d = await details.getDetails(vehId, { fetchImpl: apiFetch(), forAdmin: true });
+ assert.equal(d.meta.model, 'Elroq');
+ assert.equal(d.meta.modelYear, '2026');
+ assert.equal(d.meta.powerKw, 210);
+ assert.equal(d.meta.batteryKwh, 77);
+ assert.equal(d.meta.vin, VIN); // admin: full vin
+ assert.deepEqual(d.equipment, ['Braking Assists', 'Kessy Advanced']); // null name dropped
+ assert.deepEqual(d.connection, { online: true, ignitionOn: false, inMotion: false });
+ assert.deepEqual(d.drivingScore, { weekly: 96, monthly: 93, lastCalculationDate: '2026-07-23' });
+});
+
+test('no active session → SKODA_NO_SESSION, no cloud call', async () => {
+ accounts.saveSession(accId, null);
+ await assert.rejects(details.getDetails(vehId, { fetchImpl: () => { throw new Error('should not be called'); } }),
+ (e) => e.code === 'SKODA_NO_SESSION');
+});
+
+test('warm admin cache still masks vin for a following portal call (VIN-leak regression)', async () => {
+ await details.getDetails(vehId, { fetchImpl: apiFetch(), forAdmin: true }); // warms cache with full VIN
+ const portal = await details.getDetails(vehId, { fetchImpl: () => { throw new Error('no refetch'); }, forAdmin: false });
+ assert.match(portal.meta.vin, /^\*\*\*4567$/); // masked, served from cache
+});
+
+test('portal redaction returns a clone (mutating it does not poison the cache)', async () => {
+ const portal = await details.getDetails(vehId, { fetchImpl: apiFetch(), forAdmin: false });
+ portal.equipment.push('INJECTED'); // downstream mutation
+ const admin = await details.getDetails(vehId, { fetchImpl: () => { throw new Error('no refetch'); }, forAdmin: true });
+ assert.equal(admin.equipment.includes('INJECTED'), false);
+});
+
+test('concurrent expands share one cloud roundtrip (in-flight dedupe)', async () => {
+ const spy = [];
+ const f = apiFetch(spy);
+ await Promise.all([
+ details.getDetails(vehId, { fetchImpl: f, forAdmin: true }),
+ details.getDetails(vehId, { fetchImpl: f, forAdmin: false }),
+ ]);
+ assert.equal(spy.length, 4); // 4 endpoints once, not 8
+});
+
+test('SKODA_RATE_LIMITED aborts and is negative-cached', async () => {
+ const rl = async () => ({ status: 429, ok: false, headers: new Headers(), json: async () => ({}), text: async () => '' });
+ await assert.rejects(details.getDetails(vehId, { fetchImpl: rl, forAdmin: true }), (e) => e.code === 'SKODA_RATE_LIMITED');
+ await assert.rejects(details.getDetails(vehId, { fetchImpl: () => { throw new Error('no refetch'); }, forAdmin: true }),
+ (e) => e.code === 'SKODA_RATE_LIMITED'); // hits negative cache, no refetch
+});
diff --git a/tests/skoda_i18n.test.js b/tests/skoda_i18n.test.js
index 100c844f..d099527a 100644
--- a/tests/skoda_i18n.test.js
+++ b/tests/skoda_i18n.test.js
@@ -15,6 +15,13 @@ const KEYS = [
'skoda.vehicle.refresh', 'skoda.vehicle.owners', 'skoda.vehicle.fetched', 'skoda.vehicle.mileage',
'skoda.settings.poll_interval', 'skoda.owner.save', 'skoda.owner.title',
'nav.skoda', 'skoda.error.cooldown', 'skoda.error.generic',
+ 'skoda.details.title', 'skoda.details.model', 'skoda.details.year', 'skoda.details.made',
+ 'skoda.details.body', 'skoda.details.trim', 'skoda.details.power', 'skoda.details.battery',
+ 'skoda.details.max_charging', 'skoda.details.equipment', 'skoda.details.connection',
+ 'skoda.details.online', 'skoda.details.offline', 'skoda.details.ignition_on',
+ 'skoda.details.ignition_off', 'skoda.details.in_motion', 'skoda.details.score',
+ 'skoda.details.score_weekly', 'skoda.details.score_monthly', 'skoda.details.score_as_of',
+ 'skoda.details.load_error', 'skoda.details.rate_limited',
];
test('all skoda keys exist in de and en', () => {
diff --git a/tests/skoda_portal_widget_ui.test.js b/tests/skoda_portal_widget_ui.test.js
index f0f1a91d..f8562e75 100644
--- a/tests/skoda_portal_widget_ui.test.js
+++ b/tests/skoda_portal_widget_ui.test.js
@@ -15,13 +15,27 @@ const I18N_KEYS = [
'portal.skoda.doors', 'portal.skoda.windows', 'portal.skoda.cable_connected', 'portal.skoda.warnings',
'portal.skoda.bonnet', 'portal.skoda.trunk', 'portal.skoda.sunroof', 'portal.skoda.lights_on',
'portal.skoda.climate_on', 'portal.skoda.climate_off',
+ 'portal.skoda.details.title', 'portal.skoda.details.model', 'portal.skoda.details.year',
+ 'portal.skoda.details.made', 'portal.skoda.details.body', 'portal.skoda.details.trim',
+ 'portal.skoda.details.power', 'portal.skoda.details.battery', 'portal.skoda.details.max_charging',
+ 'portal.skoda.details.equipment', 'portal.skoda.details.connection', 'portal.skoda.details.online',
+ 'portal.skoda.details.offline', 'portal.skoda.details.ignition_on', 'portal.skoda.details.ignition_off',
+ 'portal.skoda.details.in_motion', 'portal.skoda.details.score', 'portal.skoda.details.score_weekly',
+ 'portal.skoda.details.score_monthly', 'portal.skoda.details.score_as_of',
+ 'portal.skoda.details.load_error', 'portal.skoda.details.rate_limited',
];
// PT_CAMEL must cover every client-used key so a forgotten PT entry fails the test.
const PT_CAMEL = ['skodaTitle', 'skodaSoc', 'skodaRange', 'skodaLocked', 'skodaUnlocked', 'skodaCharging',
'skodaClimate', 'skodaTargetTemp', 'skodaClimateRemaining', 'skodaWindowHeating', 'skodaMileage',
'skodaInspection', 'skodaPartner', 'skodaPosition', 'skodaAsOf', 'skodaWindows', 'skodaDoors',
'skodaCableConnected', 'skodaWarnings', 'skodaBonnet', 'skodaTrunk', 'skodaSunroof', 'skodaLightsOn',
- 'skodaClimateOn', 'skodaClimateOff'];
+ 'skodaClimateOn', 'skodaClimateOff',
+ 'skodaDetailsTitle', 'skodaDetailsModel', 'skodaDetailsYear', 'skodaDetailsMade', 'skodaDetailsBody',
+ 'skodaDetailsTrim', 'skodaDetailsPower', 'skodaDetailsBattery', 'skodaDetailsMaxCharging',
+ 'skodaDetailsEquipment', 'skodaDetailsConnection', 'skodaDetailsOnline', 'skodaDetailsOffline',
+ 'skodaDetailsIgnitionOn', 'skodaDetailsIgnitionOff', 'skodaDetailsInMotion', 'skodaDetailsScore',
+ 'skodaDetailsScoreWeekly', 'skodaDetailsScoreMonthly', 'skodaDetailsScoreAsOf',
+ 'skodaDetailsLoadError', 'skodaDetailsRateLimited'];
test('portal.skoda.* keys exist in de and en', () => {
for (const k of I18N_KEYS) {