diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d5d143..03017cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ - Skoda Connect portal widget (TP2): logged-in portal users see their own vehicles (lock, battery/charging, climate, position with address, health/ maintenance) read-only, owner-gated, with reverse-geocoded location. +- Skoda Connect vehicle control (TP3): climate, charging, charge limit, window + heating and lock/unlock (S-PIN) from the admin page and the portal (logged-in + owners only), with command validation, S-PIN gate and lock rate-limiting. --- diff --git a/public/css/portal.css b/public/css/portal.css index f6524fb0..414f1fdd 100644 --- a/public/css/portal.css +++ b/public/css/portal.css @@ -472,6 +472,12 @@ body::before{ .sensor .v{font-family:var(--font-mono);font-size:19px;font-weight:700} .sensor .l{color:var(--faint);font-size:11px;margin-top:3px} .c-sh-msg{margin-top:10px;font-size:13px;color:var(--muted,#90a1b3)} +.skoda-cmds { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 12px; } +.skoda-cmds label { display: inline-flex; align-items: center; gap: 4px; font-size: .85em; color: var(--faint); } +.skoda-cmds button { padding: 5px 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-3); color: inherit; cursor: pointer; font-size: .85em; } +.skoda-cmds input[type="number"] { width: 58px; } +.skoda-cmds button.danger { background: color-mix(in srgb, var(--coral) 20%, transparent); border-color: var(--coral); color: var(--coral); } +.skoda-msg { color: var(--coral); margin-top: 8px; } /* ============================================================ REDUCED MOTION diff --git a/public/css/skoda.css b/public/css/skoda.css index d6fa1d0b..fa1b686e 100644 --- a/public/css/skoda.css +++ b/public/css/skoda.css @@ -17,3 +17,7 @@ .skoda-card-actions { display: flex; gap: 8px; } .skoda-owner-item { display: block; padding: 4px 0; } .skoda-page .btn-sm{padding:6px 11px;font-size:12.5px;border-radius:9px} /* app.css lacks .btn-sm */ +.skoda-cmds { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 12px; } +.skoda-cmds label { display: inline-flex; align-items: center; gap: 4px; font-size: .85em; } +.skoda-cmds input[type="number"] { width: 60px; } +.skoda-cmds .btn-danger, .skoda-cmds button.btn-danger { background: var(--danger, #da3633); color: #fff; border-color: var(--danger, #da3633); } diff --git a/public/js/portal.js b/public/js/portal.js index 329b0897..a802258d 100644 --- a/public/js/portal.js +++ b/public/js/portal.js @@ -884,7 +884,56 @@ return '' + escHtml(label) + ''; } - function renderSkodaCard(v) { + // ponytail: watchdog matches skodaCommand's own 3s settle path; 30s only + // guards against a fetch that never resolves (dropped connection etc). + function skodaMsg(text) { var m = document.getElementById('skodaMsg'); if (m) { m.textContent = text; m.style.display = 'block'; } } + function skodaCommand(vehicleId, action, args, el) { + if (action === 'unlock' && !window.confirm(PT.skodaCmdConfirmUnlock)) return; + if (el && el.disabled) return; // in-flight → no command storm + var isBtn = el && el.tagName === 'BUTTON'; + var restore = isBtn ? el.textContent : null; + var watchdog = null; + var reset = function () { + if (watchdog) { clearTimeout(watchdog); watchdog = null; } + if (el) { el.disabled = false; if (isBtn && restore != null) el.textContent = restore; } + }; + if (el) { el.disabled = true; if (isBtn) el.textContent = PT.skodaCmdRunning; } + watchdog = setTimeout(reset, 30000); + fetch('/api/v1/portal/skoda/vehicles/' + vehicleId + '/command', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: action, args: args || {} }), + }).then(function (r) { return r.json().catch(function () { return {}; }); }) + .then(function (body) { + // reason (login_required/unavailable) comes as data:null,reason WITH ok:true → also a failure + if (!body || !body.ok || body.reason) skodaMsg(PT.skodaCmdFailed); + else setTimeout(hydrateSkoda, 3000); + }) + .catch(function () { skodaMsg(PT.skodaCmdFailed); }) + .then(function () { clearTimeout(watchdog); setTimeout(reset, 3000); }); + } + + var _skodaCmdBound = false; + function bindSkodaCommands() { + if (_skodaCmdBound) return; _skodaCmdBound = true; + var el = skodaEl(); if (!el) return; + el.addEventListener('click', function (ev) { + var b = ev.target.closest('button[data-cmd]'); if (!b) return; + var box = b.closest('.skoda-cmds'); if (!box) return; + var args = {}; + if (b.dataset.cmd === 'ac_start' || b.dataset.cmd === 'ac_temp') { + var inp = box.querySelector('[data-temp-input]'); + var tv = Number(inp && inp.value); + args = { temp: (isFinite(tv) && tv) ? tv : 21 }; + } else if (b.dataset.temp) args = { temp: Number(b.dataset.temp) }; + skodaCommand(Number(box.dataset.veh), b.dataset.cmd, args, b); + }); + el.addEventListener('change', function (ev) { + 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); + }); + } + + function renderSkodaCard(v, loggedIn) { var s = v.state || {}; var ch = s.charging || {}, cl = s.climate || {}, hl = s.health || {}, mt = s.maintenance || {}, dt = s.detail || {}; var up = function (x) { return String(x || '').toUpperCase(); }; @@ -928,10 +977,24 @@ + (mt.dueInKm != null ? ' · ' + numOr(mt.dueInKm, ' km') : '') + '' + (mt.partner ? '
' + escHtml(PT.skodaPartner) + ': ' + escHtml(mt.partner) + '
' : '') + (hl.warnings && hl.warnings.length ? '
' + escHtml(PT.skodaWarnings) + ': ' + escHtml(hl.warnings.join(', ')) + '
' : '') - + ''; + + '' + + (loggedIn ? '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' : '') + + ''; } - function renderSkoda(vehicles) { + 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 @@ -943,7 +1006,7 @@ var od = oldCards[i].querySelector('details'); wasOpen[i] = !!(od && od.open); } - el.innerHTML = vehicles.map(renderSkodaCard).join(''); + el.innerHTML = vehicles.map(function (v) { return renderSkodaCard(v, loggedIn); }).join(''); var newCards = el.querySelectorAll('.skoda-card'); for (var j = 0; j < newCards.length; j++) { if (wasOpen[j]) { var nd = newCards[j].querySelector('details'); if (nd) nd.open = true; } @@ -954,7 +1017,8 @@ var card = document.querySelector('.c-skoda'); if (!card) return; fetch('/api/v1/portal/skoda').then(function (r) { return r.status === 404 ? null : r.json(); }).then(function (body) { if (!body || !body.ok || body.data === null) { card.style.display = 'none'; return; } - renderSkoda(body.data.vehicles); + renderSkoda(body.data.vehicles, body.data.loggedIn); + bindSkodaCommands(); startSkodaPoll(); // start polling only once we have data (mirrors hydrateMidea) }).catch(function () { card.style.display = 'none'; }); } @@ -967,7 +1031,7 @@ // Poll refresh: render on success, keep the last good cards on any // failure/null — a transient hiccup must never blank the whole section. fetch('/api/v1/portal/skoda').then(function (r) { return r.json(); }).then(function (body) { - if (body && body.ok && body.data) renderSkoda(body.data.vehicles); + if (body && body.ok && body.data) renderSkoda(body.data.vehicles, body.data.loggedIn); }).catch(function () {}); }, 120000); } diff --git a/public/js/skoda.js b/public/js/skoda.js index ec0776ad..a46f39af 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,41 @@ +
+ + + + + + + + + + + +
`; } + 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 + var isBtn = el && el.tagName === 'BUTTON'; + var restore = isBtn ? el.textContent : null; + var reset = function () { if (el) { el.disabled = false; if (isBtn && restore != null) el.textContent = restore; } }; + if (el) { el.disabled = true; if (isBtn) el.textContent = T('skoda.cmd.running'); } + var watchdog = setTimeout(reset, 30000); // hard fallback if the request never settles + 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 { + clearTimeout(watchdog); + setTimeout(reset, 3000); + } + } + async function load() { current = await api('GET', ''); el('skoda-poll-interval').value = current.poll_interval_min; @@ -96,6 +130,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 +162,22 @@ } 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.cmd === 'ac_start' || b.dataset.cmd === 'ac_temp') { + var inp = box.querySelector('[data-temp-input]'); + var tv = Number(inp && inp.value); + args = { temp: (isFinite(tv) && tv) ? tv : 21 }; + } else if (b.dataset.temp) args = { temp: Number(b.dataset.temp) }; + 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/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/i18n/de.json b/src/i18n/de.json index ba3ae93d..05fdf0ce 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", @@ -2260,6 +2275,19 @@ "portal.skoda.lights_on": "Licht an", "portal.skoda.climate_on": "An", "portal.skoda.climate_off": "Aus", + "portal.skoda.cmd_ac_on": "Klima an", + "portal.skoda.cmd_ac_off": "Klima aus", + "portal.skoda.cmd_set_temp": "Zieltemperatur", + "portal.skoda.cmd_charge_on": "Laden", + "portal.skoda.cmd_charge_off": "Laden stoppen", + "portal.skoda.cmd_charge_limit": "Ladelimit", + "portal.skoda.cmd_window_heat": "Scheibenheizung an", + "portal.skoda.cmd_window_heat_off": "Scheibenheizung aus", + "portal.skoda.cmd_lock": "Verriegeln", + "portal.skoda.cmd_unlock": "Entriegeln", + "portal.skoda.cmd_confirm_unlock": "Fahrzeug wirklich entriegeln?", + "portal.skoda.cmd_running": "läuft…", + "portal.skoda.cmd_failed": "Befehl fehlgeschlagen", "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 9f5baf4b..a9e43f77 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", @@ -2316,6 +2331,19 @@ "portal.skoda.lights_on": "Lights on", "portal.skoda.climate_on": "On", "portal.skoda.climate_off": "Off", + "portal.skoda.cmd_ac_on": "AC on", + "portal.skoda.cmd_ac_off": "AC off", + "portal.skoda.cmd_set_temp": "Target temp", + "portal.skoda.cmd_charge_on": "Charge", + "portal.skoda.cmd_charge_off": "Stop charging", + "portal.skoda.cmd_charge_limit": "Charge limit", + "portal.skoda.cmd_window_heat": "Window heating on", + "portal.skoda.cmd_window_heat_off": "Window heating off", + "portal.skoda.cmd_lock": "Lock", + "portal.skoda.cmd_unlock": "Unlock", + "portal.skoda.cmd_confirm_unlock": "Really unlock the vehicle?", + "portal.skoda.cmd_running": "running…", + "portal.skoda.cmd_failed": "Command failed", "portal.smarthome.title": "Smart Home", "portal.smarthome.power": "Power", "portal.smarthome.brightness": "Brightness", diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js index 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/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/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/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/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/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/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 @@ + +