'
diff --git a/public/js/skoda.js b/public/js/skoda.js
index a46f39af..bcce96ce 100644
--- a/public/js/skoda.js
+++ b/public/js/skoda.js
@@ -64,7 +64,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')}
diff --git a/src/services/skoda/skodaClient.js b/src/services/skoda/skodaClient.js
index 4262cee7..34971fcf 100644
--- a/src/services/skoda/skodaClient.js
+++ b/src/services/skoda/skodaClient.js
@@ -93,12 +93,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/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_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 () => {
From 12d9777af403bcdb54a521547a2a5c0ea847d1cb Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Thu, 23 Jul 2026 11:23:15 +0200
Subject: [PATCH 2/5] feat(skoda): read-only details aggregator (meta,
equipment, connection, driving score)
---
src/services/skoda/skodaClient.js | 8 ++
src/services/skoda/skodaDetails.js | 132 +++++++++++++++++++++++++++++
tests/skoda_client_details.test.js | 33 ++++++++
tests/skoda_details.test.js | 88 +++++++++++++++++++
4 files changed, 261 insertions(+)
create mode 100644 src/services/skoda/skodaDetails.js
create mode 100644 tests/skoda_client_details.test.js
create mode 100644 tests/skoda_details.test.js
diff --git a/src/services/skoda/skodaClient.js b/src/services/skoda/skodaClient.js
index 34971fcf..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).
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/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_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
+});
From 2d61ba0c9ec01854ba647f9805d95a070f2e1045 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Thu, 23 Jul 2026 11:24:08 +0200
Subject: [PATCH 3/5] feat(skoda): admin + portal details routes (owner-gated,
masked VIN)
---
src/routes/api/portal.js | 19 +++++++++++++++++++
src/routes/api/skoda.js | 7 +++++++
2 files changed, 26 insertions(+)
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.
From a6980c2bd0ebd8e3990ae24cac870a4fadcf0887 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Thu, 23 Jul 2026 11:38:52 +0200
Subject: [PATCH 4/5] feat(skoda): read-only details block in admin + portal UI
(lazy, i18n, escaped)
---
public/css/portal.css | 3 +
public/css/skoda.css | 6 ++
public/js/portal.js | 82 ++++++++++++++++++++++++-
public/js/skoda.js | 91 +++++++++++++++++++++++++++-
src/i18n/de.json | 44 ++++++++++++++
src/i18n/en.json | 44 ++++++++++++++
templates/aurora/layout.njk | 24 +++++++-
templates/default/layout.njk | 24 +++++++-
templates/portal/portal.njk | 22 +++++++
templates/pro/layout.njk | 24 +++++++-
tests/skoda_i18n.test.js | 7 +++
tests/skoda_portal_widget_ui.test.js | 16 ++++-
12 files changed, 381 insertions(+), 6 deletions(-)
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 40e07e15..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,6 +1043,7 @@
+ (mt.dueInKm != null ? ' · ' + numOr(mt.dueInKm, ' km') : '') + '