From e4b1cdd125fc86b137d2dc5c99e9c40b49611249 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:40:10 +0200 Subject: [PATCH 01/11] feat(skoda): expose air-conditioning timers as climate.timers --- src/services/skoda/skodaClient.js | 11 +++++++++++ tests/skoda_api.test.js | 11 +++++++++++ tests/skoda_client.test.js | 25 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/src/services/skoda/skodaClient.js b/src/services/skoda/skodaClient.js index b7f9a5f7..cb9b7786 100644 --- a/src/services/skoda/skodaClient.js +++ b/src/services/skoda/skodaClient.js @@ -162,6 +162,17 @@ function normalizeVehicleState({ status, drivingRange, charging, airConditioning return Number.isFinite(ts) ? Math.max(0, Math.round((ts - Date.now()) / 60000)) : null; })(), windowHeating: windowHeating ? (ON(windowHeating.front) || ON(windowHeating.rear)) : null, + // Abfahrtstimer (MEB: Klima-Timer). Kommen bei jedem Sync im + // air-conditioning-Payload mit — kein eigener Call nötig. + timers: (Array.isArray(airConditioning && airConditioning.timers) ? airConditioning.timers : []) + .map((t) => ({ + id: num(t && t.id), + enabled: t && t.enabled != null ? !!t.enabled : null, + time: t && typeof t.time === 'string' ? t.time : null, + type: (t && t.type) || null, + days: Array.isArray(t && t.selectedDays) ? t.selectedDays.map(String) : [], + })) + .filter((t) => t.id != null), }, position: pos && pos.gpsCoordinates ? { lat: num(pos.gpsCoordinates.latitude), lon: num(pos.gpsCoordinates.longitude) } : null, diff --git a/tests/skoda_api.test.js b/tests/skoda_api.test.js index dc62c83e..e85f7c75 100644 --- a/tests/skoda_api.test.js +++ b/tests/skoda_api.test.js @@ -130,3 +130,14 @@ test('unauthenticated request is rejected', async () => { const res = await supertest(ctx.app).get('/api/v1/skoda'); assert.equal(res.status, 401); }); + +test('GET /api/v1/skoda exposes the departure timers of a vehicle', async () => { + const acc = accounts.createAccount({ email: 'timers@x.y', password: 'pw' }); + const state = JSON.stringify({ climate: { state: 'OFF', timers: [{ id: 1, enabled: true, time: '07:30', type: 'RECURRING', days: ['MONDAY'] }] } }); + getDb().prepare("INSERT INTO skoda_vehicles (account_id, vin, name, model, state_json, fetched_at) VALUES (?, 'TMBTIM', 'Enyaq', 'Enyaq', ?, datetime('now'))").run(acc.id, state); + + const res = await ctx.agent.get('/api/v1/skoda'); + assert.equal(res.status, 200); + const v = res.body.vehicles.find((x) => x.vin === 'TMBTIM'); + assert.deepEqual(v.state.climate.timers, [{ id: 1, enabled: true, time: '07:30', type: 'RECURRING', days: ['MONDAY'] }]); +}); diff --git a/tests/skoda_client.test.js b/tests/skoda_client.test.js index 61ce6c28..ea7f1fd6 100644 --- a/tests/skoda_client.test.js +++ b/tests/skoda_client.test.js @@ -143,3 +143,28 @@ test('fetchFullState survives one failing endpoint', async () => { assert.equal(state.soc, 74); // from drivingRange assert.equal(state.charging.state, null); // failed part -> nulls }); + +test('normalizeVehicleState maps air-conditioning timers to climate.timers', () => { + const state = normalizeVehicleState({ + status: null, drivingRange: null, charging: null, position: null, health: null, maintenance: null, + airConditioning: { + state: 'OFF', + timers: [ + { id: 1, enabled: true, time: '12:00', type: 'RECURRING', selectedDays: ['MONDAY', 'FRIDAY'] }, + { id: 2, enabled: false, time: '05:15', type: 'ONE_OFF', selectedDays: [] }, + { enabled: true, time: '09:00' }, + ], + }, + }); + assert.deepEqual(state.climate.timers, [ + { id: 1, enabled: true, time: '12:00', type: 'RECURRING', days: ['MONDAY', 'FRIDAY'] }, + { id: 2, enabled: false, time: '05:15', type: 'ONE_OFF', days: [] }, + ]); +}); + +test('climate.timers is an empty array when the cloud sends none', () => { + const noAc = normalizeVehicleState({ status: null, drivingRange: null, charging: null, airConditioning: null, position: null, health: null, maintenance: null }); + assert.deepEqual(noAc.climate.timers, []); + const noList = normalizeVehicleState({ status: null, drivingRange: null, charging: null, airConditioning: { state: 'OFF' }, position: null, health: null, maintenance: null }); + assert.deepEqual(noList.climate.timers, []); +}); From a5f556398cf672d034545378f22c809a4fb3b0bc Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:46:10 +0200 Subject: [PATCH 02/11] feat(skoda): pass departure timers through portal redaction, login-gated --- src/services/skoda/skodaPortal.js | 16 ++++++++++++--- tests/skoda_portal.test.js | 34 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/services/skoda/skodaPortal.js b/src/services/skoda/skodaPortal.js index fc836cf0..0082d3f5 100644 --- a/src/services/skoda/skodaPortal.js +++ b/src/services/skoda/skodaPortal.js @@ -34,11 +34,11 @@ async function portalVehiclesFor(ownerId, { fetchImpl, includePosition = true } ); } -async function redactState(state, fetchImpl, includePosition) { +async function redactState(state, fetchImpl, loggedIn) { if (!state) return null; let position = null; // GPS + home address only for a real login; device-trust reads never see it. - if (includePosition && state.position && typeof state.position.lat === 'number' && typeof state.position.lon === 'number') { + if (loggedIn && state.position && typeof state.position.lat === 'number' && typeof state.position.lon === 'number') { const address = await geocode.reverseGeocode(state.position.lat, state.position.lon, { fetchImpl }); position = { lat: state.position.lat, lon: state.position.lon, address }; } @@ -53,7 +53,17 @@ async function redactState(state, fetchImpl, includePosition) { lightsOn: state.lightsOn, soc: state.soc, rangeKm: state.rangeKm, charging: pick(state.charging, ['state', 'powerKw', 'remainingMin', 'targetPercent', 'mode', 'cableConnected']), - climate: pick(state.climate, ['state', 'targetC', 'remainingMin', 'windowHeating']), + // pick() ist eine Leaf-Allowlist und greift nicht in Arrays — die Timer + // werden deshalb einzeln durch dieselbe Allowlist geschickt, damit ein + // künftig von Skoda ergänztes Feld das Portal nicht ungeprüft erreicht. + // Abfahrtszeiten sind ein Anwesenheitsprofil: dieselbe Sensitivitätsklasse + // wie GPS, also nur bei echtem Login (routes/api/portal.js:318). + climate: { + ...pick(state.climate, ['state', 'targetC', 'remainingMin', 'windowHeating']), + timers: loggedIn && state.climate && Array.isArray(state.climate.timers) + ? state.climate.timers.map((t) => pick(t, ['id', 'enabled', 'time', 'type', 'days'])) + : [], + }, position, health: { mileageKm: h.mileageKm, warnings: Array.isArray(h.warnings) ? h.warnings : [] }, maintenance: pick(state.maintenance, ['dueInDays', 'dueInKm', 'partner']), diff --git a/tests/skoda_portal.test.js b/tests/skoda_portal.test.js index 1018227a..a2aca1a9 100644 --- a/tests/skoda_portal.test.js +++ b/tests/skoda_portal.test.js @@ -89,3 +89,37 @@ test('includePosition:false nulls position and skips geocoding', async () => { }); assert.equal(list[0].state.position, null); }); + +test('portal redaction passes timers through with only the five allowed fields', async () => { + const withTimers = JSON.parse(JSON.stringify(STATE)); + withTimers.climate.timers = [ + { id: 1, enabled: true, time: '12:00', type: 'RECURRING', days: ['MONDAY'], secretVin: 'TMBLEAK' }, + ]; + const id = seedVehicle('TMBTIMER', 'Elroq', withTimers); + owners.setOwners(id, [adminId]); + + const list = await portal.portalVehiclesFor(adminId, { includePosition: true }); + assert.deepEqual(list[0].state.climate.timers, [ + { id: 1, enabled: true, time: '12:00', type: 'RECURRING', days: ['MONDAY'] }, + ]); +}); + +test('portal redaction hides timers from a device-trust reader without a login', async () => { + const withTimers = JSON.parse(JSON.stringify(STATE)); + withTimers.climate.timers = [{ id: 1, enabled: true, time: '12:00', type: 'RECURRING', days: ['MONDAY'] }]; + const id = seedVehicle('TMBNOLOGIN', 'Elroq', withTimers); + owners.setOwners(id, [adminId]); + + const list = await portal.portalVehiclesFor(adminId, { includePosition: false }); + assert.deepEqual(list[0].state.climate.timers, []); +}); + +test('portal redaction yields an empty timer list when state has none', async () => { + const noTimers = JSON.parse(JSON.stringify(STATE)); + delete noTimers.climate.timers; + const id = seedVehicle('TMBNOTIMER', 'Enyaq', noTimers); + owners.setOwners(id, [adminId]); + + const list = await portal.portalVehiclesFor(adminId, { includePosition: true }); + assert.deepEqual(list[0].state.climate.timers, []); +}); From 44e56e254ae49c86bd19bcd75c2b5a1f65ac1fd7 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:50:12 +0200 Subject: [PATCH 03/11] feat(skoda): add timer_set command writing air-conditioning timers --- src/services/skoda/skodaClient.js | 5 +++ src/services/skoda/skodaControl.js | 30 +++++++++++++ tests/skoda_client_control.test.js | 11 +++++ tests/skoda_control.test.js | 71 ++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/src/services/skoda/skodaClient.js b/src/services/skoda/skodaClient.js index cb9b7786..35763b9a 100644 --- a/src/services/skoda/skodaClient.js +++ b/src/services/skoda/skodaClient.js @@ -115,6 +115,11 @@ class SkodaClient { setChargeLimit(vin, pct) { return this._request('PUT', `/api/v1/charging/${vin}/set-charge-limit`, { targetSOCInPercent: pct }); } lock(vin, spin) { return this._request('POST', `/api/v1/vehicle-access/${vin}/lock`, { currentSpin: spin }); } unlock(vin, spin) { return this._request('POST', `/api/v1/vehicle-access/${vin}/unlock`, { currentSpin: spin }); } + // Abfahrtstimer. Live-verifiziert: 202 auf beiden Fahrzeugen, und ein einzeln + // geschriebener Slot lässt die übrigen unangetastet (Spike 2026-07-24). + // Schreibform aus python-myskoda set_ac_timer. Der departure/timers-Endpunkt + // aus vehicle-automatization antwortet für diese Autos 500 — nicht benutzen. + setAcTimer(vin, timer) { return this._request('POST', `/api/v2/air-conditioning/${vin}/timers`, { timers: [timer] }); } } const YES = (v) => (v == null ? null : String(v).toUpperCase() === 'YES'); diff --git a/src/services/skoda/skodaControl.js b/src/services/skoda/skodaControl.js index 61001b82..b3722bfc 100644 --- a/src/services/skoda/skodaControl.js +++ b/src/services/skoda/skodaControl.js @@ -7,6 +7,8 @@ const skoda = require('./index'); 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; +const WEEKDAYS = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY']; +const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; function err(message, code) { const e = new Error(message); e.code = code; return e; } function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : NaN; } @@ -17,6 +19,33 @@ function reqTemp(a) { return { temp: t }; } +function reqTimer(a) { + const id = num(a && a.id); // num() ist typeof-basiert: "1" oder true fallen durch + if (!Number.isInteger(id) || id <= 0) throw err('timer id invalid', 'SKODA_VALIDATION'); + if (typeof (a && a.enabled) !== 'boolean') throw err('enabled must be a boolean', 'SKODA_VALIDATION'); + if (typeof (a && a.time) !== 'string' || !TIME_RE.test(a.time)) throw err('time must be HH:MM', 'SKODA_VALIDATION'); + if (!Array.isArray(a && a.days) || !a.days.length || a.days.length > WEEKDAYS.length) throw err('one to seven weekdays required', 'SKODA_VALIDATION'); + const days = [...new Set(a.days)]; + // Werte-Allowlist, kein Objektschlüssel — '__proto__' fällt hier durch. + if (days.some((d) => !WEEKDAYS.includes(d))) throw err('unknown weekday', 'SKODA_VALIDATION'); + days.sort((x, y) => WEEKDAYS.indexOf(x) - WEEKDAYS.indexOf(y)); + return { id, enabled: a.enabled, time: a.time, days }; +} + +// Frischer Lesevorgang statt state_json: `type` darf NIE aus dem Request kommen +// (sonst schaltet ein Client einen Timer auf ONE_OFF um), und ein bis zu 15 min +// alter state_json würde eine zwischenzeitliche Änderung in der Skoda-App +// stillschweigend zurücksetzen. Ein GET pro Speichervorgang ist der Preis. +async function setTimer(c, vin, a) { + const ac = await c.airConditioning(vin); + const found = (ac && Array.isArray(ac.timers) ? ac.timers : []).find((t) => t && t.id === a.id); + if (!found) throw err('timer slot not found', 'SKODA_TIMER_NOT_FOUND'); + // ponytail: ONE_OFF-Slots bleiben unangetastet — der Live-Spike hat nur + // RECURRING gesehen, das Verhalten von selectedDays bei ONE_OFF ist unbekannt. + if (found.type !== 'RECURRING') throw err('timer is not recurring', 'SKODA_TIMER_READONLY'); + return c.setAcTimer(vin, { id: a.id, enabled: a.enabled, time: a.time, type: found.type, selectedDays: a.days }); +} + const COMMANDS = { ac_start: { needsSpin: false, validate: reqTemp, run: (c, vin, a) => c.startAc(vin, a.temp) }, ac_stop: { needsSpin: false, validate: () => ({}), run: (c, vin) => c.stopAc(vin) }, @@ -28,6 +57,7 @@ const COMMANDS = { charge_limit: { needsSpin: false, validate: (a) => { const l = num(a && a.limit); if (!CHARGE_STEPS.includes(l)) throw err('limit not allowed', 'SKODA_VALIDATION'); return { limit: l }; }, run: (c, vin, a) => c.setChargeLimit(vin, a.limit) }, lock: { needsSpin: true, validate: () => ({}), run: (c, vin, a, spin) => c.lock(vin, spin) }, unlock: { needsSpin: true, validate: () => ({}), run: (c, vin, a, spin) => c.unlock(vin, spin) }, + timer_set: { needsSpin: false, validate: reqTimer, run: setTimer }, }; // ponytail: grows one entry per account ever touched — same trade-off as diff --git a/tests/skoda_client_control.test.js b/tests/skoda_client_control.test.js index 7d09eaba..5d5b2ad6 100644 --- a/tests/skoda_client_control.test.js +++ b/tests/skoda_client_control.test.js @@ -83,3 +83,14 @@ test('control 4xx maps to SKODA_API_ERROR with status', async () => { const { client } = makeClient([['/vehicle-access/V/lock', okRes(400)]]); await assert.rejects(client.lock('V', '0000'), (e) => e.code === 'SKODA_API_ERROR' && e.status === 400); }); + +test('setAcTimer POSTs the timer wrapped in a timers array', async () => { + const { client, calls } = makeClient([['/air-conditioning/V/timers', okRes(202)]]); + await client.setAcTimer('V', { id: 1, enabled: true, time: '07:30', type: 'RECURRING', selectedDays: ['MONDAY'] }); + const c = calls[0]; + assert.equal(c.method, 'POST'); + assert.equal(c.url, API_BASE + '/api/v2/air-conditioning/V/timers'); + assert.deepEqual(JSON.parse(c.body), { + timers: [{ id: 1, enabled: true, time: '07:30', type: 'RECURRING', selectedDays: ['MONDAY'] }], + }); +}); diff --git a/tests/skoda_control.test.js b/tests/skoda_control.test.js index 4b7c88f5..55796fc1 100644 --- a/tests/skoda_control.test.js +++ b/tests/skoda_control.test.js @@ -97,3 +97,74 @@ test('unlock uses the stored S-PIN and rate-limits after 5 attempts', async () = await assert.rejects(control.runCommand(vehId, 'unlock', {}, { fetchImpl: apiFetch(spy) }), (e) => e.code === 'SKODA_COMMAND_RATE_LIMIT'); assert.equal(spy.length, 5); // 6th blocked before cloud }); + +// GET air-conditioning liefert die Slots, POST .../timers quittiert mit 202. +function timerFetch(spy, { timers = [{ id: 1, enabled: false, time: '05:15', type: 'RECURRING', selectedDays: ['MONDAY'] }], getStatus = 200 } = {}) { + return async (url, opts = {}) => { + spy.push({ url, method: opts.method || 'GET', body: opts.body }); + if (!opts.method || opts.method === 'GET') { + if (getStatus !== 200) return { status: getStatus, ok: false, headers: new Headers(), json: async () => ({}), text: async () => '' }; + return { status: 200, ok: true, headers: new Headers(), json: async () => ({ state: 'OFF', timers }), text: async () => '' }; + } + return { status: 202, ok: true, headers: new Headers(), json: async () => ({}), text: async () => '' }; + }; +} +const OK_ARGS = { id: 1, enabled: true, time: '07:30', days: ['FRIDAY', 'MONDAY', 'MONDAY'] }; + +test('timer_set rejects malformed arguments before touching the cloud', async () => { + const bad = [ + { id: 0, enabled: true, time: '07:30', days: ['MONDAY'] }, + { id: 1.5, enabled: true, time: '07:30', days: ['MONDAY'] }, + { id: '1', enabled: true, time: '07:30', days: ['MONDAY'] }, + { id: 1, enabled: 'true', time: '07:30', days: ['MONDAY'] }, + { id: 1, enabled: true, time: '7:30', days: ['MONDAY'] }, + { id: 1, enabled: true, time: '24:00', days: ['MONDAY'] }, + { id: 1, enabled: true, time: '07:60', days: ['MONDAY'] }, + { id: 1, enabled: true, time: '07:30', days: [] }, + { id: 1, enabled: true, time: '07:30', days: ['FUNDAY'] }, + { id: 1, enabled: true, time: '07:30', days: '__proto__' }, + { id: 1, enabled: true, time: '07:30', days: ['__proto__'] }, + ]; + for (const args of bad) { + const spy = []; + await assert.rejects(control.runCommand(vehId, 'timer_set', args, { fetchImpl: timerFetch(spy) }), (e) => e.code === 'SKODA_VALIDATION', JSON.stringify(args)); + assert.equal(spy.length, 0, `reached the cloud with ${JSON.stringify(args)}`); + } +}); + +test('timer_set writes the slot back with deduped, sorted days', async () => { + const spy = []; + const r = await control.runCommand(vehId, 'timer_set', OK_ARGS, { fetchImpl: timerFetch(spy) }); + assert.equal(r.ok, true); + const post = spy.find((c) => c.method === 'POST'); + assert.match(post.url, /\/api\/v2\/air-conditioning\/VINCTL\/timers$/); + assert.deepEqual(JSON.parse(post.body), { + timers: [{ id: 1, enabled: true, time: '07:30', type: 'RECURRING', selectedDays: ['MONDAY', 'FRIDAY'] }], + }); +}); + +test('timer_set ignores a type supplied by the client', async () => { + const spy = []; + await control.runCommand(vehId, 'timer_set', { ...OK_ARGS, type: 'ONE_OFF' }, { fetchImpl: timerFetch(spy) }); + const post = spy.find((c) => c.method === 'POST'); + assert.equal(JSON.parse(post.body).timers[0].type, 'RECURRING'); // aus der Cloud-Antwort, nicht aus dem Request +}); + +test('timer_set on an unknown slot fails without writing', async () => { + const spy = []; + await assert.rejects(control.runCommand(vehId, 'timer_set', { ...OK_ARGS, id: 9 }, { fetchImpl: timerFetch(spy) }), (e) => e.code === 'SKODA_TIMER_NOT_FOUND'); + assert.equal(spy.filter((c) => c.method === 'POST').length, 0); +}); + +test('timer_set refuses ONE_OFF slots without writing', async () => { + const spy = []; + const timers = [{ id: 1, enabled: false, time: '05:15', type: 'ONE_OFF', selectedDays: [] }]; + await assert.rejects(control.runCommand(vehId, 'timer_set', OK_ARGS, { fetchImpl: timerFetch(spy, { timers }) }), (e) => e.code === 'SKODA_TIMER_READONLY'); + assert.equal(spy.filter((c) => c.method === 'POST').length, 0); +}); + +test('a failing air-conditioning read propagates and writes nothing', async () => { + const spy = []; + await assert.rejects(control.runCommand(vehId, 'timer_set', OK_ARGS, { fetchImpl: timerFetch(spy, { getStatus: 429 }) }), (e) => e.code === 'SKODA_RATE_LIMITED'); + assert.equal(spy.filter((c) => c.method === 'POST').length, 0); +}); From c56ddf96636efbb279d68fddd175b29521767464 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:56:12 +0200 Subject: [PATCH 04/11] feat(skoda): map timer error codes in admin and portal routers --- src/routes/api/portal.js | 2 +- src/routes/api/skoda.js | 2 ++ tests/skoda_portal_control.test.js | 23 +++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js index 8917dce6..4999247f 100644 --- a/src/routes/api/portal.js +++ b/src/routes/api/portal.js @@ -364,7 +364,7 @@ router.get('/skoda/vehicles/:id/details', async (req, res) => { }); // 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 }; +const SKODA_CMD_STATUS = { SKODA_UNKNOWN_COMMAND: 400, SKODA_VALIDATION: 400, SKODA_SPIN_REQUIRED: 409, SKODA_NO_SESSION: 409, SKODA_TIMER_READONLY: 409, SKODA_COMMAND_RATE_LIMIT: 429, SKODA_RATE_LIMITED: 429, SKODA_VEHICLE_NOT_FOUND: 404, SKODA_TIMER_NOT_FOUND: 404 }; router.post('/skoda/vehicles/:id/command', async (req, res) => { try { if (!portalConfig().widgets.skoda) return res.status(404).json({ ok: false }); diff --git a/src/routes/api/skoda.js b/src/routes/api/skoda.js index bcb4d99e..a260dac2 100644 --- a/src/routes/api/skoda.js +++ b/src/routes/api/skoda.js @@ -28,10 +28,12 @@ const STATUS_BY_CODE = { SKODA_ACCOUNT_EXISTS: 409, SKODA_SPIN_REQUIRED: 409, SKODA_NO_SESSION: 409, + SKODA_TIMER_READONLY: 409, SKODA_REFRESH_COOLDOWN: 429, SKODA_RATE_LIMITED: 429, SKODA_COMMAND_RATE_LIMIT: 429, SKODA_VEHICLE_NOT_FOUND: 404, + SKODA_TIMER_NOT_FOUND: 404, SKODA_ACCOUNT_NOT_FOUND: 404, }; diff --git a/tests/skoda_portal_control.test.js b/tests/skoda_portal_control.test.js index d786dea5..dda3056a 100644 --- a/tests/skoda_portal_control.test.js +++ b/tests/skoda_portal_control.test.js @@ -57,3 +57,26 @@ test('commanding a foreign vehicle is 403 SKODA_NOT_OWNER', async () => { assert.equal(m.mock.callCount(), 0); m.mock.restore(); }); + +test('portal timer_set without a login answers login_required instead of acting', async () => { + const m = mock.method(control, 'runCommand', async () => ({ ok: true })); + const res = await supertest(app).post(`/api/v1/portal/skoda/vehicles/${mineId}/command`) + .set('Host', HOME_HOST) + .send({ action: 'timer_set', args: { id: 1, enabled: true, time: '07:30', days: ['MONDAY'] } }); + assert.equal(res.status, 200); + assert.equal(res.body.reason, 'login_required'); + assert.equal(m.mock.callCount(), 0); + m.mock.restore(); +}); + +test('portal timer_set on a foreign vehicle is rejected with 403', async () => { + const m = mock.method(control, 'runCommand', async () => ({ ok: true })); + const agent = await getAgent(); + const res = await agent.post(`/api/v1/portal/skoda/vehicles/${foreignVehId}/command`) + .set('Host', HOME_HOST) + .send({ action: 'timer_set', args: { id: 1, enabled: true, time: '07:30', days: ['MONDAY'] } }); + assert.equal(res.status, 403); + assert.equal(res.body.error, 'SKODA_NOT_OWNER'); + assert.equal(m.mock.callCount(), 0); + m.mock.restore(); +}); From ec4702f204f80186640cceb7a69a5086beefaf7f Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:59:47 +0200 Subject: [PATCH 05/11] feat(skoda): add departure timer i18n keys for admin and portal --- src/i18n/de.json | 38 +++++++++++++++++++++++++++++++++ src/i18n/en.json | 38 +++++++++++++++++++++++++++++++++ templates/aurora/layout.njk | 19 +++++++++++++++++ templates/default/layout.njk | 19 +++++++++++++++++ templates/portal/portal.njk | 19 +++++++++++++++++ templates/pro/layout.njk | 19 +++++++++++++++++ tests/skoda_timers_i18n.test.js | 31 +++++++++++++++++++++++++++ 7 files changed, 183 insertions(+) create mode 100644 tests/skoda_timers_i18n.test.js diff --git a/src/i18n/de.json b/src/i18n/de.json index 1aec797f..e482f8c2 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -2263,6 +2263,25 @@ "skoda.details.score_as_of": "Stand", "skoda.details.load_error": "Details derzeit nicht verfügbar", "skoda.details.rate_limited": "Cloud aktuell ausgelastet", + "skoda.timers.title": "Abfahrtstimer", + "skoda.timers.none": "Keine Timer konfiguriert", + "skoda.timers.timer": "Timer {{n}}", + "skoda.timers.active": "Aktiv", + "skoda.timers.time": "Zeit", + "skoda.timers.days": "Wochentage", + "skoda.timers.save": "Speichern", + "skoda.timers.saved": "Gespeichert", + "skoda.timers.save_failed": "Speichern fehlgeschlagen", + "skoda.timers.invalid": "Bitte Uhrzeit und mindestens einen Wochentag wählen", + "skoda.timers.not_found": "Timer nicht gefunden — bitte Fahrzeug aktualisieren", + "skoda.timers.readonly": "Einmal-Timer — nur in der Skoda-App änderbar", + "skoda.timers.day.mon": "Mo", + "skoda.timers.day.tue": "Di", + "skoda.timers.day.wed": "Mi", + "skoda.timers.day.thu": "Do", + "skoda.timers.day.fri": "Fr", + "skoda.timers.day.sat": "Sa", + "skoda.timers.day.sun": "So", "portal.midea.fan": "Lüfter", "portal.midea.fan_auto": "Auto", "portal.midea.fan_silent": "Silent", @@ -2332,6 +2351,25 @@ "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.skoda.timers.title": "Abfahrtstimer", + "portal.skoda.timers.none": "Keine Timer konfiguriert", + "portal.skoda.timers.timer": "Timer", + "portal.skoda.timers.active": "Aktiv", + "portal.skoda.timers.time": "Zeit", + "portal.skoda.timers.days": "Wochentage", + "portal.skoda.timers.save": "Speichern", + "portal.skoda.timers.saved": "Gespeichert", + "portal.skoda.timers.save_failed": "Speichern fehlgeschlagen", + "portal.skoda.timers.invalid": "Bitte Uhrzeit und mindestens einen Wochentag wählen", + "portal.skoda.timers.not_found": "Timer nicht gefunden — bitte Fahrzeug aktualisieren", + "portal.skoda.timers.readonly": "Einmal-Timer — nur in der Skoda-App änderbar", + "portal.skoda.timers.day.mon": "Mo", + "portal.skoda.timers.day.tue": "Di", + "portal.skoda.timers.day.wed": "Mi", + "portal.skoda.timers.day.thu": "Do", + "portal.skoda.timers.day.fri": "Fr", + "portal.skoda.timers.day.sat": "Sa", + "portal.skoda.timers.day.sun": "So", "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 a4ec066c..ab4e85d4 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2319,6 +2319,25 @@ "skoda.details.score_as_of": "As of", "skoda.details.load_error": "Details currently unavailable", "skoda.details.rate_limited": "Cloud is currently busy", + "skoda.timers.title": "Departure timers", + "skoda.timers.none": "No timers configured", + "skoda.timers.timer": "Timer {{n}}", + "skoda.timers.active": "Active", + "skoda.timers.time": "Time", + "skoda.timers.days": "Weekdays", + "skoda.timers.save": "Save", + "skoda.timers.saved": "Saved", + "skoda.timers.save_failed": "Saving failed", + "skoda.timers.invalid": "Pick a time and at least one weekday", + "skoda.timers.not_found": "Timer not found — refresh the vehicle", + "skoda.timers.readonly": "One-off timer — editable in the Skoda app only", + "skoda.timers.day.mon": "Mon", + "skoda.timers.day.tue": "Tue", + "skoda.timers.day.wed": "Wed", + "skoda.timers.day.thu": "Thu", + "skoda.timers.day.fri": "Fri", + "skoda.timers.day.sat": "Sat", + "skoda.timers.day.sun": "Sun", "portal.midea.fan": "Fan", "portal.midea.fan_auto": "Auto", "portal.midea.fan_silent": "Silent", @@ -2388,6 +2407,25 @@ "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.skoda.timers.title": "Departure timers", + "portal.skoda.timers.none": "No timers configured", + "portal.skoda.timers.timer": "Timer", + "portal.skoda.timers.active": "Active", + "portal.skoda.timers.time": "Time", + "portal.skoda.timers.days": "Weekdays", + "portal.skoda.timers.save": "Save", + "portal.skoda.timers.saved": "Saved", + "portal.skoda.timers.save_failed": "Saving failed", + "portal.skoda.timers.invalid": "Pick a time and at least one weekday", + "portal.skoda.timers.not_found": "Timer not found — refresh the vehicle", + "portal.skoda.timers.readonly": "One-off timer — editable in the Skoda app only", + "portal.skoda.timers.day.mon": "Mon", + "portal.skoda.timers.day.tue": "Tue", + "portal.skoda.timers.day.wed": "Wed", + "portal.skoda.timers.day.thu": "Thu", + "portal.skoda.timers.day.fri": "Fri", + "portal.skoda.timers.day.sat": "Sat", + "portal.skoda.timers.day.sun": "Sun", "portal.smarthome.title": "Smart Home", "portal.smarthome.power": "Power", "portal.smarthome.brightness": "Brightness", diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk index 98c40693..6ea8ac15 100644 --- a/templates/aurora/layout.njk +++ b/templates/aurora/layout.njk @@ -578,6 +578,25 @@ 'skoda.cmd.spin': {{ t('skoda.cmd.spin') | dump | safe }}, 'skoda.cmd.spin_set': {{ t('skoda.cmd.spin_set') | dump | safe }}, 'skoda.details.title': {{ t('skoda.details.title') | dump | safe }}, + 'skoda.timers.title': {{ t('skoda.timers.title') | dump | safe }}, + 'skoda.timers.none': {{ t('skoda.timers.none') | dump | safe }}, + 'skoda.timers.timer': {{ t('skoda.timers.timer') | dump | safe }}, + 'skoda.timers.active': {{ t('skoda.timers.active') | dump | safe }}, + 'skoda.timers.time': {{ t('skoda.timers.time') | dump | safe }}, + 'skoda.timers.days': {{ t('skoda.timers.days') | dump | safe }}, + 'skoda.timers.save': {{ t('skoda.timers.save') | dump | safe }}, + 'skoda.timers.saved': {{ t('skoda.timers.saved') | dump | safe }}, + 'skoda.timers.save_failed': {{ t('skoda.timers.save_failed') | dump | safe }}, + 'skoda.timers.invalid': {{ t('skoda.timers.invalid') | dump | safe }}, + 'skoda.timers.not_found': {{ t('skoda.timers.not_found') | dump | safe }}, + 'skoda.timers.readonly': {{ t('skoda.timers.readonly') | dump | safe }}, + 'skoda.timers.day.mon': {{ t('skoda.timers.day.mon') | dump | safe }}, + 'skoda.timers.day.tue': {{ t('skoda.timers.day.tue') | dump | safe }}, + 'skoda.timers.day.wed': {{ t('skoda.timers.day.wed') | dump | safe }}, + 'skoda.timers.day.thu': {{ t('skoda.timers.day.thu') | dump | safe }}, + 'skoda.timers.day.fri': {{ t('skoda.timers.day.fri') | dump | safe }}, + 'skoda.timers.day.sat': {{ t('skoda.timers.day.sat') | dump | safe }}, + 'skoda.timers.day.sun': {{ t('skoda.timers.day.sun') | 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 }}, diff --git a/templates/default/layout.njk b/templates/default/layout.njk index 71bf00e5..daf3ed7b 100644 --- a/templates/default/layout.njk +++ b/templates/default/layout.njk @@ -571,6 +571,25 @@ 'skoda.cmd.spin': {{ t('skoda.cmd.spin') | dump | safe }}, 'skoda.cmd.spin_set': {{ t('skoda.cmd.spin_set') | dump | safe }}, 'skoda.details.title': {{ t('skoda.details.title') | dump | safe }}, + 'skoda.timers.title': {{ t('skoda.timers.title') | dump | safe }}, + 'skoda.timers.none': {{ t('skoda.timers.none') | dump | safe }}, + 'skoda.timers.timer': {{ t('skoda.timers.timer') | dump | safe }}, + 'skoda.timers.active': {{ t('skoda.timers.active') | dump | safe }}, + 'skoda.timers.time': {{ t('skoda.timers.time') | dump | safe }}, + 'skoda.timers.days': {{ t('skoda.timers.days') | dump | safe }}, + 'skoda.timers.save': {{ t('skoda.timers.save') | dump | safe }}, + 'skoda.timers.saved': {{ t('skoda.timers.saved') | dump | safe }}, + 'skoda.timers.save_failed': {{ t('skoda.timers.save_failed') | dump | safe }}, + 'skoda.timers.invalid': {{ t('skoda.timers.invalid') | dump | safe }}, + 'skoda.timers.not_found': {{ t('skoda.timers.not_found') | dump | safe }}, + 'skoda.timers.readonly': {{ t('skoda.timers.readonly') | dump | safe }}, + 'skoda.timers.day.mon': {{ t('skoda.timers.day.mon') | dump | safe }}, + 'skoda.timers.day.tue': {{ t('skoda.timers.day.tue') | dump | safe }}, + 'skoda.timers.day.wed': {{ t('skoda.timers.day.wed') | dump | safe }}, + 'skoda.timers.day.thu': {{ t('skoda.timers.day.thu') | dump | safe }}, + 'skoda.timers.day.fri': {{ t('skoda.timers.day.fri') | dump | safe }}, + 'skoda.timers.day.sat': {{ t('skoda.timers.day.sat') | dump | safe }}, + 'skoda.timers.day.sun': {{ t('skoda.timers.day.sun') | 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 }}, diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk index 1b504ef6..77423dd1 100644 --- a/templates/portal/portal.njk +++ b/templates/portal/portal.njk @@ -120,6 +120,25 @@ skodaDetailsScoreAsOf: t('portal.skoda.details.score_as_of'), skodaDetailsLoadError: t('portal.skoda.details.load_error'), skodaDetailsRateLimited: t('portal.skoda.details.rate_limited'), + skodaTimersTitle: t('portal.skoda.timers.title'), + skodaTimersNone: t('portal.skoda.timers.none'), + skodaTimersTimer: t('portal.skoda.timers.timer'), + skodaTimersActive: t('portal.skoda.timers.active'), + skodaTimersTime: t('portal.skoda.timers.time'), + skodaTimersDays: t('portal.skoda.timers.days'), + skodaTimersSave: t('portal.skoda.timers.save'), + skodaTimersSaved: t('portal.skoda.timers.saved'), + skodaTimersSaveFailed: t('portal.skoda.timers.save_failed'), + skodaTimersInvalid: t('portal.skoda.timers.invalid'), + skodaTimersNotFound: t('portal.skoda.timers.not_found'), + skodaTimersReadonly: t('portal.skoda.timers.readonly'), + skodaTimersDayMon: t('portal.skoda.timers.day.mon'), + skodaTimersDayTue: t('portal.skoda.timers.day.tue'), + skodaTimersDayWed: t('portal.skoda.timers.day.wed'), + skodaTimersDayThu: t('portal.skoda.timers.day.thu'), + skodaTimersDayFri: t('portal.skoda.timers.day.fri'), + skodaTimersDaySat: t('portal.skoda.timers.day.sat'), + skodaTimersDaySun: t('portal.skoda.timers.day.sun'), 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 44b13acc..d6cd6096 100644 --- a/templates/pro/layout.njk +++ b/templates/pro/layout.njk @@ -573,6 +573,25 @@ 'skoda.cmd.spin': {{ t('skoda.cmd.spin') | dump | safe }}, 'skoda.cmd.spin_set': {{ t('skoda.cmd.spin_set') | dump | safe }}, 'skoda.details.title': {{ t('skoda.details.title') | dump | safe }}, + 'skoda.timers.title': {{ t('skoda.timers.title') | dump | safe }}, + 'skoda.timers.none': {{ t('skoda.timers.none') | dump | safe }}, + 'skoda.timers.timer': {{ t('skoda.timers.timer') | dump | safe }}, + 'skoda.timers.active': {{ t('skoda.timers.active') | dump | safe }}, + 'skoda.timers.time': {{ t('skoda.timers.time') | dump | safe }}, + 'skoda.timers.days': {{ t('skoda.timers.days') | dump | safe }}, + 'skoda.timers.save': {{ t('skoda.timers.save') | dump | safe }}, + 'skoda.timers.saved': {{ t('skoda.timers.saved') | dump | safe }}, + 'skoda.timers.save_failed': {{ t('skoda.timers.save_failed') | dump | safe }}, + 'skoda.timers.invalid': {{ t('skoda.timers.invalid') | dump | safe }}, + 'skoda.timers.not_found': {{ t('skoda.timers.not_found') | dump | safe }}, + 'skoda.timers.readonly': {{ t('skoda.timers.readonly') | dump | safe }}, + 'skoda.timers.day.mon': {{ t('skoda.timers.day.mon') | dump | safe }}, + 'skoda.timers.day.tue': {{ t('skoda.timers.day.tue') | dump | safe }}, + 'skoda.timers.day.wed': {{ t('skoda.timers.day.wed') | dump | safe }}, + 'skoda.timers.day.thu': {{ t('skoda.timers.day.thu') | dump | safe }}, + 'skoda.timers.day.fri': {{ t('skoda.timers.day.fri') | dump | safe }}, + 'skoda.timers.day.sat': {{ t('skoda.timers.day.sat') | dump | safe }}, + 'skoda.timers.day.sun': {{ t('skoda.timers.day.sun') | 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 }}, diff --git a/tests/skoda_timers_i18n.test.js b/tests/skoda_timers_i18n.test.js new file mode 100644 index 00000000..b57abf40 --- /dev/null +++ b/tests/skoda_timers_i18n.test.js @@ -0,0 +1,31 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const de = require('../src/i18n/de.json'); +const en = require('../src/i18n/en.json'); + +const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; +const BASE = ['title', 'none', 'timer', 'active', 'time', 'days', 'save', 'saved', 'save_failed', 'invalid', 'not_found', 'readonly']; +const ADMIN_KEYS = BASE.map((k) => `skoda.timers.${k}`).concat(DAYS.map((d) => `skoda.timers.day.${d}`)); +const PORTAL_KEYS = BASE.map((k) => `portal.skoda.timers.${k}`).concat(DAYS.map((d) => `portal.skoda.timers.day.${d}`)); + +test('all timer keys exist in de and en', () => { + for (const k of ADMIN_KEYS.concat(PORTAL_KEYS)) { + assert.ok(de[k] && de[k].trim(), `de ${k}`); + assert.ok(en[k] && en[k].trim(), `en ${k}`); + } +}); + +test('all three layouts carry the skoda.timers.* GC.t whitelist', () => { + for (const theme of ['aurora', 'default', 'pro']) { + const layout = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'layout.njk'), 'utf8'); + for (const k of ADMIN_KEYS) assert.ok(layout.includes(`'${k}'`), `${theme} ${k}`); + } +}); + +test('the portal PT block carries every timer key', () => { + const njk = fs.readFileSync(path.join(__dirname, '..', 'templates', 'portal', 'portal.njk'), 'utf8'); + for (const k of PORTAL_KEYS) assert.ok(njk.includes(`t('${k}')`), `PT block ${k}`); +}); From 96cec0e85b70f34a4799a0c6bfc2f76194f3a1a2 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:07:38 +0200 Subject: [PATCH 06/11] feat(skoda): departure timer editor on the admin vehicle cards --- public/css/skoda.css | 11 +++ public/js/skoda.js | 122 ++++++++++++++++++++++++++++++-- tests/skoda_timers_i18n.test.js | 20 ++++++ 3 files changed, 147 insertions(+), 6 deletions(-) diff --git a/public/css/skoda.css b/public/css/skoda.css index 4ac44c05..7335208d 100644 --- a/public/css/skoda.css +++ b/public/css/skoda.css @@ -27,3 +27,14 @@ .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; } +.skoda-timers-block { margin-top: 8px; font-size: .9em; } +.skoda-timers-block summary { cursor: pointer; } +.skoda-timer { border-top: 1px solid var(--border-color, rgba(128,128,128,0.25)); padding: 8px 0; } +.skoda-timer-head { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } +.skoda-timer-days { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; } +.skoda-timer-day { padding: 3px 8px; border-radius: 999px; border: 1px solid var(--border-color, rgba(128,128,128,0.25)); background: transparent; color: inherit; cursor: pointer; font-size: .85em; } +.skoda-timer-day[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--text-inverse, #fff); } +.skoda-timer-day[disabled] { opacity: .5; cursor: default; } +.skoda-timer-foot { display: flex; gap: 8px; align-items: center; } +.skoda-timer-msg { font-size: .85em; } +.skoda-timer-msg.skoda-timer-ok { color: var(--green); } diff --git a/public/js/skoda.js b/public/js/skoda.js index fd8060dc..9ac7692f 100644 --- a/public/js/skoda.js +++ b/public/js/skoda.js @@ -44,6 +44,36 @@ `; } + const DAY_KEYS = [['MONDAY', 'mon'], ['TUESDAY', 'tue'], ['WEDNESDAY', 'wed'], ['THURSDAY', 'thu'], ['FRIDAY', 'fri'], ['SATURDAY', 'sat'], ['SUNDAY', 'sun']]; + + function timerRow(vehId, t) { + const days = Array.isArray(t.days) ? t.days : []; + // Nur RECURRING ist schreibbar — der Server lehnt alles andere mit + // SKODA_TIMER_READONLY ab. Werte werden trotzdem angezeigt, nur gesperrt. + const editable = t.type === 'RECURRING'; + const dis = editable ? '' : ' disabled'; + const chips = DAY_KEYS.map(([code, k]) => + ``).join(''); + return `
+
+ ${T('skoda.timers.timer', { n: Number(t.id) })} + + +
+
${chips}
+
+ ${editable ? `` : ''} + ${t.type === 'ONE_OFF' ? T('skoda.timers.readonly') : ''} +
+
`; + } + + function timersBlock(v) { + const timers = (v.state && v.state.climate && v.state.climate.timers) || []; + const body = timers.length ? timers.map((t) => timerRow(v.id, t)).join('') : `

