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 ? '
' + '' + '' - + '' + + '' + '' + '' + '' @@ -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 @@
- + @@ -74,9 +76,75 @@
+
${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) {