From 8931f5f4dd0f3e171a1162035928ec0ee2c65b36 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:06:24 +0200 Subject: [PATCH 01/11] feat(skoda): generic _request + vehicle control methods on the client --- src/services/skoda/skodaClient.js | 33 ++++++++++--- tests/skoda_client_control.test.js | 79 ++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 tests/skoda_client_control.test.js diff --git a/src/services/skoda/skodaClient.js b/src/services/skoda/skodaClient.js index b0576c46..4262cee7 100644 --- a/src/services/skoda/skodaClient.js +++ b/src/services/skoda/skodaClient.js @@ -20,11 +20,11 @@ class SkodaClient { this.fetchImpl = fetchImpl; } - async _get(path, { retried = false } = {}) { + async _request(method, path, body, { retried = false } = {}) { const session = this.getSession(); - const res = await this.fetchImpl(`${API_BASE}${path}`, { - headers: { authorization: `Bearer ${session.accessToken}`, accept: 'application/json' }, - }); + const opts = { method, headers: { authorization: `Bearer ${session.accessToken}`, accept: 'application/json' } }; + if (body !== undefined) { opts.headers['content-type'] = 'application/json'; opts.body = JSON.stringify(body); } + const res = await this.fetchImpl(`${API_BASE}${path}`, opts); if (res.status === 401 && !retried) { let tokens; try { @@ -34,12 +34,16 @@ class SkodaClient { throw new SkodaApiError('token refresh failed', 'SKODA_UNAUTHORIZED', 401); } this.saveSession(tokens); - return this._get(path, { retried: true }); + return this._request(method, path, body, { retried: true }); } if (res.status === 401) throw new SkodaApiError('unauthorized', 'SKODA_UNAUTHORIZED', 401); if (res.status === 429) throw new SkodaApiError('rate limited', 'SKODA_RATE_LIMITED', 429); if (res.status >= 400) throw new SkodaApiError(`api error ${res.status} for ${path}`, 'SKODA_API_ERROR', res.status); - return res.json(); + return res; + } + + async _get(path) { + return (await this._request('GET', path)).json(); } garage() { return this._get('/api/v2/garage?connectivityGenerations=MOD1&connectivityGenerations=MOD2&connectivityGenerations=MOD3&connectivityGenerations=MOD4'); } @@ -86,6 +90,23 @@ class SkodaClient { } return { parts, state: normalizeVehicleState(parts) }; } + + startAc(vin, temp) { + return this._request('POST', `/api/v2/air-conditioning/${vin}/start`, + { heaterSource: 'ELECTRIC', targetTemperature: { temperatureValue: Math.round(temp), 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' }); + } + 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`); } + startCharging(vin) { return this._request('POST', `/api/v1/charging/${vin}/start`); } + stopCharging(vin) { return this._request('POST', `/api/v1/charging/${vin}/stop`); } + 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 }); } } const YES = (v) => (v == null ? null : String(v).toUpperCase() === 'YES'); diff --git a/tests/skoda_client_control.test.js b/tests/skoda_client_control.test.js new file mode 100644 index 00000000..7b49c163 --- /dev/null +++ b/tests/skoda_client_control.test.js @@ -0,0 +1,79 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { SkodaClient, SkodaApiError } = require('../src/services/skoda/skodaClient'); +const { API_BASE } = require('../src/services/skoda/skodaAuth'); + +function okRes(status = 200) { + return { status, ok: status < 400, headers: new Headers(), json: async () => ({}), text: async () => '' }; +} +function makeClient(routes, { session = { accessToken: 'AT', refreshToken: 'RT' } } = {}) { + const calls = []; + const fetchImpl = async (url, opts = {}) => { + calls.push({ url, method: opts.method, body: opts.body, auth: (opts.headers || {}).authorization, ct: (opts.headers || {})['content-type'] }); + 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: () => session, saveSession: () => {}, fetchImpl }), calls }; +} + +test('startAc POSTs the correct path and body with bearer + content-type', async () => { + const { client, calls } = makeClient([['/air-conditioning/V/start', okRes(202)]]); + await client.startAc('V', 21.4); + const c = calls[0]; + assert.equal(c.method, 'POST'); + assert.match(c.url, new RegExp(API_BASE.replace(/[.]/g, '\\.') + '/api/v2/air-conditioning/V/start$')); + assert.equal(c.auth, 'Bearer AT'); + 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.unitInCar, 'CELSIUS'); +}); + +test('stopAc and window heating POST with no body', async () => { + const { client, calls } = makeClient([ + ['/air-conditioning/V/stop', okRes()], + ['/start-window-heating', okRes()], + ]); + await client.stopAc('V'); + await client.startWindowHeating('V'); + assert.equal(calls[0].method, 'POST'); + assert.equal(calls[0].body, undefined); + assert.match(calls[1].url, /\/api\/v2\/air-conditioning\/V\/start-window-heating$/); +}); + +test('setChargeLimit uses PUT with targetSOCInPercent', async () => { + const { client, calls } = makeClient([['/set-charge-limit', okRes()]]); + await client.setChargeLimit('V', 80); + assert.equal(calls[0].method, 'PUT'); + assert.match(calls[0].url, /\/api\/v1\/charging\/V\/set-charge-limit$/); + assert.equal(JSON.parse(calls[0].body).targetSOCInPercent, 80); +}); + +test('lock/unlock POST currentSpin to vehicle-access', async () => { + const { client, calls } = makeClient([['/vehicle-access/V/unlock', okRes()]]); + await client.unlock('V', '1234'); + assert.match(calls[0].url, /\/api\/v1\/vehicle-access\/V\/unlock$/); + assert.equal(JSON.parse(calls[0].body).currentSpin, '1234'); +}); + +test('control 401 refreshes once then retries', async () => { + let n = 0; + const { client } = makeClient([ + ['/charging/V/start', () => (n++ === 0 ? okRes(401) : okRes())], + ['/authentication/refresh-token', { status: 200, ok: true, headers: new Headers(), json: async () => ({ accessToken: 'AT2', refreshToken: 'RT2', idToken: 'ID2' }) }], + ]); + await client.startCharging('V'); // must not throw + assert.equal(n, 2); +}); + +test('control 429 maps to SKODA_RATE_LIMITED', async () => { + const { client } = makeClient([['/charging/V/stop', okRes(429)]]); + await assert.rejects(client.stopCharging('V'), (e) => e.code === 'SKODA_RATE_LIMITED'); +}); + +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); +}); From fbc8cfa4301428e92d3bdbc28eca5842b9f7474c Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:10:00 +0200 Subject: [PATCH 02/11] feat(skoda): encrypted S-PIN storage per account (migration V67) --- src/db/migrationList.js | 6 +++++ src/services/skoda/skodaAccounts.js | 17 ++++++++++-- tests/skoda_spin.test.js | 41 +++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 tests/skoda_spin.test.js diff --git a/src/db/migrationList.js b/src/db/migrationList.js index 5a6d95c2..235a8f05 100644 --- a/src/db/migrationList.js +++ b/src/db/migrationList.js @@ -1213,6 +1213,12 @@ const migrations = [ CREATE INDEX IF NOT EXISTS idx_skoda_owners_user ON skoda_vehicle_owners(user_id);`, detect: (db) => tableExists(db, 'skoda_accounts'), }, + { + version: 67, + name: 'skoda_account_spin', + sql: `ALTER TABLE skoda_accounts ADD COLUMN spin_enc TEXT;`, + detect: (db) => hasColumn(db, 'skoda_accounts', 'spin_enc'), + }, ]; module.exports = { migrations }; diff --git a/src/services/skoda/skodaAccounts.js b/src/services/skoda/skodaAccounts.js index 84e9ec59..20d9457a 100644 --- a/src/services/skoda/skodaAccounts.js +++ b/src/services/skoda/skodaAccounts.js @@ -30,11 +30,12 @@ function createAccount({ email, password }) { } function listAccounts() { - return getDb().prepare('SELECT id, email, status, status_detail, next_retry_at, updated_at, password_enc FROM skoda_accounts ORDER BY id').all() + return getDb().prepare('SELECT id, email, status, status_detail, next_retry_at, updated_at, password_enc, spin_enc FROM skoda_accounts ORDER BY id').all() .map((r) => ({ id: r.id, email: r.email, status: r.status, status_detail: r.status_detail, next_retry_at: r.next_retry_at, updated_at: r.updated_at, has_credentials: Boolean(r.password_enc), + has_spin: Boolean(r.spin_enc), })); } @@ -77,4 +78,16 @@ function removeAccount(id) { tx(id); } -module.exports = { createAccount, listAccounts, getAccountWithSecrets, updatePassword, saveSession, setStatus, removeAccount }; +function setSpin(id, spin) { + if (!/^[0-9]{4,10}$/.test(String(spin || ''))) throw err('spin must be 4-10 digits', 'SKODA_VALIDATION'); + const info = getDb().prepare("UPDATE skoda_accounts SET spin_enc = ?, updated_at = datetime('now') WHERE id = ?") + .run(encrypt(String(spin)), id); + if (!info.changes) throw err('account not found', 'SKODA_ACCOUNT_NOT_FOUND'); +} + +function getSpin(id) { + const r = getDb().prepare('SELECT spin_enc FROM skoda_accounts WHERE id = ?').get(id); + return r && r.spin_enc ? decrypt(r.spin_enc) : null; +} + +module.exports = { createAccount, listAccounts, getAccountWithSecrets, updatePassword, saveSession, setStatus, removeAccount, setSpin, getSpin }; diff --git a/tests/skoda_spin.test.js b/tests/skoda_spin.test.js new file mode 100644 index 00000000..c1f917cd --- /dev/null +++ b/tests/skoda_spin.test.js @@ -0,0 +1,41 @@ +'use strict'; +const { test, before, after, beforeEach } = 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 accounts; let getDb; + +before(async () => { await setup(); accounts = require('../src/services/skoda/skodaAccounts'); ({ getDb } = require('../src/db/connection')); }); +after(async () => { await teardown(); }); +beforeEach(() => { for (const a of accounts.listAccounts()) accounts.removeAccount(a.id); }); + +test('spin_enc column exists on skoda_accounts', () => { + const cols = getDb().prepare('PRAGMA table_info(skoda_accounts)').all().map((c) => c.name); + assert.ok(cols.includes('spin_enc')); +}); + +test('setSpin encrypts at rest, getSpin roundtrips, has_spin reflects it', () => { + const acc = accounts.createAccount({ email: 'a@b.c', password: 'pw' }); + assert.equal(accounts.listAccounts()[0].has_spin, false); + accounts.setSpin(acc.id, '1234'); + const row = getDb().prepare('SELECT spin_enc FROM skoda_accounts WHERE id = ?').get(acc.id); + assert.notEqual(row.spin_enc, '1234'); + assert.match(row.spin_enc, /^[0-9a-f]{24}:[0-9a-f]{32}:/); + assert.equal(accounts.getSpin(acc.id), '1234'); + const listed = accounts.listAccounts()[0]; + assert.equal(listed.has_spin, true); + assert.equal('spin_enc' in listed, false); + assert.equal('spin' in listed, false); +}); + +test('setSpin rejects non-numeric or wrong-length', () => { + const acc = accounts.createAccount({ email: 'a@b.c', password: 'pw' }); + assert.throws(() => accounts.setSpin(acc.id, 'abcd'), (e) => e.code === 'SKODA_VALIDATION'); + assert.throws(() => accounts.setSpin(acc.id, '12'), (e) => e.code === 'SKODA_VALIDATION'); +}); + +test('getSpin returns null when unset', () => { + const acc = accounts.createAccount({ email: 'a@b.c', password: 'pw' }); + assert.equal(accounts.getSpin(acc.id), null); +}); From 8793ab18149a6e16431a1483d5680da4b3a5fd6c Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:13:39 +0200 Subject: [PATCH 03/11] feat(skoda): command orchestration with validation, S-PIN gate, lock rate-limit --- src/services/skoda/index.js | 22 +++++--- src/services/skoda/skodaControl.js | 84 +++++++++++++++++++++++++++ tests/skoda_control.test.js | 91 ++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 src/services/skoda/skodaControl.js create mode 100644 tests/skoda_control.test.js diff --git a/src/services/skoda/index.js b/src/services/skoda/index.js index 5c93e9e2..bfbae57a 100644 --- a/src/services/skoda/index.js +++ b/src/services/skoda/index.js @@ -20,6 +20,7 @@ let lastSyncAt = null; // ponytail: both maps grow one entry per vehicle/account ever touched — fine // for a two-car household, add cleanup if the fleet ever grows. const refreshCooldown = new Map(); // vehicleId -> ts +const cmdRefreshCooldown = new Map(); // vehicleId -> ts (nach Kommando, 30s-Fenster) const accountLocks = new Map(); // accountId -> promise chain tail // Serializes poller, manual refresh and account removal per account — @@ -125,19 +126,23 @@ async function syncAll({ fetchImpl, ignoreRetryAt = false } = {}) { } } -async function refreshVehicle(vehicleId, { fetchImpl } = {}) { - const last = refreshCooldown.get(vehicleId) || 0; - if (Date.now() - last < REFRESH_COOLDOWN_MS) { - const e = new Error('refresh cooldown active'); - e.code = 'SKODA_REFRESH_COOLDOWN'; - throw e; +async function refreshVehicle(vehicleId, { fetchImpl, afterCommand = false } = {}) { + const map = afterCommand ? cmdRefreshCooldown : refreshCooldown; + const cooldown = afterCommand ? 30000 : REFRESH_COOLDOWN_MS; + const last = map.get(vehicleId) || 0; + if (Date.now() - last < cooldown) { + const e = new Error('refresh cooldown active'); e.code = 'SKODA_REFRESH_COOLDOWN'; throw e; } const accountId = vehicles.accountIdOf(vehicleId); if (!accountId) { const e = new Error('vehicle not found'); e.code = 'SKODA_VEHICLE_NOT_FOUND'; throw e; } - refreshCooldown.set(vehicleId, Date.now()); + map.set(vehicleId, Date.now()); return syncAccount(accountId, { fetchImpl }); } +function clientForAccount(accountId, fetchImpl) { + return clientFor({ id: accountId }, fetchImpl); // clientFor liest nur account.id (getSession/saveSession je Account) +} + function removeAccount(accountId) { // Wait for any in-flight sync of this account before deleting, otherwise the // sync re-inserts vehicle rows for an account that no longer exists. @@ -175,9 +180,10 @@ function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } -function _resetForTest() { stopPolling(); refreshCooldown.clear(); accountLocks.clear(); pollRunning = false; lastSyncAt = null; } +function _resetForTest() { stopPolling(); refreshCooldown.clear(); cmdRefreshCooldown.clear(); accountLocks.clear(); pollRunning = false; lastSyncAt = null; } module.exports = { syncAccount, syncAll, refreshVehicle, removeAccount, getStatus, getVehicleImage, startPolling, stopPolling, pollTick, pollIntervalMs, _resetForTest, + clientForAccount, withAccountLock, }; diff --git a/src/services/skoda/skodaControl.js b/src/services/skoda/skodaControl.js new file mode 100644 index 00000000..17217837 --- /dev/null +++ b/src/services/skoda/skodaControl.js @@ -0,0 +1,84 @@ +'use strict'; + +const accounts = require('./skodaAccounts'); +const vehicles = require('./skodaVehicles'); +const skoda = require('./index'); + +const TEMP_MIN = 16, TEMP_MAX = 30; +const CHARGE_STEPS = [50, 60, 70, 80, 90, 100]; +const LOCK_LIMIT = 5, LOCK_WINDOW_MS = 15 * 60 * 1000; + +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; } + +function reqTemp(a) { + const t = num(a && a.temp); + if (!Number.isFinite(t) || t < TEMP_MIN || t > TEMP_MAX) throw err('temp out of range', 'SKODA_VALIDATION'); + return { temp: t }; +} + +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) }, + ac_temp: { needsSpin: false, validate: reqTemp, run: (c, vin, a) => c.setAcTemp(vin, a.temp) }, + window_heat_start: { needsSpin: false, validate: () => ({}), run: (c, vin) => c.startWindowHeating(vin) }, + window_heat_stop: { needsSpin: false, validate: () => ({}), run: (c, vin) => c.stopWindowHeating(vin) }, + charge_start: { needsSpin: false, validate: () => ({}), run: (c, vin) => c.startCharging(vin) }, + charge_stop: { needsSpin: false, validate: () => ({}), run: (c, vin) => c.stopCharging(vin) }, + 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) }, +}; + +// ponytail: grows one entry per account ever touched — same trade-off as +// index.js refreshCooldown/accountLocks; fine at household scale, prozesslokal. +const lockAttempts = new Map(); // accountId -> [timestamps] +function checkLockRate(accountId) { + const now = Date.now(); + const arr = (lockAttempts.get(accountId) || []).filter((t) => now - t < LOCK_WINDOW_MS); + if (arr.length >= LOCK_LIMIT) throw err('too many lock/unlock attempts', 'SKODA_COMMAND_RATE_LIMIT'); + arr.push(now); + lockAttempts.set(accountId, arr); +} + +async function runCommand(vehicleId, action, args, { fetchImpl } = {}) { + // hasOwnProperty-Guard: kein Prototype-Key (constructor/…) als Action. + const cmd = Object.prototype.hasOwnProperty.call(COMMANDS, action) ? COMMANDS[action] : null; + if (!cmd) throw err(`unknown command ${action}`, 'SKODA_UNKNOWN_COMMAND'); + const normArgs = cmd.validate(args || {}); + + 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'); + + // Ohne aktive Session (neues Konto vor erstem Poll, oder login_failed/error mit + // genullter Session): typisierter 409 statt untypisiertem TypeError im Client. + 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'); + } + + let spin = null; + if (cmd.needsSpin) { + spin = accounts.getSpin(accountId); + if (!spin) throw err('S-PIN not set for this account', 'SKODA_SPIN_REQUIRED'); + checkLockRate(accountId); // count BEFORE the cloud call — a failed PIN still counts + } + + // Unter der Konto-Lock (wie der Sync): serialisiert Command vs. Poller-Sync + // → kein Session-Refresh-Race mit single-use Refresh-Token. + await skoda.withAccountLock(accountId, async () => { + const client = skoda.clientForAccount(accountId, fetchImpl); + await cmd.run(client, vin, normArgs, spin); + }); + + // command-triggered refresh in its own 30s window (never blocks the response) + skoda.refreshVehicle(vehicleId, { afterCommand: true }).catch(() => {}); + return { ok: true }; +} + +function _resetForTest() { lockAttempts.clear(); } + +module.exports = { COMMANDS, runCommand, _resetForTest }; diff --git a/tests/skoda_control.test.js b/tests/skoda_control.test.js new file mode 100644 index 00000000..57ec5647 --- /dev/null +++ b/tests/skoda_control.test.js @@ -0,0 +1,91 @@ +'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 control, accounts, skoda, getDb, vehId, accId; + +function apiFetch(spy) { + return async (url, opts = {}) => { + spy.push({ url, method: opts.method, body: opts.body }); + return { status: 202, ok: true, headers: new Headers(), json: async () => ({}), text: async () => '' }; + }; +} + +before(async () => { + await setup(); + control = require('../src/services/skoda/skodaControl'); + accounts = require('../src/services/skoda/skodaAccounts'); + skoda = require('../src/services/skoda'); + ({ getDb } = require('../src/db/connection')); +}); +after(async () => { skoda.stopPolling(); await teardown(); }); +beforeEach(() => { + control._resetForTest(); + for (const a of accounts.listAccounts()) accounts.removeAccount(a.id); + const acc = accounts.createAccount({ email: 'c@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 (?, 'VINCTL', 'Elroq', '{}', datetime('now'))").run(acc.id); + vehId = getDb().prepare("SELECT id FROM skoda_vehicles WHERE vin='VINCTL'").get().id; + mock.method(skoda, 'refreshVehicle', async () => ({ ok: true })); // don't hit cloud on the post-command refresh +}); + +test('unknown action is rejected', async () => { + await assert.rejects(control.runCommand(vehId, 'explode', {}), (e) => e.code === 'SKODA_UNKNOWN_COMMAND'); +}); + +test('prototype keys are not treated as commands', async () => { + await assert.rejects(control.runCommand(vehId, 'constructor', {}), (e) => e.code === 'SKODA_UNKNOWN_COMMAND'); +}); + +test('command on an account without a session is rejected with SKODA_NO_SESSION', async () => { + accounts.saveSession(accId, null); // drop the session + const spy = []; + await assert.rejects(control.runCommand(vehId, 'ac_stop', {}, { fetchImpl: apiFetch(spy) }), (e) => e.code === 'SKODA_NO_SESSION'); + assert.equal(spy.length, 0); +}); + +test('ac_start without a temperature is rejected (NaN guard)', async () => { + const spy = []; + await assert.rejects(control.runCommand(vehId, 'ac_start', {}, { fetchImpl: apiFetch(spy) }), (e) => e.code === 'SKODA_VALIDATION'); + assert.equal(spy.length, 0); +}); + +test('ac_start validates temperature range', async () => { + const spy = []; + await assert.rejects(control.runCommand(vehId, 'ac_start', { temp: 40 }, { fetchImpl: apiFetch(spy) }), (e) => e.code === 'SKODA_VALIDATION'); + assert.equal(spy.length, 0); // never reached the cloud +}); + +test('charge_limit only accepts allowed steps', async () => { + const spy = []; + await assert.rejects(control.runCommand(vehId, 'charge_limit', { limit: 55 }, { fetchImpl: apiFetch(spy) }), (e) => e.code === 'SKODA_VALIDATION'); + const spy2 = []; + await control.runCommand(vehId, 'charge_limit', { limit: 80 }, { fetchImpl: apiFetch(spy2) }); + assert.equal(JSON.parse(spy2[0].body).targetSOCInPercent, 80); +}); + +test('ac_start reaches the cloud with rounded temp', async () => { + const spy = []; + 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); +}); + +test('lock without a set S-PIN is rejected before any cloud call', async () => { + const spy = []; + await assert.rejects(control.runCommand(vehId, 'lock', {}, { fetchImpl: apiFetch(spy) }), (e) => e.code === 'SKODA_SPIN_REQUIRED'); + assert.equal(spy.length, 0); +}); + +test('unlock uses the stored S-PIN and rate-limits after 5 attempts', async () => { + accounts.setSpin(accId, '4321'); + const spy = []; + for (let i = 0; i < 5; i++) await control.runCommand(vehId, 'unlock', {}, { fetchImpl: apiFetch(spy) }); + assert.equal(JSON.parse(spy[0].body).currentSpin, '4321'); + 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 +}); From c78e3c5082b7321fce31ae59862625d2e7a0fabd Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:17:59 +0200 Subject: [PATCH 04/11] feat(skoda): admin command + set-spin endpoints --- src/routes/api/skoda.js | 15 ++++++++ tests/skoda_command_api.test.js | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/skoda_command_api.test.js diff --git a/src/routes/api/skoda.js b/src/routes/api/skoda.js index e817b2bc..fde1b975 100644 --- a/src/routes/api/skoda.js +++ b/src/routes/api/skoda.js @@ -7,6 +7,7 @@ const skoda = require('../../services/skoda'); const accounts = require('../../services/skoda/skodaAccounts'); const owners = require('../../services/skoda/skodaOwners'); const settings = require('../../services/settings'); +const control = require('../../services/skoda/skodaControl'); const router = Router(); @@ -22,9 +23,13 @@ router.use(requireFeature('skoda_integration')); const STATUS_BY_CODE = { SKODA_VALIDATION: 400, SKODA_OWNER_UNKNOWN_USER: 400, + SKODA_UNKNOWN_COMMAND: 400, SKODA_ACCOUNT_EXISTS: 409, + SKODA_SPIN_REQUIRED: 409, + SKODA_NO_SESSION: 409, SKODA_REFRESH_COOLDOWN: 429, SKODA_RATE_LIMITED: 429, + SKODA_COMMAND_RATE_LIMIT: 429, SKODA_VEHICLE_NOT_FOUND: 404, SKODA_ACCOUNT_NOT_FOUND: 404, }; @@ -96,4 +101,14 @@ router.put('/settings', wrap(async (req, res) => { res.json({ ok: true }); })); +router.post('/vehicles/:id/command', wrap(async (req, res) => { + await control.runCommand(Number(req.params.id), req.body.action, req.body.args || {}); + res.json({ ok: true }); +})); + +router.put('/accounts/:id/spin', wrap(async (req, res) => { + accounts.setSpin(Number(req.params.id), req.body.spin); + res.json({ ok: true }); +})); + module.exports = router; diff --git a/tests/skoda_command_api.test.js b/tests/skoda_command_api.test.js new file mode 100644 index 00000000..cbe55caa --- /dev/null +++ b/tests/skoda_command_api.test.js @@ -0,0 +1,62 @@ +'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 ctx, accounts, control, getDb, vehId; + +before(async () => { + ctx = await setup(); + accounts = require('../src/services/skoda/skodaAccounts'); + control = require('../src/services/skoda/skodaControl'); + ({ getDb } = require('../src/db/connection')); +}); +after(async () => { await teardown(); }); +beforeEach(() => { + for (const a of accounts.listAccounts()) accounts.removeAccount(a.id); + const acc = accounts.createAccount({ email: 'a@x.y', password: 'pw' }); + getDb().prepare("INSERT INTO skoda_vehicles (account_id, vin, name, state_json, fetched_at) VALUES (?, 'VINA', 'Elroq', '{}', datetime('now'))").run(acc.id); + vehId = getDb().prepare("SELECT id FROM skoda_vehicles WHERE vin='VINA'").get().id; +}); + +test('POST command forwards to runCommand and returns ok', async () => { + const m = mock.method(control, 'runCommand', async () => ({ ok: true })); + const res = await ctx.agent.post(`/api/v1/skoda/vehicles/${vehId}/command`).set('x-csrf-token', ctx.csrfToken).send({ action: 'ac_start', args: { temp: 21 } }); + assert.equal(res.status, 200); + assert.equal(m.mock.calls[0].arguments[1], 'ac_start'); + m.mock.restore(); +}); + +test('unknown command maps to 400', async () => { + const err = Object.assign(new Error('x'), { code: 'SKODA_UNKNOWN_COMMAND' }); + 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: 'x' }); + assert.equal(res.status, 400); + assert.equal(res.body.code, 'SKODA_UNKNOWN_COMMAND'); + m.mock.restore(); +}); + +test('spin required maps to 409', async () => { + const err = Object.assign(new Error('x'), { code: 'SKODA_SPIN_REQUIRED' }); + 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: 'unlock' }); + assert.equal(res.status, 409); + 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' }); + assert.equal(res.status, 200); + assert.ok(!JSON.stringify(res.body).includes('1234')); + assert.equal(accounts.listAccounts()[0].has_spin, true); + const bad = await ctx.agent.put(`/api/v1/skoda/accounts/${accId}/spin`).set('x-csrf-token', ctx.csrfToken).send({ spin: 'ab' }); + assert.equal(bad.status, 400); +}); + +test('command requires admin session (unauth 401)', async () => { + const supertest = require('supertest'); + const res = await supertest(ctx.app).post(`/api/v1/skoda/vehicles/${vehId}/command`).send({ action: 'ac_stop' }); + assert.equal(res.status, 401); +}); From e75cb495a0c3a26b4f5083fe580e2fe9af99d8c6 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:25:11 +0200 Subject: [PATCH 05/11] feat(skoda): owner-gated login-required portal command endpoint --- src/routes/api/portal.js | 19 ++++++++++ tests/skoda_portal_control.test.js | 59 ++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 tests/skoda_portal_control.test.js diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js index 9840bdc3..1bd36722 100644 --- a/src/routes/api/portal.js +++ b/src/routes/api/portal.js @@ -18,6 +18,7 @@ const skoda = require('../../services/skoda'); 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 router = Router(); @@ -343,6 +344,24 @@ router.get('/skoda/vehicles/:id/image', (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 }; +router.post('/skoda/vehicles/:id/command', 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' }); + // Not logged in → 200 + reason (exakt wie POST /midea/:id/state), NICHT 401. + if (!req.portalLoggedIn) return res.json({ ok: true, data: null, reason: 'login_required' }); + const id = Number(req.params.id); + if (!skodaOwners.isOwner(id, req.session.userId)) return res.status(403).json({ ok: false, error: 'SKODA_NOT_OWNER' }); + await skodaControl.runCommand(id, req.body.action, req.body.args || {}); + res.json({ ok: true }); + } catch (err) { + const status = SKODA_CMD_STATUS[err.code] || 502; + res.status(status).json({ ok: false, error: err.code || 'command failed' }); + } +}); + function smarthomeUnavailable() { return !license.hasFeature('smarthome'); } diff --git a/tests/skoda_portal_control.test.js b/tests/skoda_portal_control.test.js new file mode 100644 index 00000000..d786dea5 --- /dev/null +++ b/tests/skoda_portal_control.test.js @@ -0,0 +1,59 @@ +'use strict'; +const { test, beforeEach, afterEach, 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 supertest = require('supertest'); +const { setup, teardown, getAgent } = require('./helpers/setup'); +const config = require('../config/default'); +const license = require('../src/services/license'); +const control = require('../src/services/skoda/skodaControl'); +const HOME_HOST = `home.${config.dns.domain}`; +let app, accounts, owners, getDb, adminId, foreignId, mineId, foreignVehId; + +beforeEach(async () => { + await setup(); + app = require('../src/app').createApp(); + accounts = require('../src/services/skoda/skodaAccounts'); + owners = require('../src/services/skoda/skodaOwners'); + ({ getDb } = require('../src/db/connection')); + license.hasFeature = () => true; + adminId = getDb().prepare("SELECT id FROM users WHERE role='admin'").get().id; + foreignId = getDb().prepare("INSERT INTO users (username, password_hash, role) VALUES ('frau','x','user')").run().lastInsertRowid; + const acc = accounts.createAccount({ email: 'a@x.y', password: 'pw' }); + getDb().prepare("INSERT INTO skoda_vehicles (account_id, vin, name, state_json, fetched_at) VALUES (?, 'VINM', 'Elroq', '{}', datetime('now'))").run(acc.id); + mineId = getDb().prepare("SELECT id FROM skoda_vehicles WHERE vin='VINM'").get().id; + owners.setOwners(mineId, [adminId]); + getDb().prepare("INSERT INTO skoda_vehicles (account_id, vin, name, state_json, fetched_at) VALUES (?, 'VINF', 'Enyaq', '{}', datetime('now'))").run(acc.id); + foreignVehId = getDb().prepare("SELECT id FROM skoda_vehicles WHERE vin='VINF'").get().id; + owners.setOwners(foreignVehId, [foreignId]); +}); +afterEach(async () => { await teardown(); }); + +test('logged-in owner can command own vehicle', async () => { + const m = mock.method(control, 'runCommand', async () => ({ ok: true })); + const agent = await getAgent(); + const res = await agent.post(`/api/v1/portal/skoda/vehicles/${mineId}/command`).set('Host', HOME_HOST).send({ action: 'ac_start', args: { temp: 21 } }); + assert.equal(res.status, 200); + assert.equal(m.mock.callCount(), 1); + m.mock.restore(); +}); + +test('unauthenticated command returns 200 + reason login_required (no cloud call, Midea-parity)', 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: 'ac_stop' }); + assert.equal(res.status, 200); + assert.equal(res.body.reason, 'login_required'); + assert.equal(m.mock.callCount(), 0); + m.mock.restore(); +}); + +test('commanding a foreign vehicle is 403 SKODA_NOT_OWNER', 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: 'ac_stop' }); + assert.equal(res.status, 403); + assert.equal(res.body.error, 'SKODA_NOT_OWNER'); + assert.equal(m.mock.callCount(), 0); + m.mock.restore(); +}); From a0b5069b4b0427c4f3fca2fc33f9cb128c297110 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:26:12 +0200 Subject: [PATCH 06/11] fix(skoda): add spin_enc to schema test expected columns (V67 fallout) --- tests/skoda_schema.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/skoda_schema.test.js b/tests/skoda_schema.test.js index b01d0a75..02c20097 100644 --- a/tests/skoda_schema.test.js +++ b/tests/skoda_schema.test.js @@ -14,7 +14,7 @@ test('skoda tables exist with expected columns', () => { const cols = (t) => db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name); assert.deepEqual( cols('skoda_accounts').sort(), - ['backoff_min', 'created_at', 'email', 'id', 'next_retry_at', 'password_enc', 'session_enc', 'status', 'status_detail', 'updated_at'] + ['backoff_min', 'created_at', 'email', 'id', 'next_retry_at', 'password_enc', 'session_enc', 'spin_enc', 'status', 'status_detail', 'updated_at'] ); assert.deepEqual( cols('skoda_vehicles').sort(), From 634a4f8d5ded572019aaa348f5cfdd94e4cc6454 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:30:49 +0200 Subject: [PATCH 07/11] feat(skoda): admin control buttons + S-PIN field --- public/js/skoda.js | 56 +++++++++++++++++++++++++++++++ src/i18n/de.json | 15 +++++++++ src/i18n/en.json | 15 +++++++++ templates/aurora/layout.njk | 17 +++++++++- templates/aurora/pages/skoda.njk | 18 ++++++++++ templates/default/layout.njk | 17 +++++++++- templates/default/pages/skoda.njk | 18 ++++++++++ templates/pro/layout.njk | 17 +++++++++- templates/pro/pages/skoda.njk | 18 ++++++++++ tests/skoda_control_i18n.test.js | 26 ++++++++++++++ 10 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 tests/skoda_control_i18n.test.js diff --git a/public/js/skoda.js b/public/js/skoda.js index ec0776ad..1ba94c4f 100644 --- a/public/js/skoda.js +++ b/public/js/skoda.js @@ -26,6 +26,7 @@ const hideModal = (id) => { el(id).style.display = 'none'; }; let current = { accounts: [], vehicles: [] }; let ownerVehicleId = null; + let spinAccountId = null; function accountRow(a) { const statusKey = `skoda.accounts.status.${a.status}`; @@ -36,6 +37,7 @@ ${esc(a.email)} ${esc(statusText)} + `; } @@ -59,9 +61,37 @@ +
+ + + + + + + + + + + +
`; } + async function command(vehicleId, action, args, el) { + if (action === 'unlock' && !confirm(T('skoda.cmd.confirm_unlock'))) return; + if (el && el.disabled) return; // already in flight → no command storm + const restore = el ? el.textContent : null; + if (el) { el.disabled = true; el.textContent = T('skoda.cmd.running'); } + try { + await api('POST', `/vehicles/${vehicleId}/command`, { action, args: args || {} }); + setTimeout(load, 3000); // let the 30s post-command refresh begin; reload state + } catch (e) { + alert(e.code === 'SKODA_SPIN_REQUIRED' ? T('skoda.cmd.spin') + '?' : (e.message || T('skoda.cmd.failed'))); + } finally { + if (el) setTimeout(() => { el.disabled = false; if (restore != null) el.textContent = restore; }, 3000); + } + } + async function load() { current = await api('GET', ''); el('skoda-poll-interval').value = current.poll_interval_min; @@ -96,6 +126,19 @@ await load(); } } + if (btn.dataset.action === 'spin') { + spinAccountId = id; + el('skoda-spin-input').value = ''; + showModal('skoda-spin-modal'); + } + } catch (e) { fail(e); } + }); + + el('skoda-spin-cancel').addEventListener('click', () => hideModal('skoda-spin-modal')); + el('skoda-spin-save').addEventListener('click', async () => { + try { + await api('PUT', `/accounts/${spinAccountId}/spin`, { spin: el('skoda-spin-input').value }); + hideModal('skoda-spin-modal'); } catch (e) { fail(e); } }); @@ -115,6 +158,19 @@ } catch (e) { fail(e); } }); + el('skoda-vehicles').addEventListener('click', (ev) => { + const b = ev.target.closest('button[data-cmd]'); if (!b) return; + const box = b.closest('.skoda-cmds'); const veh = Number(box.dataset.veh); + let args = {}; + if (b.dataset.temp) args = { temp: Number(b.dataset.temp) }; + else if (b.dataset.cmd === 'ac_temp') { const inp = box.querySelector('[data-temp-input]'); args = { temp: Number(inp && inp.value) }; } + command(veh, b.dataset.cmd, args, b); + }); + el('skoda-vehicles').addEventListener('change', (ev) => { + 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); + }); + el('skoda-owner-cancel').addEventListener('click', () => hideModal('skoda-owner-modal')); el('skoda-owner-save').addEventListener('click', async () => { const ids = [...el('skoda-owner-list').querySelectorAll('input:checked')].map((i) => Number(i.value)); diff --git a/src/i18n/de.json b/src/i18n/de.json index ba3ae93d..75bac298 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -2226,6 +2226,21 @@ "skoda.owner.save": "Speichern", "skoda.error.cooldown": "Bitte warten — Aktualisierung erst in ein paar Minuten wieder möglich.", "skoda.error.generic": "Aktion fehlgeschlagen", + "skoda.cmd.ac_on": "Klima an", + "skoda.cmd.ac_off": "Klima aus", + "skoda.cmd.charge_on": "Laden", + "skoda.cmd.charge_off": "Laden stoppen", + "skoda.cmd.window_heat_on": "Scheibenheizung an", + "skoda.cmd.window_heat_off": "Scheibenheizung aus", + "skoda.cmd.lock": "Verriegeln", + "skoda.cmd.unlock": "Entriegeln", + "skoda.cmd.set_temp": "Zieltemperatur", + "skoda.cmd.set_limit": "Ladelimit", + "skoda.cmd.confirm_unlock": "Fahrzeug wirklich entriegeln?", + "skoda.cmd.running": "läuft…", + "skoda.cmd.failed": "Befehl fehlgeschlagen", + "skoda.cmd.spin": "S-PIN", + "skoda.cmd.spin_set": "S-PIN speichern", "portal.midea.fan": "Lüfter", "portal.midea.fan_auto": "Auto", "portal.midea.fan_silent": "Silent", diff --git a/src/i18n/en.json b/src/i18n/en.json index 9f5baf4b..028a6f77 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2282,6 +2282,21 @@ "skoda.owner.save": "Save", "skoda.error.cooldown": "Please wait — refresh available again in a few minutes.", "skoda.error.generic": "Action failed", + "skoda.cmd.ac_on": "AC on", + "skoda.cmd.ac_off": "AC off", + "skoda.cmd.charge_on": "Charge", + "skoda.cmd.charge_off": "Stop charging", + "skoda.cmd.window_heat_on": "Window heating on", + "skoda.cmd.window_heat_off": "Window heating off", + "skoda.cmd.lock": "Lock", + "skoda.cmd.unlock": "Unlock", + "skoda.cmd.set_temp": "Target temp", + "skoda.cmd.set_limit": "Charge limit", + "skoda.cmd.confirm_unlock": "Really unlock the vehicle?", + "skoda.cmd.running": "running…", + "skoda.cmd.failed": "Command failed", + "skoda.cmd.spin": "S-PIN", + "skoda.cmd.spin_set": "Save S-PIN", "portal.midea.fan": "Fan", "portal.midea.fan_auto": "Auto", "portal.midea.fan_silent": "Silent", diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk index cfa62621..1c6cb60d 100644 --- a/templates/aurora/layout.njk +++ b/templates/aurora/layout.njk @@ -561,7 +561,22 @@ 'skoda.owner.save': {{ t('skoda.owner.save') | dump | safe }}, 'skoda.owner.title': {{ t('skoda.owner.title') | dump | safe }}, 'skoda.error.cooldown': {{ t('skoda.error.cooldown') | dump | safe }}, - 'skoda.error.generic': {{ t('skoda.error.generic') | dump | safe }} + 'skoda.error.generic': {{ t('skoda.error.generic') | dump | safe }}, + 'skoda.cmd.ac_on': {{ t('skoda.cmd.ac_on') | dump | safe }}, + 'skoda.cmd.ac_off': {{ t('skoda.cmd.ac_off') | dump | safe }}, + 'skoda.cmd.charge_on': {{ t('skoda.cmd.charge_on') | dump | safe }}, + 'skoda.cmd.charge_off': {{ t('skoda.cmd.charge_off') | dump | safe }}, + 'skoda.cmd.window_heat_on': {{ t('skoda.cmd.window_heat_on') | dump | safe }}, + 'skoda.cmd.window_heat_off': {{ t('skoda.cmd.window_heat_off') | dump | safe }}, + 'skoda.cmd.lock': {{ t('skoda.cmd.lock') | dump | safe }}, + 'skoda.cmd.unlock': {{ t('skoda.cmd.unlock') | dump | safe }}, + 'skoda.cmd.set_temp': {{ t('skoda.cmd.set_temp') | dump | safe }}, + 'skoda.cmd.set_limit': {{ t('skoda.cmd.set_limit') | dump | safe }}, + 'skoda.cmd.confirm_unlock': {{ t('skoda.cmd.confirm_unlock') | dump | safe }}, + 'skoda.cmd.running': {{ t('skoda.cmd.running') | dump | safe }}, + 'skoda.cmd.failed': {{ t('skoda.cmd.failed') | dump | safe }}, + 'skoda.cmd.spin': {{ t('skoda.cmd.spin') | dump | safe }}, + 'skoda.cmd.spin_set': {{ t('skoda.cmd.spin_set') | dump | safe }} } }; diff --git a/templates/aurora/pages/skoda.njk b/templates/aurora/pages/skoda.njk index df093f86..f9060701 100644 --- a/templates/aurora/pages/skoda.njk +++ b/templates/aurora/pages/skoda.njk @@ -39,6 +39,24 @@ + +