${T('skoda.timers.none')}

`; + return `
${T('skoda.timers.title')}${body}
`; + } + function vehicleCard(v) { const s = v.state || {}; const lock = s.locked === true ? T('skoda.vehicle.locked') : s.locked === false ? T('skoda.vehicle.unlocked') : '—'; @@ -77,6 +107,7 @@
${T('skoda.details.title')}
+ ${timersBlock(v)} `; } @@ -164,20 +195,87 @@ } } + // Notbremse wie im Portal: Gesetzt wird `dirty` bei jeder Eingabe, entfernt nur + // beim erfolgreichen Speichern. Ohne Ablauf blockierte eine nie gespeicherte + // Zeile JEDEN load()-Aufrufer dauerhaft — und das sind fast alle Aktionen der + // Seite (Konto anlegen/löschen, Passwort, Fahrzeug-Refresh, Besitzer speichern). + // Der Admin pollt nicht, es gäbe also keine Selbstheilung außer F5. + let _timerDirtyTimeout = null; + function markTimerDirty(row) { + if (!row) return; + row.dataset.dirty = '1'; + if (_timerDirtyTimeout) clearTimeout(_timerDirtyTimeout); + _timerDirtyTimeout = setTimeout(() => { + el('skoda-vehicles').querySelectorAll('.skoda-timer[data-dirty]').forEach((r) => { delete r.dataset.dirty; }); + _timerDirtyTimeout = null; + }, 600000); + } + + async function saveTimer(row, btn) { + if (!row || btn.disabled) return; + const msg = row.querySelector('.skoda-timer-msg'); + const time = row.querySelector('[data-timer-time]').value; + const days = [...row.querySelectorAll('.skoda-timer-day[aria-pressed="true"]')].map((b) => b.dataset.day); + if (!time || !days.length) { msg.textContent = T('skoda.timers.invalid'); return; } + const args = { id: Number(row.dataset.timer), enabled: row.querySelector('[data-timer-enabled]').checked, time, days }; + // Ganze Zeile einfrieren: sonst quittiert das grüne "Gespeichert" auch + // Änderungen, die nach dem Absenden getippt und nie gesendet wurden. + const fields = [...row.querySelectorAll('input, .skoda-timer-day')]; + const release = () => { btn.disabled = false; fields.forEach((f) => { f.disabled = false; }); }; + btn.disabled = true; + fields.forEach((f) => { f.disabled = true; }); + msg.textContent = ''; + msg.classList.remove('skoda-timer-ok'); + // fetch hat kein eigenes Timeout — ohne Watchdog bliebe die Zeile nach einem + // hängenden Request bis zum Reload gesperrt. + const watchdog = setTimeout(() => { release(); msg.textContent = T('skoda.timers.save_failed'); }, 30000); + try { + await api('POST', `/vehicles/${row.dataset.veh}/command`, { action: 'timer_set', args }); + delete row.dataset.dirty; + msg.textContent = T('skoda.timers.saved'); + msg.classList.add('skoda-timer-ok'); + setTimeout(() => { msg.textContent = ''; msg.classList.remove('skoda-timer-ok'); }, 3000); + } catch (e) { + // Nur übersetzte Texte — e.message wären rohe englische Serverstrings. + msg.textContent = e.code === 'SKODA_TIMER_NOT_FOUND' ? T('skoda.timers.not_found') + : e.code === 'SKODA_TIMER_READONLY' ? T('skoda.timers.readonly') + : e.code === 'SKODA_VALIDATION' ? T('skoda.timers.invalid') + : T('skoda.timers.save_failed'); + } finally { + clearTimeout(watchdog); + release(); + } + } + 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); + // Ungespeicherte Timer-Eingabe schlägt jeden Rebuild — auch den, den + // command() 3s nach einem Klima-/Lade-/Sperrbefehl auslöst. + if (vehiclesEl.querySelector('.skoda-timer[data-dirty]')) return; + // Offen-Zustand je Fahrzeug-ID merken (nicht positionsindiziert: ein + // entferntes oder neues Fahrzeug würde sonst den falschen Block öffnen) + // und den bereits geladenen Enrich-Inhalt wieder einsetzen. + const wasOpen = {}; + vehiclesEl.querySelectorAll('.skoda-card').forEach((c) => { + wasOpen[c.dataset.id] = { + details: !!(c.querySelector('.skoda-details-block') || {}).open, + timers: !!(c.querySelector('.skoda-timers-block') || {}).open, + }; + }); current = await api('GET', ''); el('skoda-poll-interval').value = current.poll_interval_min; el('skoda-accounts').innerHTML = current.accounts.map(accountRow).join('') || ''; vehiclesEl.innerHTML = current.vehicles.map(vehicleCard).join('') || `

${T('skoda.vehicles.empty')}

`; - vehiclesEl.querySelectorAll('.skoda-details-block').forEach((d, i) => { - if (!wasOpen[i]) return; + vehiclesEl.querySelectorAll('.skoda-card').forEach((c) => { + const st = wasOpen[c.dataset.id]; + if (!st) return; + const timers = c.querySelector('.skoda-timers-block'); + if (timers && st.timers) timers.open = true; + const d = c.querySelector('.skoda-details-block'); + if (!d || !st.details) return; d.open = true; const box = d.querySelector('.skoda-enrich'); + if (!box) return; const vehId = Number(box.dataset.veh); if (skodaEnrich[vehId] != null) box.innerHTML = skodaEnrich[vehId]; }); @@ -248,10 +346,19 @@ const d = ev.target; if (!d.classList || !d.classList.contains('skoda-details-block') || !d.open) return; const box = d.querySelector('.skoda-enrich'); + if (!box) return; loadDetails(Number(box.dataset.veh), box); }, true); el('skoda-vehicles').addEventListener('click', (ev) => { + const chip = ev.target.closest('.skoda-timer-day'); + if (chip && !chip.disabled) { + chip.setAttribute('aria-pressed', chip.getAttribute('aria-pressed') === 'true' ? 'false' : 'true'); + markTimerDirty(chip.closest('.skoda-timer')); + return; + } + const save = ev.target.closest('[data-timer-save]'); + if (save) { saveTimer(save.closest('.skoda-timer'), save); return; } const b = ev.target.closest('button[data-cmd]'); if (!b) return; const box = b.closest('.skoda-cmds'); const veh = Number(box.dataset.veh); let args = {}; @@ -266,6 +373,9 @@ const s = ev.target.closest('select[data-cmd="charge_limit"]'); if (!s) return; command(Number(s.closest('.skoda-cmds').dataset.veh), 'charge_limit', { limit: Number(s.value) }, s); }); + ['input', 'change'].forEach((evt) => { + el('skoda-vehicles').addEventListener(evt, (ev) => markTimerDirty(ev.target.closest('.skoda-timer'))); + }); el('skoda-owner-cancel').addEventListener('click', () => hideModal('skoda-owner-modal')); el('skoda-owner-save').addEventListener('click', async () => { diff --git a/tests/skoda_timers_i18n.test.js b/tests/skoda_timers_i18n.test.js index b57abf40..9090fda8 100644 --- a/tests/skoda_timers_i18n.test.js +++ b/tests/skoda_timers_i18n.test.js @@ -29,3 +29,23 @@ test('the portal PT block carries every timer key', () => { const njk = fs.readFileSync(path.join(__dirname, '..', 'templates', 'portal', 'portal.njk'), 'utf8'); for (const k of PORTAL_KEYS) assert.ok(njk.includes(`t('${k}')`), `PT block ${k}`); }); + +test('skoda.js renders the timer block and wires timer_set', () => { + const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'skoda.js'), 'utf8'); + assert.match(js, /skoda-timers-block/); + assert.match(js, /timer_set/); + assert.match(js, /type="time"/); + // Der Timer-Block darf NICHT die Details-Klasse tragen — sonst laufen der + // Rebuild-Erhalt und der Toggle-Handler auf einen fehlenden .skoda-enrich. + // Zeilenweise prüfen: im selben Markup-Fragment dürfen beide nicht stehen. + assert.doesNotMatch(js, /skoda-timers-block[^\n]*skoda-enrich/); +}); + +test('skoda.js guards every enrich lookup and never shows raw server messages', () => { + const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'skoda.js'), 'utf8'); + const lookups = (js.match(/querySelector\('\.skoda-enrich'\)/g) || []).length; + const guards = (js.match(/if \(!box\) return;/g) || []).length; + assert.ok(lookups >= 2, `expected at least two enrich lookups, found ${lookups}`); + assert.ok(guards >= lookups, `every enrich lookup needs a null-guard (${guards} guards for ${lookups} lookups)`); + assert.doesNotMatch(js, /skoda-timer-msg[\s\S]{0,400}e\.message/); +}); From 290332319117982b421d1cf2bcd33974615d1bbc Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:20:26 +0200 Subject: [PATCH 07/11] feat(skoda): departure timer editor in the portal vehicle widget --- public/css/portal.css | 13 +++ public/js/portal.js | 154 ++++++++++++++++++++++++++++++-- tests/skoda_timers_i18n.test.js | 31 +++++++ 3 files changed, 189 insertions(+), 9 deletions(-) diff --git a/public/css/portal.css b/public/css/portal.css index de971ac4..77d25e3e 100644 --- a/public/css/portal.css +++ b/public/css/portal.css @@ -489,3 +489,16 @@ body::before{ *,*::before{transition:none !important; animation:none !important} .card{opacity:1; transform:none} } + +.skoda-timers { margin-top: 10px; font-size: .88em; color: var(--muted); } +.skoda-timers summary { cursor: pointer; } +.skoda-timer { border-top: 1px solid var(--line); padding: 8px 0; } +.skoda-timer-head { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } +.skoda-timer-days { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; } +.skoda-timer-day { padding: 3px 8px; border-radius: 999px; border: 1px solid var(--line); background: transparent; color: inherit; cursor: pointer; font-size: .85em; } +.skoda-timer-day[aria-pressed="true"] { background: color-mix(in srgb, var(--teal) 20%, transparent); border-color: var(--teal); color: var(--teal); } +.skoda-timer-day[disabled] { opacity: .5; cursor: default; } +.skoda-timer-foot { display: flex; gap: 8px; align-items: center; } +.skoda-timer-foot button { padding: 5px 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-3); color: inherit; cursor: pointer; font-size: .85em; } +.skoda-timer-msg { font-size: .85em; } +.skoda-timer-msg.skoda-timer-ok { color: var(--green); } diff --git a/public/js/portal.js b/public/js/portal.js index 1354f98b..9df649f7 100644 --- a/public/js/portal.js +++ b/public/js/portal.js @@ -914,6 +914,84 @@ .then(function () { clearTimeout(watchdog); setTimeout(reset, 3000); }); } + // Ungespeicherte Eingaben werden PRO ZEILE markiert: pro Block oder pro Karte + // zu markieren hieße, dass das Speichern von Timer 1 die Eingabe in Timer 2 + // zum Überschreiben freigibt. Zuklappen räumt bewusst NICHT auf. + var _skodaDirtyTimeout = null; + function markSkodaTimerDirty(node) { + var row = node.closest('.skoda-timer'); if (!row) return; + row.dataset.dirty = '1'; + if (_skodaDirtyTimeout) clearTimeout(_skodaDirtyTimeout); + // Notbremse gegen einen vergessenen Tab: nach 10 Minuten ohne Eingabe wird + // freigegeben — und die Eingabe damit bewusst verworfen. + _skodaDirtyTimeout = setTimeout(function () { clearSkodaTimerDirty(); }, 600000); + } + function clearSkodaTimerDirty(row) { + if (row) { delete row.dataset.dirty; } else { + var all = skodaEl(); if (!all) return; + var rows = all.querySelectorAll('.skoda-timer[data-dirty]'); + for (var i = 0; i < rows.length; i++) delete rows[i].dataset.dirty; + } + var el = skodaEl(); + if (_skodaDirtyTimeout && el && !el.querySelector('.skoda-timer[data-dirty]')) { + clearTimeout(_skodaDirtyTimeout); _skodaDirtyTimeout = null; + } + } + + var SKODA_TIMER_ERRORS = { + SKODA_TIMER_NOT_FOUND: 'skodaTimersNotFound', + SKODA_TIMER_READONLY: 'skodaTimersReadonly', + SKODA_VALIDATION: 'skodaTimersInvalid', + }; + + // Eigener Sender statt skodaCommand(): der ruft bei Erfolg hydrateSkoda() nach + // 3s, und die Cloud übernimmt Timer-Änderungen erst nach rund 55 Sekunden — + // der Rebuild würde die optimistisch gesetzten Werte durch alte ersetzen. + function saveSkodaTimer(row, btn) { + if (!row || btn.disabled) return; + var msg = row.querySelector('.skoda-timer-msg'); + var time = row.querySelector('[data-timer-time]').value; + var chips = row.querySelectorAll('.skoda-timer-day[aria-pressed="true"]'); + var days = []; + for (var i = 0; i < chips.length; i++) days.push(chips[i].dataset.day); + if (!time || !days.length) { msg.textContent = PT.skodaTimersInvalid; return; } + // Ganze Zeile einfrieren, sonst quittiert "Gespeichert" auch Änderungen, + // die nach dem Absenden getippt und nie gesendet wurden. + var fields = row.querySelectorAll('input, .skoda-timer-day'); + var release = function () { + btn.disabled = false; + for (var f = 0; f < fields.length; f++) fields[f].disabled = false; + }; + btn.disabled = true; + for (var k = 0; k < fields.length; k++) fields[k].disabled = true; + msg.textContent = ''; + msg.classList.remove('skoda-timer-ok'); + // fetch hat kein eigenes Timeout. + var watchdog = setTimeout(function () { release(); msg.textContent = PT.skodaTimersSaveFailed; }, 30000); + fetch('/api/v1/portal/skoda/vehicles/' + encodeURIComponent(row.dataset.veh) + '/command', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'timer_set', + args: { id: Number(row.dataset.timer), enabled: row.querySelector('[data-timer-enabled]').checked, time: time, days: days }, + }), + }).then(function (r) { return r.json().catch(function () { return {}; }); }) + .then(function (body) { + // reason (login_required/unavailable) kommt als data:null,reason MIT ok:true → auch ein Fehlschlag + if (!body || !body.ok || body.reason) { + var code = body && body.error; + var key = Object.prototype.hasOwnProperty.call(SKODA_TIMER_ERRORS, code) ? SKODA_TIMER_ERRORS[code] : null; + msg.textContent = (key && PT[key]) || PT.skodaTimersSaveFailed; + return; + } + clearSkodaTimerDirty(row); + msg.textContent = PT.skodaTimersSaved; + msg.classList.add('skoda-timer-ok'); + setTimeout(function () { msg.textContent = ''; msg.classList.remove('skoda-timer-ok'); }, 3000); + }) + .catch(function () { msg.textContent = PT.skodaTimersSaveFailed; }) + .then(function () { clearTimeout(watchdog); release(); }); + } + // 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 @@ -997,6 +1075,56 @@ var s = ev.target.closest('select[data-cmd="charge_limit"]'); if (!s) return; skodaCommand(Number(s.closest('.skoda-cmds').dataset.veh), 'charge_limit', { limit: Number(s.value) }, s); }); + el.addEventListener('click', function (ev) { + var chip = ev.target.closest('.skoda-timer-day'); + if (chip && !chip.disabled) { + chip.setAttribute('aria-pressed', chip.getAttribute('aria-pressed') === 'true' ? 'false' : 'true'); + markSkodaTimerDirty(chip); + return; + } + var save = ev.target.closest('[data-timer-save]'); + if (save) saveSkodaTimer(save.closest('.skoda-timer'), save); + }); + el.addEventListener('input', function (ev) { + if (ev.target.closest('.skoda-timer')) markSkodaTimerDirty(ev.target); + }); + el.addEventListener('change', function (ev) { + if (ev.target.closest('.skoda-timer')) markSkodaTimerDirty(ev.target); + }); + } + + var SKODA_DAYS = [['MONDAY', 'skodaTimersDayMon'], ['TUESDAY', 'skodaTimersDayTue'], ['WEDNESDAY', 'skodaTimersDayWed'], + ['THURSDAY', 'skodaTimersDayThu'], ['FRIDAY', 'skodaTimersDayFri'], ['SATURDAY', 'skodaTimersDaySat'], ['SUNDAY', 'skodaTimersDaySun']]; + + function skodaTimerRow(vehId, t) { + var days = Array.isArray(t.days) ? t.days : []; + var editable = t.type === 'RECURRING'; + var dis = editable ? '' : ' disabled'; + var chips = SKODA_DAYS.map(function (d) { + return ''; + }).join(''); + return '
' + + '
' + escHtml(PT.skodaTimersTimer) + ' ' + escHtml(t.id) + '' + + '' + + '
' + + '
' + chips + '
' + + '
' + + (editable ? '' : '') + + '' + (t.type === 'ONE_OFF' ? escHtml(PT.skodaTimersReadonly) : '') + '' + + '
'; + } + + function skodaTimersBlock(v, loggedIn) { + // Abfahrtszeiten sind ein Anwesenheitsprofil — ohne Login liefert der Server + // sie gar nicht, also auch keinen Abschnitt zeigen (sonst stünde dort die + // Unwahrheit "Keine Timer konfiguriert"). + if (!loggedIn) return ''; + var timers = (v.state && v.state.climate && v.state.climate.timers) || []; + var body = timers.length + ? timers.map(function (t) { return skodaTimerRow(v.id, t); }).join('') + : '
' + escHtml(PT.skodaTimersNone) + '
'; + return '
' + escHtml(PT.skodaTimersTitle) + '' + body + '
'; } function renderSkodaCard(v, loggedIn) { @@ -1045,6 +1173,7 @@ + (hl.warnings && hl.warnings.length ? '
' + escHtml(PT.skodaWarnings) + ': ' + escHtml(hl.warnings.join(', ')) + '
' : '') + '
' + '
' + + skodaTimersBlock(v, loggedIn) + (loggedIn ? '
' + '' + '' @@ -1063,27 +1192,34 @@ function renderSkoda(vehicles, loggedIn) { var el = skodaEl(); if (!el) return; - // Preserve which cards had their
expanded across the full rebuild, - // so a 120s poll never collapses what the user opened. Tracked by position - // (card order is stable — server returns vehicles ORDER BY id) using only - // the boolean `details.open`, so no DOM text ever feeds back into innerHTML. - var wasOpen = []; + // Ein Rebuild ersetzt innerHTML komplett. Steht irgendwo eine ungespeicherte + // Timer-Eingabe, wird NICHT gerendert — egal ob der Aufruf aus dem 120s-Poll + // oder aus hydrateSkoda() kommt. + if (el.querySelector('.skoda-timer[data-dirty]')) return; + // Offen-Zustand je Fahrzeug-ID merken, nicht positionsindiziert, und je + // Aufklapper klassengebunden — ein nacktes details-Selektorziel ohne Klasse + // träfe je nach DOM-Reihenfolge den falschen Block. + var wasOpen = {}; var oldCards = el.querySelectorAll('.skoda-card'); for (var i = 0; i < oldCards.length; i++) { - var od = oldCards[i].querySelector('details'); - wasOpen[i] = !!(od && od.open); + var od = oldCards[i].querySelector('details.skoda-details'); + var ot = oldCards[i].querySelector('details.skoda-timers'); + wasOpen[oldCards[i].dataset.id] = { details: !!(od && od.open), timers: !!(ot && ot.open) }; } 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++) { - var nd = newCards[j].querySelector('details'); if (!nd) continue; + var st = wasOpen[newCards[j].dataset.id] || { details: false, timers: false }; + var nt = newCards[j].querySelector('details.skoda-timers'); + if (nt && st.timers) nt.open = true; + var nd = newCards[j].querySelector('details.skoda-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]) { + if (st.details) { nd.open = true; // 120s poll rebuild would otherwise blank an already-fetched block. var box = nd.querySelector('.skoda-enrich'); diff --git a/tests/skoda_timers_i18n.test.js b/tests/skoda_timers_i18n.test.js index 9090fda8..d0d3e747 100644 --- a/tests/skoda_timers_i18n.test.js +++ b/tests/skoda_timers_i18n.test.js @@ -49,3 +49,34 @@ test('skoda.js guards every enrich lookup and never shows raw server messages', assert.ok(guards >= lookups, `every enrich lookup needs a null-guard (${guards} guards for ${lookups} lookups)`); assert.doesNotMatch(js, /skoda-timer-msg[\s\S]{0,400}e\.message/); }); + +test('portal.js renders the timer block and wires timer_set', () => { + const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'portal.js'), 'utf8'); + assert.match(js, /skoda-timers/); + assert.match(js, /timer_set/); + assert.match(js, /data-dirty/); +}); + +test('portal.js escapes every value inside the timer renderer', () => { + const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'portal.js'), 'utf8'); + const from = js.indexOf('function skodaTimerRow'); + const to = js.indexOf('function renderSkodaCard'); + assert.ok(from > 0 && to > from, 'timer renderer block not found'); + const body = js.slice(from, to); + // CodeQL js/xss-through-dom: PT stammt aus #portal-i18n.textContent, ist also + // eine DOM-Text-Quelle. Jeder PT-Zugriff im Renderer muss in escHtml( stehen. + for (const m of body.matchAll(/PT[.[]/g)) { + assert.ok(/escHtml\($/.test(body.slice(0, m.index)), + 'unescaped PT value: …' + body.slice(Math.max(0, m.index - 60), m.index + 30)); + } + assert.match(body, /data-timer="' \+ escHtml\(t\.id/); + assert.match(body, /value="' \+ escHtml\(t\.time/); + assert.equal((body.match(/innerHTML/g) || []).length, 0, 'renderer builds strings, never assigns innerHTML'); +}); + +test('portal.js narrows the details selectors so the timer block is not mistaken for it', () => { + const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'portal.js'), 'utf8'); + assert.doesNotMatch(js, /querySelector\('details'\)/); + assert.match(js, /querySelector\('details\.skoda-details'\)/); + assert.match(js, /querySelector\('details\.skoda-timers'\)/); +}); From d7d5c0d839f3f205f220b359ce542172780f77d5 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:33:53 +0200 Subject: [PATCH 08/11] docs: changelog for TP4b departure timers --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74bd1a09..4e2ac8a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [Unreleased] + +### Added +- Skoda: Abfahrtstimer (Klima-Timer) je Fahrzeug lesen und setzen — An/Aus, Uhrzeit und Wochentage, in der Admin-Seite `/skoda` und im Portal-Widget. Neuer Command `timer_set` über `POST /api/v2/air-conditioning/{vin}/timers`. Einmal-Timer (`ONE_OFF`) werden nur angezeigt, nicht geschrieben. +- Skoda: Im Portal sind die Abfahrtszeiten nur für eingeloggte Besitzer sichtbar — sie sind ein Anwesenheitsprofil und werden wie die GPS-Position behandelt, nicht wie der Klimastatus. + +### Changed +- Skoda: `state.climate.timers` wird aus dem ohnehin geholten Klima-Payload übernommen — kein zusätzlicher Cloud-Abruf. + +### Notes +- Die Skoda-Cloud übernimmt Timer-Änderungen asynchron (gemessen rund 55 Sekunden). Bis zum nächsten vollständigen Sync kann die Anzeige noch die alten Werte zeigen, ohne dass etwas fehlgeschlagen wäre. +- Direkt nach dem Update zeigen die Karten bis zum nächsten Poll „Keine Timer konfiguriert", weil der gespeicherte Fahrzeugzustand das Feld noch nicht kennt. + +--- + ## [1.118.2] — 2026-07-23 ### Dokumentation From 7bf5d2e8761dd23bcf0dc445974a58e3cd932f79 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:42:47 +0200 Subject: [PATCH 09/11] fix(skoda): a11y group role on day chips + timer status/login-gate test coverage - role="group" on .skoda-timer-days so the aria-label has an accessible name (admin skoda.js + portal.js) - tests for SKODA_TIMER_NOT_FOUND (404) and SKODA_TIMER_READONLY (409) via the real admin and portal command routes - source smoke test that skodaTimersBlock returns '' without a login --- public/js/portal.js | 2 +- public/js/skoda.js | 2 +- tests/skoda_command_api.test.js | 18 ++++++++++++++++++ tests/skoda_portal_control.test.js | 24 ++++++++++++++++++++++++ tests/skoda_timers_i18n.test.js | 9 +++++++++ 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/public/js/portal.js b/public/js/portal.js index 9df649f7..2048f06b 100644 --- a/public/js/portal.js +++ b/public/js/portal.js @@ -1108,7 +1108,7 @@ + '
' + escHtml(PT.skodaTimersTimer) + ' ' + escHtml(t.id) + '' + '' + '
' - + '
' + chips + '
' + + '
' + chips + '
' + '
' + (editable ? '' : '') + '' + (t.type === 'ONE_OFF' ? escHtml(PT.skodaTimersReadonly) : '') + '' diff --git a/public/js/skoda.js b/public/js/skoda.js index 9ac7692f..6b83e1bb 100644 --- a/public/js/skoda.js +++ b/public/js/skoda.js @@ -60,7 +60,7 @@
-
${chips}
+
${chips}
${editable ? `` : ''} ${t.type === 'ONE_OFF' ? T('skoda.timers.readonly') : ''} diff --git a/tests/skoda_command_api.test.js b/tests/skoda_command_api.test.js index cbe55caa..f0e434f1 100644 --- a/tests/skoda_command_api.test.js +++ b/tests/skoda_command_api.test.js @@ -45,6 +45,24 @@ test('spin required maps to 409', async () => { m.mock.restore(); }); +test('timer not found maps to 404', async () => { + const err = Object.assign(new Error('x'), { code: 'SKODA_TIMER_NOT_FOUND' }); + const m = mock.method(control, 'runCommand', async () => { throw err; }); + const res = await ctx.agent.post(`/api/v1/skoda/vehicles/${vehId}/command`).set('x-csrf-token', ctx.csrfToken).send({ action: 'set_timer' }); + assert.equal(res.status, 404); + assert.equal(res.body.code, 'SKODA_TIMER_NOT_FOUND'); + m.mock.restore(); +}); + +test('timer readonly maps to 409', async () => { + const err = Object.assign(new Error('x'), { code: 'SKODA_TIMER_READONLY' }); + const m = mock.method(control, 'runCommand', async () => { throw err; }); + const res = await ctx.agent.post(`/api/v1/skoda/vehicles/${vehId}/command`).set('x-csrf-token', ctx.csrfToken).send({ action: 'set_timer' }); + assert.equal(res.status, 409); + assert.equal(res.body.code, 'SKODA_TIMER_READONLY'); + m.mock.restore(); +}); + test('PUT spin sets it (validated) and never echoes it', async () => { const accId = accounts.listAccounts()[0].id; const res = await ctx.agent.put(`/api/v1/skoda/accounts/${accId}/spin`).set('x-csrf-token', ctx.csrfToken).send({ spin: '1234' }); diff --git a/tests/skoda_portal_control.test.js b/tests/skoda_portal_control.test.js index dda3056a..69719cea 100644 --- a/tests/skoda_portal_control.test.js +++ b/tests/skoda_portal_control.test.js @@ -69,6 +69,30 @@ test('portal timer_set without a login answers login_required instead of acting' m.mock.restore(); }); +test('portal timer_set with unknown timer id maps to 404', async () => { + const err = Object.assign(new Error('x'), { code: 'SKODA_TIMER_NOT_FOUND' }); + const m = mock.method(control, 'runCommand', async () => { throw err; }); + const agent = await getAgent(); + const res = await agent.post(`/api/v1/portal/skoda/vehicles/${mineId}/command`) + .set('Host', HOME_HOST) + .send({ action: 'timer_set', args: { id: 99, enabled: true, time: '07:30', days: ['MONDAY'] } }); + assert.equal(res.status, 404); + assert.equal(res.body.error, 'SKODA_TIMER_NOT_FOUND'); + m.mock.restore(); +}); + +test('portal timer_set on a non-recurring (readonly) timer maps to 409', async () => { + const err = Object.assign(new Error('x'), { code: 'SKODA_TIMER_READONLY' }); + const m = mock.method(control, 'runCommand', async () => { throw err; }); + const agent = await getAgent(); + const res = await agent.post(`/api/v1/portal/skoda/vehicles/${mineId}/command`) + .set('Host', HOME_HOST) + .send({ action: 'timer_set', args: { id: 1, enabled: true, time: '07:30', days: ['MONDAY'] } }); + assert.equal(res.status, 409); + assert.equal(res.body.error, 'SKODA_TIMER_READONLY'); + m.mock.restore(); +}); + test('portal timer_set on a foreign vehicle is rejected with 403', async () => { const m = mock.method(control, 'runCommand', async () => ({ ok: true })); const agent = await getAgent(); diff --git a/tests/skoda_timers_i18n.test.js b/tests/skoda_timers_i18n.test.js index d0d3e747..eddb1445 100644 --- a/tests/skoda_timers_i18n.test.js +++ b/tests/skoda_timers_i18n.test.js @@ -74,6 +74,15 @@ test('portal.js escapes every value inside the timer renderer', () => { assert.equal((body.match(/innerHTML/g) || []).length, 0, 'renderer builds strings, never assigns innerHTML'); }); +test('skodaTimersBlock bails out early without a login', () => { + const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'portal.js'), 'utf8'); + const from = js.indexOf('function skodaTimersBlock'); + const to = js.indexOf('function', from + 1); + assert.ok(from > 0 && to > from, 'skodaTimersBlock not found'); + const body = js.slice(from, to); + assert.match(body, /if \(!loggedIn\) return '';/); +}); + test('portal.js narrows the details selectors so the timer block is not mistaken for it', () => { const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'portal.js'), 'utf8'); assert.doesNotMatch(js, /querySelector\('details'\)/); From 95c135d807e62bc5d9de52dcf8c04d928182fec6 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:52:21 +0200 Subject: [PATCH 10/11] fix(security): force grpc >= v1.82.1 in the caddy plugin graph (GHSA-hrxh-6v49-42gf) --- caddy-plugins/mirror/go.mod | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/caddy-plugins/mirror/go.mod b/caddy-plugins/mirror/go.mod index eed136ca..ea32d24e 100644 --- a/caddy-plugins/mirror/go.mod +++ b/caddy-plugins/mirror/go.mod @@ -8,5 +8,8 @@ require ( github.com/go-jose/go-jose/v3 v3.0.5 github.com/go-jose/go-jose/v4 v4.1.4 github.com/smallstep/certificates v0.30.0 - google.golang.org/grpc v1.79.3 + // Mindestversion erzwingt den Fix für GHSA-hrxh-6v49-42gf (gRPC-Go: xDS RBAC + // und HTTP/2). Der Plugin-Graph zog sonst v1.81.0 herein, was Trivy als HIGH + // meldet; behoben in v1.82.1. + google.golang.org/grpc v1.82.1 ) From 4559aa2f611b9878040045050e84d4623183e2af Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:33:40 +0200 Subject: [PATCH 11/11] chore(security): trivyignore CVE-2026-14257 (brace-expansion in npm's own bundle, not in runtime path) --- .trivyignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.trivyignore b/.trivyignore index 771e0a69..357eb18d 100644 --- a/.trivyignore +++ b/.trivyignore @@ -12,6 +12,15 @@ CVE-2026-33671 # our deployment. Will be removed once upstream Caddy pulls otel >= 1.43.0. CVE-2026-39883 +# brace-expansion 5.0.7 in npm's OWN bundled node_modules (usr/local/lib/ +# node_modules/npm/node_modules/brace-expansion) — used only by npm itself for +# glob expansion during CLI operations, NOT reachable from gatecontrol runtime. +# Our own brace-expansion (5.0.6) is a devDependency and never enters the image: +# the Dockerfile builds with `npm ci --production`. No fix available without npm +# shipping a patched bundle — npm is pinned to @11 because @12 broke the build +# on Node 20. Will be removed once npm@11 bundles brace-expansion >= 5.0.8. +CVE-2026-14257 + # undici in npm's OWN bundled node_modules (usr/local/lib/node_modules/npm/ # node_modules/undici) — pulled in by `npm install -g npm@latest` in the image, # used only by npm itself for registry fetches, NOT reachable from gatecontrol