diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2b5d4e..6db6554e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ ## [Unreleased] +### Added +- Skoda Connect integration (TP1): MySkoda account linking, vehicle sync with + state cache and rate-limit backoff, admin page `/skoda` with owner mapping + (feature flag `skoda_integration`). + ### Changed - Routes page (Aurora): flat table replaced by a card grid — group cards for service bundles/shared domains, slim single-route cards elsewhere; domainless L4 forwards get derived names (SSH, RDP, IPP, …); new EXTERNAL/INTERNAL badge + filter and a KPI strip (total/HTTP/forwards/external/disabled). diff --git a/public/css/skoda.css b/public/css/skoda.css new file mode 100644 index 00000000..d6fa1d0b --- /dev/null +++ b/public/css/skoda.css @@ -0,0 +1,19 @@ +.skoda-page h2 { margin: 24px 0 12px; } +.skoda-poll { display: inline-flex; align-items: center; gap: 8px; margin-right: 12px; font-size: 0.9em; } +.skoda-poll input { width: 72px; } +.skoda-account { display: flex; align-items: center; gap: 12px; padding: 8px 0; } +.skoda-account-email { min-width: 220px; } +.skoda-badge { padding: 2px 10px; border-radius: 999px; font-size: 0.8em; } +.skoda-badge-ok { background: rgba(46, 160, 67, 0.15); color: #2ea043; } +.skoda-badge-login_failed, .skoda-badge-error { background: rgba(218, 54, 51, 0.15); color: #da3633; } +.skoda-badge-rate_limited { background: rgba(210, 153, 34, 0.15); color: #d29922; } +.skoda-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; } +.skoda-card { border: 1px solid var(--border-color, rgba(128,128,128,0.25)); border-radius: 12px; padding: 16px; } +.skoda-card-img { max-width: 100%; height: 120px; object-fit: contain; display: block; margin: 0 auto 8px; } +.skoda-card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } +.skoda-vin { font-size: 0.75em; opacity: 0.6; } +.skoda-card-stats { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 10px 0; } +.skoda-card-meta { font-size: 0.8em; opacity: 0.7; margin-bottom: 10px; } +.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 */ diff --git a/public/js/skoda.js b/public/js/skoda.js new file mode 100644 index 00000000..ec0776ad --- /dev/null +++ b/public/js/skoda.js @@ -0,0 +1,130 @@ +(() => { + 'use strict'; + const GC = window.GC || {}; + const T = (k, params) => { + let s = (GC.t && GC.t[k]) || k; + for (const [p, v] of Object.entries(params || {})) s = s.replace(`{{${p}}}`, v); + return s; + }; + const headers = { 'Content-Type': 'application/json', 'x-csrf-token': GC.csrfToken }; + async function api(method, path, body) { + const res = await fetch('/api/v1/skoda' + path, { method, headers, body: body ? JSON.stringify(body) : undefined }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw Object.assign(new Error(json.error || res.statusText), { code: json.code }); + return json; + } + async function apiRoot(method, path, body) { + const res = await fetch('/api/v1' + path, { method, headers, body: body ? JSON.stringify(body) : undefined }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw Object.assign(new Error(json.error || res.statusText), { code: json.code }); + return json; + } + + const el = (id) => document.getElementById(id); + const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const showModal = (id) => { el(id).style.display = 'flex'; }; + const hideModal = (id) => { el(id).style.display = 'none'; }; + let current = { accounts: [], vehicles: [] }; + let ownerVehicleId = null; + + function accountRow(a) { + const statusKey = `skoda.accounts.status.${a.status}`; + const statusText = a.status === 'rate_limited' + ? T(statusKey, { time: a.next_retry_at ? new Date(a.next_retry_at).toLocaleTimeString() : '—' }) + : T(statusKey); + return `
+ ${esc(a.email)} + ${esc(statusText)} + + +
`; + } + + function vehicleCard(v) { + const s = v.state || {}; + const lock = s.locked === true ? T('skoda.vehicle.locked') : s.locked === false ? T('skoda.vehicle.unlocked') : '—'; + // sqlite datetime('now') is "YYYY-MM-DD HH:MM:SS" in UTC -> proper ISO first + const fetched = v.fetched_at ? T('skoda.vehicle.fetched', { time: new Date(v.fetched_at.replace(' ', 'T') + 'Z').toLocaleString() }) : '—'; + return `
+ ${v.has_image ? `` : ''} +
${esc(v.name || v.model || v.vin)}${esc(v.vin)}
+
+ ${T('skoda.vehicle.soc')}: ${s.soc ?? '—'}${s.soc != null ? '%' : ''} + ${T('skoda.vehicle.range')}: ${s.rangeKm ?? '—'}${s.rangeKm != null ? ' km' : ''} + ${lock} + ${T('skoda.vehicle.mileage')}: ${s.health && s.health.mileageKm != null ? s.health.mileageKm + ' km' : '—'} +
+
${esc(fetched)} · ${T('skoda.vehicle.owners')}: ${esc((v.owners || []).map((o) => o.username).join(', ') || '—')}
+
+ + +
+
`; + } + + async function load() { + current = await api('GET', ''); + el('skoda-poll-interval').value = current.poll_interval_min; + el('skoda-accounts').innerHTML = current.accounts.map(accountRow).join('') || ''; + el('skoda-vehicles').innerHTML = current.vehicles.map(vehicleCard).join('') || `

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

`; + } + + function fail(e) { alert(e.code === 'SKODA_REFRESH_COOLDOWN' ? T('skoda.error.cooldown') : (e.message || T('skoda.error.generic'))); } + + el('skoda-account-add-open').addEventListener('click', () => showModal('skoda-account-modal')); + el('skoda-acc-cancel').addEventListener('click', () => hideModal('skoda-account-modal')); + el('skoda-acc-save').addEventListener('click', async () => { + try { + const created = await api('POST', '/accounts', { email: el('skoda-acc-email').value, password: el('skoda-acc-password').value }); + api('POST', `/accounts/${created.account.id}/sync`).catch(() => {}); // fire and forget, status lands on the account row + hideModal('skoda-account-modal'); + el('skoda-acc-email').value = ''; el('skoda-acc-password').value = ''; + await load(); + } catch (e) { fail(e); } + }); + + el('skoda-accounts').addEventListener('click', async (ev) => { + const btn = ev.target.closest('button'); if (!btn) return; + const id = Number(btn.closest('.skoda-account').dataset.id); + try { + if (btn.dataset.action === 'remove') { await api('DELETE', `/accounts/${id}`); await load(); } + if (btn.dataset.action === 'password') { + const pw = prompt(T('skoda.accounts.password')); + if (pw) { + await api('PUT', `/accounts/${id}`, { password: pw }); + api('POST', `/accounts/${id}/sync`).catch(() => {}); + await load(); + } + } + } catch (e) { fail(e); } + }); + + el('skoda-vehicles').addEventListener('click', async (ev) => { + const btn = ev.target.closest('button'); if (!btn) return; + const id = Number(btn.closest('.skoda-card').dataset.id); + try { + if (btn.dataset.action === 'refresh') { await api('POST', `/vehicles/${id}/refresh`); await load(); } + if (btn.dataset.action === 'owners') { + ownerVehicleId = id; + const users = (await apiRoot('GET', '/users')).users || []; + const owned = new Set(((current.vehicles.find((v) => v.id === id) || {}).owners || []).map((o) => o.id)); + el('skoda-owner-list').innerHTML = users.map((u) => + ``).join(''); + showModal('skoda-owner-modal'); + } + } catch (e) { fail(e); } + }); + + 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)); + try { await api('PUT', `/vehicles/${ownerVehicleId}/owners`, { user_ids: ids }); hideModal('skoda-owner-modal'); await load(); } + catch (e) { fail(e); } + }); + + el('skoda-poll-interval').addEventListener('change', async (ev) => { + try { await api('PUT', '/settings', { poll_interval_min: Number(ev.target.value) }); } catch (e) { fail(e); } + }); + + load().catch(fail); +})(); diff --git a/scripts/skoda-spike.js b/scripts/skoda-spike.js new file mode 100644 index 00000000..e258d08c --- /dev/null +++ b/scripts/skoda-spike.js @@ -0,0 +1,50 @@ +'use strict'; + +// Live spike for the MySkoda API. Usage: +// SKODA_EMAIL=... SKODA_PASSWORD=... node scripts/skoda-spike.js [vin] +// Prints token acquisition, garage list and the raw JSON of every status +// endpoint for the first (or given) VIN. Never writes to the DB. + +const auth = require('../src/services/skoda/skodaAuth'); + +const EMAIL = process.env.SKODA_EMAIL; +const PASSWORD = process.env.SKODA_PASSWORD; +if (!EMAIL || !PASSWORD) { + console.error('set SKODA_EMAIL and SKODA_PASSWORD'); + process.exit(1); +} + +async function get(tokens, path) { + const res = await fetch(`${auth.API_BASE}${path}`, { + headers: { authorization: `Bearer ${tokens.accessToken}`, accept: 'application/json' }, + }); + const body = await res.text(); + console.log(`\n=== GET ${path} -> ${res.status} ===`); + let json = null; + try { json = JSON.parse(body); console.log(JSON.stringify(json, null, 2)); } catch { console.log(body.slice(0, 2000)); } + return { res, json }; +} + +(async () => { + console.log('logging in…'); + const tokens = await auth.login(EMAIL, PASSWORD); + console.log('login OK, got tokens (access token length:', tokens.accessToken.length, ')'); + + console.log('testing refresh…'); + const refreshed = await auth.refresh(tokens.refreshToken); + console.log('refresh OK'); + + const garage = (await get(refreshed, '/api/v2/garage?connectivityGenerations=MOD1&connectivityGenerations=MOD2&connectivityGenerations=MOD3&connectivityGenerations=MOD4')).json; + const vin = process.argv[2] || (garage && garage.vehicles && garage.vehicles[0] && garage.vehicles[0].vin); + if (!vin) { console.error('no vin found — check garage output above'); process.exit(1); } + + await get(refreshed, `/api/v2/garage/vehicles/${vin}`); + await get(refreshed, `/api/v2/vehicle-status/${vin}`); + await get(refreshed, `/api/v2/vehicle-status/${vin}/driving-range`); + await get(refreshed, `/api/v1/charging/${vin}`); + await get(refreshed, `/api/v2/air-conditioning/${vin}`); + await get(refreshed, `/api/v1/maps/positions?vin=${vin}`); + await get(refreshed, `/api/v1/vehicle-health-report/warning-lights/${vin}`); + await get(refreshed, `/api/v3/vehicle-maintenance/vehicles/${vin}`); + console.log('\nSPIKE COMPLETE'); +})().catch((err) => { console.error('SPIKE FAILED:', err.code || '', err.message); process.exit(1); }); diff --git a/src/db/migrationList.js b/src/db/migrationList.js index ff535213..5a6d95c2 100644 --- a/src/db/migrationList.js +++ b/src/db/migrationList.js @@ -1176,6 +1176,43 @@ const migrations = [ sql: `ALTER TABLE smarthome_resources ADD COLUMN state_json TEXT;`, detect: (db) => hasColumn(db, 'smarthome_resources', 'state_json'), }, + { + version: 66, + name: 'skoda_integration', + sql: `CREATE TABLE IF NOT EXISTS skoda_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + password_enc TEXT NOT NULL, + session_enc TEXT, + status TEXT NOT NULL DEFAULT 'ok', + status_detail TEXT, + backoff_min INTEGER NOT NULL DEFAULT 0, + next_retry_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS skoda_vehicles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + vin TEXT NOT NULL UNIQUE, + name TEXT, + model TEXT, + state_json TEXT, + image BLOB, + image_url TEXT, + fetched_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_skoda_vehicles_account ON skoda_vehicles(account_id); + CREATE TABLE IF NOT EXISTS skoda_vehicle_owners ( + skoda_vehicle_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (skoda_vehicle_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_skoda_owners_user ON skoda_vehicle_owners(user_id);`, + detect: (db) => tableExists(db, 'skoda_accounts'), + }, ]; module.exports = { migrations }; diff --git a/src/i18n/de.json b/src/i18n/de.json index c63d3e04..71756326 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -2007,6 +2007,7 @@ "settings.domains.in_use_portal": "Domain wird vom Portal genutzt — bitte zuerst die Portal-Adresse ändern.", "nav.midea": "Klimaanlage", "nav.smarthome": "Smart Home", + "nav.skoda": "Fahrzeuge", "midea.title": "Klimaanlage", "midea.subtitle": "Midea-Klimageräte im Heimnetz steuern", "midea.cloud.title": "Midea-Konto", @@ -2198,6 +2199,32 @@ "smarthome.rules.count_total": "Regeln insgesamt auf dem Gateway", "smarthome.rules.count_gc": "Von GateControl verwaltet", "smarthome.rules.count_external": "Externe Regeln", + "skoda.title": "Skoda Connect", + "skoda.accounts.title": "Skoda-Konten", + "skoda.accounts.add": "Konto hinzufügen", + "skoda.accounts.email": "E-Mail", + "skoda.accounts.password": "Passwort", + "skoda.accounts.status.ok": "Verbunden", + "skoda.accounts.status.login_failed": "Anmeldung fehlgeschlagen", + "skoda.accounts.status.rate_limited": "Rate-Limit — nächster Versuch: {{time}}", + "skoda.accounts.status.error": "Fehler", + "skoda.accounts.remove": "Entfernen", + "skoda.accounts.change_password": "Passwort ändern", + "skoda.vehicles.title": "Fahrzeuge", + "skoda.vehicles.empty": "Noch keine Fahrzeuge synchronisiert.", + "skoda.vehicle.soc": "Ladestand", + "skoda.vehicle.range": "Reichweite", + "skoda.vehicle.locked": "Verriegelt", + "skoda.vehicle.unlocked": "Entriegelt", + "skoda.vehicle.refresh": "Jetzt aktualisieren", + "skoda.vehicle.owners": "Besitzer", + "skoda.vehicle.fetched": "Stand: {{time}}", + "skoda.vehicle.mileage": "Kilometerstand", + "skoda.settings.poll_interval": "Abrufintervall (Minuten)", + "skoda.owner.title": "Besitzer zuordnen", + "skoda.owner.save": "Speichern", + "skoda.error.cooldown": "Bitte warten — Aktualisierung erst in ein paar Minuten wieder möglich.", + "skoda.error.generic": "Aktion fehlgeschlagen", "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 ae700c53..1d11f443 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2063,6 +2063,7 @@ "settings.domains.in_use_portal": "Domain is in use by the portal — change the portal address first.", "nav.midea": "Air Conditioning", "nav.smarthome": "Smart Home", + "nav.skoda": "Vehicles", "midea.title": "Air Conditioning", "midea.subtitle": "Control Midea climate devices on your LAN", "midea.cloud.title": "Midea account", @@ -2254,6 +2255,32 @@ "smarthome.rules.count_total": "Total rules on gateway", "smarthome.rules.count_gc": "Managed by GateControl", "smarthome.rules.count_external": "External rules", + "skoda.title": "Skoda Connect", + "skoda.accounts.title": "Skoda accounts", + "skoda.accounts.add": "Add account", + "skoda.accounts.email": "Email", + "skoda.accounts.password": "Password", + "skoda.accounts.status.ok": "Connected", + "skoda.accounts.status.login_failed": "Login failed", + "skoda.accounts.status.rate_limited": "Rate limited — next retry: {{time}}", + "skoda.accounts.status.error": "Error", + "skoda.accounts.remove": "Remove", + "skoda.accounts.change_password": "Change password", + "skoda.vehicles.title": "Vehicles", + "skoda.vehicles.empty": "No vehicles synced yet.", + "skoda.vehicle.soc": "Charge level", + "skoda.vehicle.range": "Range", + "skoda.vehicle.locked": "Locked", + "skoda.vehicle.unlocked": "Unlocked", + "skoda.vehicle.refresh": "Refresh now", + "skoda.vehicle.owners": "Owners", + "skoda.vehicle.fetched": "As of: {{time}}", + "skoda.vehicle.mileage": "Mileage", + "skoda.settings.poll_interval": "Poll interval (minutes)", + "skoda.owner.title": "Assign owners", + "skoda.owner.save": "Save", + "skoda.error.cooldown": "Please wait — refresh available again in a few minutes.", + "skoda.error.generic": "Action failed", "portal.midea.fan": "Fan", "portal.midea.fan_auto": "Auto", "portal.midea.fan_silent": "Silent", diff --git a/src/routes/api/index.js b/src/routes/api/index.js index e6bbc209..bef849d9 100644 --- a/src/routes/api/index.js +++ b/src/routes/api/index.js @@ -49,6 +49,7 @@ router.use('/client', require('./client')); router.use('/rdp', require('./rdp')); router.use('/pihole', require('./pihole')); router.use('/midea', require('./midea')); +router.use('/skoda', require('./skoda')); router.use('/smarthome', require('./smarthome')); module.exports = router; diff --git a/src/routes/api/skoda.js b/src/routes/api/skoda.js new file mode 100644 index 00000000..e817b2bc --- /dev/null +++ b/src/routes/api/skoda.js @@ -0,0 +1,99 @@ +'use strict'; + +const { Router } = require('express'); +const { requireFeature } = require('../../middleware/license'); +const users = require('../../services/users'); +const skoda = require('../../services/skoda'); +const accounts = require('../../services/skoda/skodaAccounts'); +const owners = require('../../services/skoda/skodaOwners'); +const settings = require('../../services/settings'); + +const router = Router(); + +router.use((req, res, next) => { + if (req.tokenAuth) return res.status(403).json({ ok: false, error: req.t('error.users.session_required') }); + if (!req.session || !req.session.userId) return res.status(401).json({ ok: false, error: req.t('error.users.unauthorized') }); + const user = users.getById(req.session.userId); + if (!user || user.role !== 'admin') return res.status(403).json({ ok: false, error: req.t('error.users.admin_required') }); + next(); +}); +router.use(requireFeature('skoda_integration')); + +const STATUS_BY_CODE = { + SKODA_VALIDATION: 400, + SKODA_OWNER_UNKNOWN_USER: 400, + SKODA_ACCOUNT_EXISTS: 409, + SKODA_REFRESH_COOLDOWN: 429, + SKODA_RATE_LIMITED: 429, + SKODA_VEHICLE_NOT_FOUND: 404, + SKODA_ACCOUNT_NOT_FOUND: 404, +}; + +function wrap(fn) { + return async (req, res) => { + try { await fn(req, res); } catch (e) { + const status = STATUS_BY_CODE[e.code] || (/not found/i.test(e.message) ? 404 : 502); + res.status(status).json({ ok: false, error: e.message, code: e.code || null }); + } + }; +} + +router.get('/', wrap(async (req, res) => { + res.json({ ok: true, ...skoda.getStatus(), poll_interval_min: skoda.pollIntervalMs() / 60000 }); +})); + +router.post('/accounts', wrap(async (req, res) => { + // No implicit sync here: the UI calls POST /accounts/:id/sync afterwards. + // Keeps unit tests free of real network login attempts. + const acc = accounts.createAccount({ email: req.body.email, password: req.body.password }); + const { password, password_enc, session_enc, ...safe } = acc || {}; + res.status(201).json({ ok: true, account: safe }); +})); + +router.post('/accounts/:id/sync', wrap(async (req, res) => { + const result = await skoda.syncAccount(Number(req.params.id)); + res.json({ ok: true, result }); +})); + +router.put('/accounts/:id', wrap(async (req, res) => { + accounts.updatePassword(Number(req.params.id), req.body.password); + res.json({ ok: true }); +})); + +router.delete('/accounts/:id', wrap(async (req, res) => { + await skoda.removeAccount(Number(req.params.id)); // account lock: waits for in-flight sync + res.json({ ok: true }); +})); + +router.post('/vehicles/:id/refresh', wrap(async (req, res) => { + await skoda.refreshVehicle(Number(req.params.id)); + res.json({ ok: true }); +})); + +router.put('/vehicles/:id/owners', wrap(async (req, res) => { + const rawIds = req.body && req.body.user_ids; + if (!Array.isArray(rawIds)) { + return res.status(400).json({ ok: false, error: 'user_ids must be an array', code: 'SKODA_VALIDATION' }); + } + owners.setOwners(Number(req.params.id), rawIds); + res.json({ ok: true, owners: owners.ownersOf(Number(req.params.id)) }); +})); + +router.get('/vehicles/:id/image', wrap(async (req, res) => { + const img = skoda.getVehicleImage(Number(req.params.id)); + if (!img) return res.status(404).json({ ok: false, error: 'no image', code: null }); + res.set('content-type', 'image/png').set('cache-control', 'private, max-age=86400').send(img.image); +})); + +router.put('/settings', wrap(async (req, res) => { + const val = Number(req.body.poll_interval_min); + if (!Number.isInteger(val) || val < 5 || val > 1440) { + return res.status(400).json({ ok: false, error: 'poll_interval_min must be 5..1440', code: 'SKODA_VALIDATION' }); + } + settings.set('skoda_poll_interval_min', String(val)); + skoda.stopPolling(); + skoda.startPolling({ immediate: false }); + res.json({ ok: true }); +})); + +module.exports = router; diff --git a/src/routes/index.js b/src/routes/index.js index ca2a624d..40394f75 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -194,6 +194,7 @@ const pages = [ { path: '/dns', template: 'dns', titleKey: 'nav.dns' }, { path: '/pihole', template: 'pihole', titleKey: 'pihole.title' }, { path: '/midea', template: 'midea', titleKey: 'midea.title' }, + { path: '/skoda', template: 'skoda', titleKey: 'skoda.title' }, { path: '/smarthome', template: 'smarthome', titleKey: 'smarthome.title' }, { path: '/smarthome/rules', template: 'smarthome-rules', titleKey: 'smarthome.rules.title' }, { path: '/gateway-pools', template: 'gateway-pools', titleKey: 'gateway_pools.title' }, diff --git a/src/server.js b/src/server.js index e23f8f27..3f564d5b 100644 --- a/src/server.js +++ b/src/server.js @@ -139,6 +139,10 @@ async function start() { try { require('./services/midea').startPolling(); } catch (err) { logger.warn({ err: err.message }, 'midea start failed'); } + // Skoda Connect poll loop — best-effort; no-op without license or enrolled accounts. + try { require('./services/skoda').startPolling(); } + catch (err) { logger.warn({ err: err.message }, 'skoda start failed'); } + // Smart Home (deCONZ) poll loop — best-effort; no-op without license or gateways. try { require('./services/smarthome').startPolling(); } catch (err) { logger.warn({ err: err.message }, 'smarthome start failed'); } diff --git a/src/services/license.js b/src/services/license.js index 09dcfde6..41551d45 100644 --- a/src/services/license.js +++ b/src/services/license.js @@ -48,6 +48,7 @@ const COMMUNITY_FALLBACK = { internal_dns: false, pihole_integration: false, midea_integration: false, + skoda_integration: false, smarthome: false, gateway_peers: 1, gateway_http_targets: 3, diff --git a/src/services/skoda/index.js b/src/services/skoda/index.js new file mode 100644 index 00000000..5c93e9e2 --- /dev/null +++ b/src/services/skoda/index.js @@ -0,0 +1,183 @@ +'use strict'; + +const skodaAuth = require('./skodaAuth'); +const { SkodaClient } = require('./skodaClient'); +const accounts = require('./skodaAccounts'); +const vehicles = require('./skodaVehicles'); +const owners = require('./skodaOwners'); +const settings = require('../settings'); +const license = require('../license'); +const logger = require('../../utils/logger'); + +const FEATURE = 'skoda_integration'; +const REFRESH_COOLDOWN_MS = 5 * 60 * 1000; +const BACKOFF_START_MIN = 60; +const BACKOFF_CAP_MIN = 240; + +let pollTimer = null; +let pollRunning = false; +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 accountLocks = new Map(); // accountId -> promise chain tail + +// Serializes poller, manual refresh and account removal per account — +// mirrors midea's withDeviceLock. Prevents parallel syncs racing the +// session refresh (single-use refresh tokens) and delete-during-sync. +function withAccountLock(id, fn) { + const prev = accountLocks.get(id) || Promise.resolve(); + const next = prev.then(fn, fn); + accountLocks.set(id, next.catch(() => {})); + return next; +} + +function pollIntervalMs() { + return Math.max(5, Number(settings.get('skoda_poll_interval_min', '15')) || 15) * 60000; +} + +function clientFor(account, fetchImpl) { + return new SkodaClient({ + getSession: () => accounts.getAccountWithSecrets(account.id).session, + saveSession: (tokens) => accounts.saveSession(account.id, tokens), + fetchImpl: fetchImpl || fetch, + }); +} + +async function ensureSession(account, fetchImpl) { + if (account.session && account.session.accessToken) return; + const tokens = await skodaAuth.login(account.email, account.password, { fetchImpl: fetchImpl || fetch }); + accounts.saveSession(account.id, tokens); +} + +function firstRenderUrl(vehicleInfo) { + const renders = (vehicleInfo && vehicleInfo.compositeRenders) || []; + for (const render of renders) { + for (const layer of (render && render.layers) || []) { + if (layer && layer.url) return layer.url; + } + } + return null; +} + +async function syncVehicle(client, accountId, garageEntry) { + const row = vehicles.upsertVehicle(accountId, garageEntry); + const { state } = await client.fetchFullState(garageEntry.vin); + vehicles.saveState(row.id, state); + + // Render image: fetch once, refetch only when the url changes. + try { + const info = await client.vehicleInfo(garageEntry.vin); + const url = firstRenderUrl(info); + if (url && (!row.image || row.image_url !== url)) { + vehicles.saveImage(row.id, await client.renderImage(url), url); + } + } catch (e) { + logger.warn({ err: e.message, vin: garageEntry.vin }, 'skoda render image fetch failed'); + } +} + +function syncAccount(accountId, { fetchImpl } = {}) { + return withAccountLock(accountId, () => syncAccountLocked(accountId, { fetchImpl })); +} + +async function syncAccountLocked(accountId, { fetchImpl } = {}) { + let account = null; + try { + // Inside try: a corrupt session_enc (decrypt/JSON.parse throw) must mark + // THIS account as broken, not blow up the whole syncAll loop. + account = accounts.getAccountWithSecrets(accountId); + if (!account) return { ok: false, error: 'not found' }; + await ensureSession(account, fetchImpl); + const client = clientFor(account, fetchImpl); + const garage = await client.garage(); + const entries = (garage && garage.vehicles) || []; + for (const entry of entries) await syncVehicle(client, accountId, entry); + accounts.setStatus(accountId, 'ok', null, { backoffMin: 0, nextRetryAt: null }); + lastSyncAt = new Date().toISOString(); + return { ok: true, vehicles: entries.length }; + } catch (e) { + if (e.code === 'SKODA_RATE_LIMITED') { + const prev = (account && account.backoff_min) || 0; + const backoffMin = prev ? Math.min(prev * 2, BACKOFF_CAP_MIN) : BACKOFF_START_MIN; + const nextRetryAt = new Date(Date.now() + backoffMin * 60000).toISOString(); + accounts.setStatus(accountId, 'rate_limited', 'HTTP 429', { backoffMin, nextRetryAt }); + } else if (e.code === 'SKODA_LOGIN_FAILED' || e.code === 'SKODA_TERMS_REQUIRED' || e.code === 'SKODA_AUTH_FLOW_CHANGED') { + accounts.saveSession(accountId, null); // drop stale session, force fresh login after fix + accounts.setStatus(accountId, 'login_failed', `${e.code}: ${e.message}`); + } else if (e.code === 'SKODA_UNAUTHORIZED') { + // expired/invalid session: drop it and let the next tick re-login with the stored password + accounts.saveSession(accountId, null); + accounts.setStatus(accountId, 'error', `${e.code}: ${e.message}`); + } else { + accounts.setStatus(accountId, 'error', e.message); + } + logger.warn({ err: e.message, code: e.code, accountId }, 'skoda sync failed'); + return { ok: false, error: e.message }; + } +} + +async function syncAll({ fetchImpl, ignoreRetryAt = false } = {}) { + for (const acc of accounts.listAccounts()) { + if (acc.status === 'login_failed') continue; + if (!ignoreRetryAt && acc.status === 'rate_limited' && acc.next_retry_at && new Date(acc.next_retry_at) > new Date()) continue; + await syncAccount(acc.id, { fetchImpl }); + } +} + +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; + } + 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()); + return syncAccount(accountId, { fetchImpl }); +} + +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. + return withAccountLock(accountId, () => accounts.removeAccount(accountId)); +} + +function getStatus() { + const vehicleList = vehicles.listRedacted().map((v) => ({ ...v, owners: owners.ownersOf(v.id) })); + return { accounts: accounts.listAccounts(), vehicles: vehicleList, lastSyncAt }; +} + +function getVehicleImage(vehicleId) { + return vehicles.getImage(vehicleId); +} + +function pollTick() { + if (!license.hasFeature(FEATURE)) return; + if (pollRunning) return; // skip tick while a previous run is still going + if (!accounts.listAccounts().length) return; + pollRunning = true; + syncAll() + .catch((e) => logger.warn({ err: e.message }, 'skoda poll failed')) + .finally(() => { pollRunning = false; }); +} + +function startPolling({ immediate = true } = {}) { + if (pollTimer) return; + if (!license.hasFeature(FEATURE)) return; + pollTimer = setInterval(pollTick, pollIntervalMs()); + pollTimer.unref(); + if (immediate) pollTick(); +} + +function stopPolling() { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } +} + +function _resetForTest() { stopPolling(); refreshCooldown.clear(); accountLocks.clear(); pollRunning = false; lastSyncAt = null; } + +module.exports = { + syncAccount, syncAll, refreshVehicle, removeAccount, getStatus, getVehicleImage, + startPolling, stopPolling, pollTick, pollIntervalMs, _resetForTest, +}; diff --git a/src/services/skoda/skodaAccounts.js b/src/services/skoda/skodaAccounts.js new file mode 100644 index 00000000..84e9ec59 --- /dev/null +++ b/src/services/skoda/skodaAccounts.js @@ -0,0 +1,80 @@ +'use strict'; + +const { getDb } = require('../../db/connection'); +const { encrypt, decrypt } = require('../../utils/crypto'); + +function err(message, code) { const e = new Error(message); e.code = code; return e; } + +function isValidEmail(email) { + // Linear index checks instead of a regex: /.+@.+/ on user input is a + // polynomial-ReDoS risk (CodeQL js/polynomial-redos). indexOf is O(n) and + // the length cap is the RFC 5321 maximum. + if (email.length > 254) return false; + const at = email.indexOf('@'); + return at > 0 && at < email.length - 1; +} + +function createAccount({ email, password }) { + const trimmed = typeof email === 'string' ? email.trim() : ''; + if (!trimmed || !isValidEmail(trimmed)) throw err('valid email required', 'SKODA_VALIDATION'); + if (!password || typeof password !== 'string') throw err('password required', 'SKODA_VALIDATION'); + const db = getDb(); + try { + const info = db.prepare('INSERT INTO skoda_accounts (email, password_enc) VALUES (?, ?)') + .run(trimmed, encrypt(password)); + return listAccounts().find((a) => a.id === info.lastInsertRowid); + } catch (e) { + if (/UNIQUE/.test(e.message)) throw err('account already exists', 'SKODA_ACCOUNT_EXISTS'); + throw e; + } +} + +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() + .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), + })); +} + +function getAccountWithSecrets(id) { + const r = getDb().prepare('SELECT * FROM skoda_accounts WHERE id = ?').get(id); + if (!r) return null; + return { + id: r.id, email: r.email, status: r.status, backoff_min: r.backoff_min, next_retry_at: r.next_retry_at, + password: decrypt(r.password_enc), + session: r.session_enc ? JSON.parse(decrypt(r.session_enc)) : null, + }; +} + +function updatePassword(id, password) { + if (!password || typeof password !== 'string') throw err('password required', 'SKODA_VALIDATION'); + const info = getDb().prepare(`UPDATE skoda_accounts SET password_enc = ?, status = 'ok', status_detail = NULL, + backoff_min = 0, next_retry_at = NULL, updated_at = datetime('now') WHERE id = ?`).run(encrypt(password), id); + if (!info.changes) throw err('account not found', 'SKODA_ACCOUNT_NOT_FOUND'); +} + +function saveSession(id, sessionObj) { + getDb().prepare("UPDATE skoda_accounts SET session_enc = ?, updated_at = datetime('now') WHERE id = ?") + .run(sessionObj ? encrypt(JSON.stringify(sessionObj)) : null, id); +} + +function setStatus(id, status, detail = null, { backoffMin = null, nextRetryAt = null } = {}) { + if (detail != null) detail = String(detail).slice(0, 300); // keep upstream error blobs out of the UI + getDb().prepare(`UPDATE skoda_accounts SET status = ?, status_detail = ?, + backoff_min = COALESCE(?, backoff_min), next_retry_at = ?, updated_at = datetime('now') WHERE id = ?`) + .run(status, detail, backoffMin, nextRetryAt, id); +} + +function removeAccount(id) { + const db = getDb(); + const tx = db.transaction((accountId) => { + db.prepare('DELETE FROM skoda_vehicle_owners WHERE skoda_vehicle_id IN (SELECT id FROM skoda_vehicles WHERE account_id = ?)').run(accountId); + db.prepare('DELETE FROM skoda_vehicles WHERE account_id = ?').run(accountId); + db.prepare('DELETE FROM skoda_accounts WHERE id = ?').run(accountId); + }); + tx(id); +} + +module.exports = { createAccount, listAccounts, getAccountWithSecrets, updatePassword, saveSession, setStatus, removeAccount }; diff --git a/src/services/skoda/skodaAuth.js b/src/services/skoda/skodaAuth.js new file mode 100644 index 00000000..e2c9ee1c --- /dev/null +++ b/src/services/skoda/skodaAuth.js @@ -0,0 +1,120 @@ +'use strict'; + +// MySkoda login flow, ported from the Python `myskoda` reference library. +// The API is unofficial; scripts/skoda-spike.js is the live ground truth. + +const crypto = require('node:crypto'); +const { CookieJar, followRedirects } = require('./skodaHttp'); + +const CLIENT_ID = '7f045eee-7003-4379-9968-9355ed2adb06@apps_vw-dilab_com'; +const REDIRECT_URI = 'myskoda://redirect/login/'; +const IDENT_BASE = 'https://identity.vwgroup.io'; +const API_BASE = 'https://mysmob.api.connect.skoda-auto.cz'; +const SCOPES = 'address badge birthdate cars driversLicense dealers email mileage mbb nationalIdentifier openid phone profession profile vin'; +const FORM_HEADERS = { 'content-type': 'application/x-www-form-urlencoded', accept: 'text/html' }; + +class SkodaAuthError extends Error { + constructor(message, code) { super(message); this.name = 'SkodaAuthError'; this.code = code; } +} + +function generatePkce() { + const verifier = crypto.randomBytes(32).toString('base64url'); + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +function parseIdk(html) { + const csrf = html.match(/csrf_token:\s*['"]([^'"]+)['"]/); + const tpl = html.match(/templateModel:\s*(\{.*?\})\s*,?\s*\n/s); + if (!csrf || !tpl) throw new SkodaAuthError('cannot parse identity page', 'SKODA_AUTH_FLOW_CHANGED'); + let templateModel; + try { templateModel = JSON.parse(tpl[1]); } catch { + throw new SkodaAuthError('cannot parse templateModel', 'SKODA_AUTH_FLOW_CHANGED'); + } + return { csrfToken: csrf[1], templateModel }; +} + +function parseFragment(location) { + const hash = location.split('#')[1] || ''; + return Object.fromEntries(new URLSearchParams(hash)); +} + +function formBody(fields) { return new URLSearchParams(fields).toString(); } + +async function exchangeCode(code, verifier, fetchImpl) { + const res = await fetchImpl(`${API_BASE}/api/v1/authentication/exchange-authorization-code?tokenType=CONNECT`, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ code, redirectUri: REDIRECT_URI, verifier }), + }); + if (res.status === 429) throw new SkodaAuthError('rate limited', 'SKODA_RATE_LIMITED'); + if (res.status >= 400) throw new SkodaAuthError(`code exchange failed (${res.status})`, 'SKODA_LOGIN_FAILED'); + const json = await res.json(); + return { accessToken: json.accessToken, refreshToken: json.refreshToken, idToken: json.idToken }; +} + +async function login(email, password, { fetchImpl = fetch } = {}) { + const jar = new CookieJar(); + const { verifier, challenge } = generatePkce(); + const nonce = crypto.randomBytes(16).toString('base64url'); + + const authorizeUrl = `${IDENT_BASE}/oidc/v1/authorize?` + new URLSearchParams({ + client_id: CLIENT_ID, + nonce, + redirect_uri: REDIRECT_URI, + response_type: 'code id_token', + scope: SCOPES, + code_challenge: challenge, + code_challenge_method: 'S256', + }).toString(); + + const start = await followRedirects(jar, authorizeUrl, { method: 'GET', headers: { accept: 'text/html' } }, { fetchImpl }); + const emailIdk = parseIdk(await start.res.text()); + + const identifierRes = await followRedirects(jar, + `${IDENT_BASE}/signin-service/v1/${CLIENT_ID}/login/identifier`, + { method: 'POST', headers: FORM_HEADERS, body: formBody({ + _csrf: emailIdk.csrfToken, + relayState: emailIdk.templateModel.relayState, + hmac: emailIdk.templateModel.hmac, + email, + }) }, { fetchImpl }); + const pwIdk = parseIdk(await identifierRes.res.text()); + + const finish = await followRedirects(jar, + `${IDENT_BASE}/signin-service/v1/${CLIENT_ID}/login/authenticate`, + { method: 'POST', headers: FORM_HEADERS, body: formBody({ + _csrf: pwIdk.csrfToken, + relayState: pwIdk.templateModel.relayState, + hmac: pwIdk.templateModel.hmac, + email, + password, + }) }, { stopPrefix: 'myskoda://', fetchImpl }); + + if (!finish.location.startsWith('myskoda://')) { + if (finish.location.includes('terms-and-conditions')) { + throw new SkodaAuthError('terms acceptance required in MySkoda app', 'SKODA_TERMS_REQUIRED'); + } + throw new SkodaAuthError('login did not reach redirect (wrong credentials?)', 'SKODA_LOGIN_FAILED'); + } + const { code } = parseFragment(finish.location); + if (!code) throw new SkodaAuthError('no code in redirect', 'SKODA_AUTH_FLOW_CHANGED'); + return exchangeCode(code, verifier, fetchImpl); +} + +async function refresh(refreshToken, { fetchImpl = fetch } = {}) { + const res = await fetchImpl(`${API_BASE}/api/v1/authentication/refresh-token?tokenType=CONNECT`, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ token: refreshToken }), + }); + if (res.status === 429) throw new SkodaAuthError('rate limited', 'SKODA_RATE_LIMITED'); + if (res.status >= 400) throw new SkodaAuthError(`refresh failed (${res.status})`, 'SKODA_LOGIN_FAILED'); + const json = await res.json(); + return { accessToken: json.accessToken, refreshToken: json.refreshToken, idToken: json.idToken }; +} + +module.exports = { + SkodaAuthError, generatePkce, parseIdk, parseFragment, login, refresh, + CLIENT_ID, REDIRECT_URI, IDENT_BASE, API_BASE, +}; diff --git a/src/services/skoda/skodaClient.js b/src/services/skoda/skodaClient.js new file mode 100644 index 00000000..50961782 --- /dev/null +++ b/src/services/skoda/skodaClient.js @@ -0,0 +1,150 @@ +'use strict'; + +const skodaAuth = require('./skodaAuth'); +const { API_BASE } = skodaAuth; + +class SkodaApiError extends Error { + constructor(message, code, status) { super(message); this.name = 'SkodaApiError'; this.code = code; this.status = status; } +} + +// Hosts observed serving compositeRenders images; confirm/extend via Task-3 spike. +const RENDER_HOST_ALLOWLIST = [/\.azureedge\.net$/, /\.skoda-auto\.cz$/]; + +class SkodaClient { + constructor({ getSession, saveSession, fetchImpl = fetch }) { + this.getSession = getSession; + this.saveSession = saveSession; + this.fetchImpl = fetchImpl; + } + + async _get(path, { retried = false } = {}) { + const session = this.getSession(); + const res = await this.fetchImpl(`${API_BASE}${path}`, { + headers: { authorization: `Bearer ${session.accessToken}`, accept: 'application/json' }, + }); + if (res.status === 401 && !retried) { + let tokens; + try { + tokens = await skodaAuth.refresh(session.refreshToken, { fetchImpl: this.fetchImpl }); + } catch (e) { + if (e.code === 'SKODA_RATE_LIMITED') throw e; // account-level, abort as-is + throw new SkodaApiError('token refresh failed', 'SKODA_UNAUTHORIZED', 401); + } + this.saveSession(tokens); + return this._get(path, { 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(); + } + + garage() { return this._get('/api/v2/garage?connectivityGenerations=MOD1&connectivityGenerations=MOD2&connectivityGenerations=MOD3&connectivityGenerations=MOD4'); } + vehicleInfo(vin) { return this._get(`/api/v2/garage/vehicles/${vin}`); } + vehicleStatus(vin) { return this._get(`/api/v2/vehicle-status/${vin}`); } + drivingRange(vin) { return this._get(`/api/v2/vehicle-status/${vin}/driving-range`); } + charging(vin) { return this._get(`/api/v1/charging/${vin}`); } + airConditioning(vin) { return this._get(`/api/v2/air-conditioning/${vin}`); } + position(vin) { return this._get(`/api/v1/maps/positions?vin=${vin}`); } + health(vin) { return this._get(`/api/v1/vehicle-health-report/warning-lights/${vin}`); } + maintenance(vin) { return this._get(`/api/v3/vehicle-maintenance/vehicles/${vin}`); } + + async renderImage(url) { + // The url comes from the Skoda API response — never fetch it unvalidated, + // and never send our bearer token to an arbitrary host (SSRF/token leak). + // Confirm/extend the allowlist from the Task-3 live spike; if the spike + // shows the CDN serves images unauthenticated, drop the auth header here. + let parsed; + try { parsed = new URL(url); } catch { throw new SkodaApiError('invalid render url', 'SKODA_API_ERROR', 0); } + if (parsed.protocol !== 'https:' || !RENDER_HOST_ALLOWLIST.some((re) => re.test(parsed.hostname))) { + throw new SkodaApiError(`render url host not allowed: ${parsed.hostname}`, 'SKODA_API_ERROR', 0); + } + const session = this.getSession(); + const res = await this.fetchImpl(parsed.toString(), { headers: { authorization: `Bearer ${session.accessToken}` } }); + if (res.status >= 400) throw new SkodaApiError(`image fetch failed ${res.status}`, 'SKODA_API_ERROR', res.status); + return Buffer.from(await res.arrayBuffer()); + } + + async fetchFullState(vin) { + const parts = {}; + const jobs = { + status: () => this.vehicleStatus(vin), + drivingRange: () => this.drivingRange(vin), + charging: () => this.charging(vin), + airConditioning: () => this.airConditioning(vin), + position: () => this.position(vin), + health: () => this.health(vin), + maintenance: () => this.maintenance(vin), + }; + for (const [key, job] of Object.entries(jobs)) { + try { parts[key] = await job(); } catch (e) { + if (e.code === 'SKODA_RATE_LIMITED' || e.code === 'SKODA_UNAUTHORIZED') throw e; // account-level, abort + parts[key] = null; + } + } + return { parts, state: normalizeVehicleState(parts) }; + } +} + +const YES = (v) => (v == null ? null : String(v).toUpperCase() === 'YES'); +const OPEN = (v) => (v == null ? null : String(v).toUpperCase() === 'OPEN'); +const ON = (v) => (v == null ? null : String(v).toUpperCase() === 'ON'); +const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null); + +function normalizeVehicleState({ status, drivingRange, charging, airConditioning, position, health, maintenance }) { + const chStatus = charging && charging.status; + const per = drivingRange && drivingRange.primaryEngineRange; + const pos = position && Array.isArray(position.positions) + ? position.positions.find((p) => p && p.type === 'VEHICLE') : null; + const windowHeating = airConditioning && airConditioning.windowHeatingState; + return { + capturedAt: (status && status.carCapturedTimestamp) || (health && health.capturedAt) || null, + locked: status ? YES(status.overall && status.overall.locked) : null, + doorsOpen: status ? OPEN(status.overall && status.overall.doors) : null, + windowsOpen: status ? OPEN(status.overall && status.overall.windows) : null, + detail: { + bonnet: (status && status.detail && status.detail.bonnet) || null, + trunk: (status && status.detail && status.detail.trunk) || null, + sunroof: (status && status.detail && status.detail.sunroof) || null, + }, + lightsOn: status ? ON(status.overall && status.overall.lights) : null, + soc: per ? num(per.currentSoCInPercent) + : (chStatus && chStatus.battery ? num(chStatus.battery.stateOfChargeInPercent) : null), + rangeKm: drivingRange ? num(drivingRange.totalRangeInKm) : null, + charging: { + state: (chStatus && chStatus.state) || null, + powerKw: chStatus ? num(chStatus.chargePowerInKw) : null, + remainingMin: chStatus ? num(chStatus.remainingTimeToFullyChargedInMinutes) : null, + targetPercent: charging && charging.settings ? num(charging.settings.targetStateOfChargeInPercent) : null, + mode: (charging && charging.settings && charging.settings.chargingCareMode) || null, + cableConnected: charging && charging.plug && charging.plug.connectionState != null + ? String(charging.plug.connectionState).toUpperCase() === 'CONNECTED' : null, + }, + climate: { + state: (airConditioning && airConditioning.state) || null, + targetC: airConditioning && airConditioning.targetTemperature ? num(airConditioning.targetTemperature.temperatureValue) : null, + remainingMin: (() => { + if (!airConditioning) return null; + const direct = num(airConditioning.remainingTimeToReachTargetTemperatureInMinutes); + if (direct != null) return direct; + const ts = Date.parse(airConditioning.estimatedDateTimeToReachTargetTemperature || ''); + return Number.isFinite(ts) ? Math.max(0, Math.round((ts - Date.now()) / 60000)) : null; + })(), + windowHeating: windowHeating ? (ON(windowHeating.front) || ON(windowHeating.rear)) : null, + }, + position: pos && pos.gpsCoordinates + ? { lat: num(pos.gpsCoordinates.latitude), lon: num(pos.gpsCoordinates.longitude) } : null, + health: { + mileageKm: health ? num(health.mileageInKm) : null, + warnings: (health && Array.isArray(health.warningLights) ? health.warningLights : []) + .map((w) => (typeof w === 'string' ? w : (w && (w.category || w.type)) || 'UNKNOWN')), + }, + maintenance: { + dueInDays: maintenance && maintenance.maintenanceReport ? num(maintenance.maintenanceReport.inspectionDueInDays) : null, + dueInKm: maintenance && maintenance.maintenanceReport ? num(maintenance.maintenanceReport.inspectionDueInKm) : null, + partner: (maintenance && maintenance.preferredServicePartner && maintenance.preferredServicePartner.name) || null, + }, + }; +} + +module.exports = { SkodaClient, SkodaApiError, normalizeVehicleState }; diff --git a/src/services/skoda/skodaHttp.js b/src/services/skoda/skodaHttp.js new file mode 100644 index 00000000..af93dd3e --- /dev/null +++ b/src/services/skoda/skodaHttp.js @@ -0,0 +1,60 @@ +'use strict'; + +// Minimal in-memory cookie jar + manual redirect follower for the VW identity +// login flow (HTML form posts + 302 chains across hosts). Native fetch has no +// cookie handling, so this exists. Per-login lifetime only, never persisted. + +class CookieJar { + constructor() { + this.cookies = new Map(); // `${host}|${name}` -> value + } + + storeFrom(res, url) { + const getSetCookie = typeof res.headers.getSetCookie === 'function' + ? res.headers.getSetCookie() + : []; + const host = new URL(url).host; + for (const line of getSetCookie) { + const pair = line.split(';')[0]; + const eq = pair.indexOf('='); + if (eq < 1) continue; + this.cookies.set(`${host}|${pair.slice(0, eq).trim()}`, pair.slice(eq + 1).trim()); + } + } + + headerFor(url) { + const host = new URL(url).host; + const parts = []; + for (const [key, value] of this.cookies) { + const sep = key.indexOf('|'); + if (key.slice(0, sep) === host) parts.push(`${key.slice(sep + 1)}=${value}`); + } + return parts.length ? parts.join('; ') : null; + } +} + +async function requestWithJar(jar, url, opts = {}, fetchImpl = fetch) { + const headers = { ...(opts.headers || {}) }; + const cookie = jar.headerFor(url); + if (cookie) headers.cookie = cookie; + const res = await fetchImpl(url, { ...opts, headers, redirect: 'manual' }); + jar.storeFrom(res, url); + return res; +} + +async function followRedirects(jar, url, opts, { maxHops = 15, stopPrefix = null, fetchImpl = fetch } = {}) { + let current = url; + let init = opts || {}; + for (let hop = 0; hop <= maxHops; hop++) { + const res = await requestWithJar(jar, current, init, fetchImpl); + const loc = res.headers.get('location'); + if (res.status < 300 || res.status >= 400 || !loc) return { res, location: current }; + const next = /^[a-z][a-z0-9+.-]*:/i.test(loc) ? loc : new URL(loc, current).toString(); + if (stopPrefix && next.startsWith(stopPrefix)) return { res, location: next }; + current = next; + init = { method: 'GET' }; + } + throw new Error('too many redirects'); +} + +module.exports = { CookieJar, requestWithJar, followRedirects }; diff --git a/src/services/skoda/skodaOwners.js b/src/services/skoda/skodaOwners.js new file mode 100644 index 00000000..d04117e4 --- /dev/null +++ b/src/services/skoda/skodaOwners.js @@ -0,0 +1,47 @@ +'use strict'; + +const { getDb } = require('../../db/connection'); + +function err(message, code) { const e = new Error(message); e.code = code; return e; } + +function setOwners(vehicleId, userIds) { + const db = getDb(); + if (!db.prepare('SELECT id FROM skoda_vehicles WHERE id = ?').get(vehicleId)) { + throw err('vehicle not found', 'SKODA_VEHICLE_NOT_FOUND'); + } + const ids = [...new Set((userIds || []).map(Number))]; + for (const uid of ids) { + if (!db.prepare('SELECT id FROM users WHERE id = ?').get(uid)) { + throw err(`unknown user ${uid}`, 'SKODA_OWNER_UNKNOWN_USER'); + } + } + db.transaction(() => { + db.prepare('DELETE FROM skoda_vehicle_owners WHERE skoda_vehicle_id = ?').run(vehicleId); + const ins = db.prepare('INSERT INTO skoda_vehicle_owners (skoda_vehicle_id, user_id) VALUES (?, ?)'); + for (const uid of ids) ins.run(vehicleId, uid); + })(); +} + +function ownersOf(vehicleId) { + return getDb().prepare(`SELECT u.id, u.username FROM skoda_vehicle_owners o + JOIN users u ON u.id = o.user_id WHERE o.skoda_vehicle_id = ? ORDER BY u.username`).all(vehicleId); +} + +function vehiclesOwnedBy(userId) { + return getDb().prepare('SELECT skoda_vehicle_id FROM skoda_vehicle_owners WHERE user_id = ?') + .all(userId).map((r) => r.skoda_vehicle_id); +} + +function isOwner(vehicleId, userId) { + return Boolean(getDb().prepare('SELECT 1 FROM skoda_vehicle_owners WHERE skoda_vehicle_id = ? AND user_id = ?').get(vehicleId, userId)); +} + +function removeAllForVehicle(vehicleId) { + getDb().prepare('DELETE FROM skoda_vehicle_owners WHERE skoda_vehicle_id = ?').run(vehicleId); +} + +function removeAllForUser(userId) { + getDb().prepare('DELETE FROM skoda_vehicle_owners WHERE user_id = ?').run(userId); +} + +module.exports = { setOwners, ownersOf, vehiclesOwnedBy, isOwner, removeAllForVehicle, removeAllForUser }; diff --git a/src/services/skoda/skodaVehicles.js b/src/services/skoda/skodaVehicles.js new file mode 100644 index 00000000..19fe07da --- /dev/null +++ b/src/services/skoda/skodaVehicles.js @@ -0,0 +1,53 @@ +'use strict'; + +const { getDb } = require('../../db/connection'); +const logger = require('../../utils/logger'); + +function upsertVehicle(accountId, garageEntry) { + const db = getDb(); + const vin = garageEntry.vin; + const name = garageEntry.name || garageEntry.title || vin; + const model = (garageEntry.specification && garageEntry.specification.model) || null; + // account_id only on insert: with vehicle sharing the same VIN can appear in + // two account garages — first assignment wins, no flapping between accounts. + db.prepare(`INSERT INTO skoda_vehicles (account_id, vin, name, model) VALUES (?, ?, ?, ?) + ON CONFLICT(vin) DO UPDATE SET name = excluded.name, model = excluded.model`) + .run(accountId, vin, name, model); + return db.prepare('SELECT id, image, image_url FROM skoda_vehicles WHERE vin = ?').get(vin); +} + +function saveState(vehicleId, state) { + getDb().prepare("UPDATE skoda_vehicles SET state_json = ?, fetched_at = datetime('now') WHERE id = ?") + .run(JSON.stringify(state), vehicleId); +} + +function saveImage(vehicleId, image, url) { + getDb().prepare('UPDATE skoda_vehicles SET image = ?, image_url = ? WHERE id = ?').run(image, url, vehicleId); +} + +function listRedacted() { + return getDb().prepare('SELECT id, account_id, vin, name, model, state_json, fetched_at, image IS NOT NULL AS has_image FROM skoda_vehicles ORDER BY id').all() + .map((r) => { + let state = null; + if (r.state_json) { + try { state = JSON.parse(r.state_json); } catch { logger.warn({ vin: r.vin }, 'skoda corrupt state_json'); } + } + return { + id: r.id, account_id: r.account_id, vin: r.vin, name: r.name, model: r.model, + state, fetched_at: r.fetched_at, has_image: Boolean(r.has_image), + }; + }); +} + +function getImage(vehicleId) { + const row = getDb().prepare('SELECT image, vin FROM skoda_vehicles WHERE id = ?').get(vehicleId); + if (!row || !row.image) return null; + return { image: row.image, vin: row.vin }; +} + +function accountIdOf(vehicleId) { + const row = getDb().prepare('SELECT account_id FROM skoda_vehicles WHERE id = ?').get(vehicleId); + return row ? row.account_id : null; +} + +module.exports = { upsertVehicle, saveState, saveImage, listRedacted, getImage, accountIdOf }; diff --git a/src/services/users.js b/src/services/users.js index 5cf9f2d0..f311cdc7 100644 --- a/src/services/users.js +++ b/src/services/users.js @@ -7,6 +7,7 @@ const logger = require('../utils/logger'); const argon2Options = require('../utils/argon2Options'); const mideaOwners = require('./midea/mideaOwners'); const smarthomeOwners = require('./smarthome/smarthomeOwners'); +const skodaOwners = require('./skoda/skodaOwners'); const NO_PASSWORD_SENTINEL = '!'; @@ -267,6 +268,7 @@ function remove(id) { db.prepare('UPDATE peers SET user_id = NULL WHERE user_id = ?').run(id); mideaOwners.removeAllForUser(id); // clear AC ownership (no own tx) smarthomeOwners.removeAllForUser(id); // clear smarthome ownership (no own tx) + skodaOwners.removeAllForUser(id); // clear Skoda vehicle ownership (no own tx) db.prepare('DELETE FROM users WHERE id = ?').run(id); })(); diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk index 4d0ba562..cfa62621 100644 --- a/templates/aurora/layout.njk +++ b/templates/aurora/layout.njk @@ -51,7 +51,8 @@ l4_routes: {{ license.features.l4_routes | default(0) }}, http_routes: {{ license.features.http_routes | default(0) }}, browser_sessions: {{ ('true' if license.hasFeature('browser_sessions') else 'false') | safe }}, - midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }} + midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }}, + skoda_integration: {{ ('true' if license.features.skoda_integration else 'false') | safe }} }, t: { 'smarthome.power': {{ t('smarthome.power') | dump | safe }}, @@ -534,7 +535,33 @@ 'midea.extras.label': {{ t('midea.extras.label') | dump | safe }}, 'midea.turbo': {{ t('midea.turbo') | dump | safe }}, 'midea.eco': {{ t('midea.eco') | dump | safe }}, - 'midea.device.outdoor': {{ t('midea.device.outdoor') | dump | safe }} + 'midea.device.outdoor': {{ t('midea.device.outdoor') | dump | safe }}, + 'skoda.title': {{ t('skoda.title') | dump | safe }}, + 'skoda.accounts.title': {{ t('skoda.accounts.title') | dump | safe }}, + 'skoda.accounts.add': {{ t('skoda.accounts.add') | dump | safe }}, + 'skoda.accounts.email': {{ t('skoda.accounts.email') | dump | safe }}, + 'skoda.accounts.password': {{ t('skoda.accounts.password') | dump | safe }}, + 'skoda.accounts.status.ok': {{ t('skoda.accounts.status.ok') | dump | safe }}, + 'skoda.accounts.status.login_failed': {{ t('skoda.accounts.status.login_failed') | dump | safe }}, + 'skoda.accounts.status.rate_limited': {{ t('skoda.accounts.status.rate_limited') | dump | safe }}, + 'skoda.accounts.status.error': {{ t('skoda.accounts.status.error') | dump | safe }}, + 'skoda.accounts.remove': {{ t('skoda.accounts.remove') | dump | safe }}, + 'skoda.accounts.change_password': {{ t('skoda.accounts.change_password') | dump | safe }}, + 'skoda.vehicles.title': {{ t('skoda.vehicles.title') | dump | safe }}, + 'skoda.vehicles.empty': {{ t('skoda.vehicles.empty') | dump | safe }}, + 'skoda.vehicle.soc': {{ t('skoda.vehicle.soc') | dump | safe }}, + 'skoda.vehicle.range': {{ t('skoda.vehicle.range') | dump | safe }}, + 'skoda.vehicle.locked': {{ t('skoda.vehicle.locked') | dump | safe }}, + 'skoda.vehicle.unlocked': {{ t('skoda.vehicle.unlocked') | dump | safe }}, + 'skoda.vehicle.refresh': {{ t('skoda.vehicle.refresh') | dump | safe }}, + 'skoda.vehicle.owners': {{ t('skoda.vehicle.owners') | dump | safe }}, + 'skoda.vehicle.fetched': {{ t('skoda.vehicle.fetched') | dump | safe }}, + 'skoda.vehicle.mileage': {{ t('skoda.vehicle.mileage') | dump | safe }}, + 'skoda.settings.poll_interval': {{ t('skoda.settings.poll_interval') | dump | safe }}, + '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 }} } }; diff --git a/templates/aurora/pages/skoda.njk b/templates/aurora/pages/skoda.njk new file mode 100644 index 00000000..df093f86 --- /dev/null +++ b/templates/aurora/pages/skoda.njk @@ -0,0 +1,57 @@ +{% extends theme + "/layout.njk" %} +{% block head %}{% endblock %} +{% block content %} +
+ +

{{ t('skoda.accounts.title') }}

+
+

{{ t('skoda.vehicles.title') }}

+
+
+ + + + +{% endblock %} +{% block scripts %}{% endblock %} diff --git a/templates/aurora/partials/sidebar.njk b/templates/aurora/partials/sidebar.njk index 57013384..11bf5a80 100644 --- a/templates/aurora/partials/sidebar.njk +++ b/templates/aurora/partials/sidebar.njk @@ -27,6 +27,9 @@ {% if license.features.midea_integration %} {{ t('nav.midea') }} {% endif %} + {% if license.features.skoda_integration %} + {{ t('nav.skoda') }} + {% endif %} {% if license.features.smarthome %} {{ t('nav.smarthome') }} {% endif %} diff --git a/templates/default/layout.njk b/templates/default/layout.njk index de651c9b..ad9c7c7a 100644 --- a/templates/default/layout.njk +++ b/templates/default/layout.njk @@ -50,7 +50,8 @@ l4_routes: {{ license.features.l4_routes | default(0) }}, http_routes: {{ license.features.http_routes | default(0) }}, browser_sessions: {{ ('true' if license.hasFeature('browser_sessions') else 'false') | safe }}, - midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }} + midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }}, + skoda_integration: {{ ('true' if license.features.skoda_integration else 'false') | safe }} }, t: { 'smarthome.power': {{ t('smarthome.power') | dump | safe }}, @@ -527,7 +528,33 @@ 'midea.extras.label': {{ t('midea.extras.label') | dump | safe }}, 'midea.turbo': {{ t('midea.turbo') | dump | safe }}, 'midea.eco': {{ t('midea.eco') | dump | safe }}, - 'midea.device.outdoor': {{ t('midea.device.outdoor') | dump | safe }} + 'midea.device.outdoor': {{ t('midea.device.outdoor') | dump | safe }}, + 'skoda.title': {{ t('skoda.title') | dump | safe }}, + 'skoda.accounts.title': {{ t('skoda.accounts.title') | dump | safe }}, + 'skoda.accounts.add': {{ t('skoda.accounts.add') | dump | safe }}, + 'skoda.accounts.email': {{ t('skoda.accounts.email') | dump | safe }}, + 'skoda.accounts.password': {{ t('skoda.accounts.password') | dump | safe }}, + 'skoda.accounts.status.ok': {{ t('skoda.accounts.status.ok') | dump | safe }}, + 'skoda.accounts.status.login_failed': {{ t('skoda.accounts.status.login_failed') | dump | safe }}, + 'skoda.accounts.status.rate_limited': {{ t('skoda.accounts.status.rate_limited') | dump | safe }}, + 'skoda.accounts.status.error': {{ t('skoda.accounts.status.error') | dump | safe }}, + 'skoda.accounts.remove': {{ t('skoda.accounts.remove') | dump | safe }}, + 'skoda.accounts.change_password': {{ t('skoda.accounts.change_password') | dump | safe }}, + 'skoda.vehicles.title': {{ t('skoda.vehicles.title') | dump | safe }}, + 'skoda.vehicles.empty': {{ t('skoda.vehicles.empty') | dump | safe }}, + 'skoda.vehicle.soc': {{ t('skoda.vehicle.soc') | dump | safe }}, + 'skoda.vehicle.range': {{ t('skoda.vehicle.range') | dump | safe }}, + 'skoda.vehicle.locked': {{ t('skoda.vehicle.locked') | dump | safe }}, + 'skoda.vehicle.unlocked': {{ t('skoda.vehicle.unlocked') | dump | safe }}, + 'skoda.vehicle.refresh': {{ t('skoda.vehicle.refresh') | dump | safe }}, + 'skoda.vehicle.owners': {{ t('skoda.vehicle.owners') | dump | safe }}, + 'skoda.vehicle.fetched': {{ t('skoda.vehicle.fetched') | dump | safe }}, + 'skoda.vehicle.mileage': {{ t('skoda.vehicle.mileage') | dump | safe }}, + 'skoda.settings.poll_interval': {{ t('skoda.settings.poll_interval') | dump | safe }}, + '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 }} } }; diff --git a/templates/default/pages/skoda.njk b/templates/default/pages/skoda.njk new file mode 100644 index 00000000..df093f86 --- /dev/null +++ b/templates/default/pages/skoda.njk @@ -0,0 +1,57 @@ +{% extends theme + "/layout.njk" %} +{% block head %}{% endblock %} +{% block content %} +
+ +

{{ t('skoda.accounts.title') }}

+
+

{{ t('skoda.vehicles.title') }}

+
+
+ + + + +{% endblock %} +{% block scripts %}{% endblock %} diff --git a/templates/default/partials/sidebar.njk b/templates/default/partials/sidebar.njk index 88aca55f..98a0f035 100644 --- a/templates/default/partials/sidebar.njk +++ b/templates/default/partials/sidebar.njk @@ -64,6 +64,12 @@ {{ t('nav.midea') }} {% endif %} + {% if license.features.skoda_integration %} + + + {{ t('nav.skoda') }} + + {% endif %} {% if license.features.smarthome %} diff --git a/templates/pro/layout.njk b/templates/pro/layout.njk index 3d531981..9e1f22d8 100644 --- a/templates/pro/layout.njk +++ b/templates/pro/layout.njk @@ -52,7 +52,8 @@ l4_routes: {{ license.features.l4_routes | default(0) }}, http_routes: {{ license.features.http_routes | default(0) }}, browser_sessions: {{ ('true' if license.hasFeature('browser_sessions') else 'false') | safe }}, - midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }} + midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }}, + skoda_integration: {{ ('true' if license.features.skoda_integration else 'false') | safe }} }, t: { 'smarthome.power': {{ t('smarthome.power') | dump | safe }}, @@ -529,7 +530,33 @@ 'midea.extras.label': {{ t('midea.extras.label') | dump | safe }}, 'midea.turbo': {{ t('midea.turbo') | dump | safe }}, 'midea.eco': {{ t('midea.eco') | dump | safe }}, - 'midea.device.outdoor': {{ t('midea.device.outdoor') | dump | safe }} + 'midea.device.outdoor': {{ t('midea.device.outdoor') | dump | safe }}, + 'skoda.title': {{ t('skoda.title') | dump | safe }}, + 'skoda.accounts.title': {{ t('skoda.accounts.title') | dump | safe }}, + 'skoda.accounts.add': {{ t('skoda.accounts.add') | dump | safe }}, + 'skoda.accounts.email': {{ t('skoda.accounts.email') | dump | safe }}, + 'skoda.accounts.password': {{ t('skoda.accounts.password') | dump | safe }}, + 'skoda.accounts.status.ok': {{ t('skoda.accounts.status.ok') | dump | safe }}, + 'skoda.accounts.status.login_failed': {{ t('skoda.accounts.status.login_failed') | dump | safe }}, + 'skoda.accounts.status.rate_limited': {{ t('skoda.accounts.status.rate_limited') | dump | safe }}, + 'skoda.accounts.status.error': {{ t('skoda.accounts.status.error') | dump | safe }}, + 'skoda.accounts.remove': {{ t('skoda.accounts.remove') | dump | safe }}, + 'skoda.accounts.change_password': {{ t('skoda.accounts.change_password') | dump | safe }}, + 'skoda.vehicles.title': {{ t('skoda.vehicles.title') | dump | safe }}, + 'skoda.vehicles.empty': {{ t('skoda.vehicles.empty') | dump | safe }}, + 'skoda.vehicle.soc': {{ t('skoda.vehicle.soc') | dump | safe }}, + 'skoda.vehicle.range': {{ t('skoda.vehicle.range') | dump | safe }}, + 'skoda.vehicle.locked': {{ t('skoda.vehicle.locked') | dump | safe }}, + 'skoda.vehicle.unlocked': {{ t('skoda.vehicle.unlocked') | dump | safe }}, + 'skoda.vehicle.refresh': {{ t('skoda.vehicle.refresh') | dump | safe }}, + 'skoda.vehicle.owners': {{ t('skoda.vehicle.owners') | dump | safe }}, + 'skoda.vehicle.fetched': {{ t('skoda.vehicle.fetched') | dump | safe }}, + 'skoda.vehicle.mileage': {{ t('skoda.vehicle.mileage') | dump | safe }}, + 'skoda.settings.poll_interval': {{ t('skoda.settings.poll_interval') | dump | safe }}, + '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 }} } }; diff --git a/templates/pro/pages/skoda.njk b/templates/pro/pages/skoda.njk new file mode 100644 index 00000000..df093f86 --- /dev/null +++ b/templates/pro/pages/skoda.njk @@ -0,0 +1,57 @@ +{% extends theme + "/layout.njk" %} +{% block head %}{% endblock %} +{% block content %} +
+ +

{{ t('skoda.accounts.title') }}

+
+

{{ t('skoda.vehicles.title') }}

+
+
+ + + + +{% endblock %} +{% block scripts %}{% endblock %} diff --git a/templates/pro/partials/sidebar.njk b/templates/pro/partials/sidebar.njk index 90b75f7c..d0513322 100644 --- a/templates/pro/partials/sidebar.njk +++ b/templates/pro/partials/sidebar.njk @@ -81,6 +81,12 @@ {{ t('nav.midea') }}
{% endif %} + {% if license.features.skoda_integration %} + + + {{ t('nav.skoda') }} + + {% endif %} {% if license.features.smarthome %} diff --git a/tests/fixtures/skoda/api_responses.js b/tests/fixtures/skoda/api_responses.js new file mode 100644 index 00000000..a238596a --- /dev/null +++ b/tests/fixtures/skoda/api_responses.js @@ -0,0 +1,47 @@ +'use strict'; +// Shapes per the python-myskoda reference models. Replace with redacted live +// captures after the Task-3 spike where they deviate. +module.exports = { + garage: { + vehicles: [{ + vin: 'TMBTESTVIN000001', name: 'Elroq', title: 'Škoda Elroq', + specification: { model: 'Elroq', modelYear: '2025' }, + compositeRenders: [{ layers: [{ url: 'https://ip-modcwp.azureedge.net/render1.png', viewPoint: 'EXTERIOR_FRONT' }] }], + }], + }, + status: { + carCapturedTimestamp: '2026-07-22T08:00:00Z', + overall: { locked: 'YES', doors: 'CLOSED', windows: 'CLOSED', lights: 'OFF' }, + detail: { bonnet: 'CLOSED', trunk: 'CLOSED', sunroof: 'UNSUPPORTED' }, + }, + drivingRange: { + carType: 'ELECTRIC', + totalRangeInKm: 310, + primaryEngineRange: { engineType: 'ELECTRIC', currentSoCInPercent: 74, remainingRangeInKm: 310 }, + }, + charging: { + status: { + state: 'CHARGING', + chargePowerInKw: 10.5, + remainingTimeToFullyChargedInMinutes: 95, + battery: { stateOfChargeInPercent: 74, remainingCruisingRangeInMeters: 310000 }, + }, + settings: { targetStateOfChargeInPercent: 80, chargingCareMode: 'ACTIVATED' }, + isVehicleInSavedLocation: false, plug: { connectionState: 'CONNECTED' }, + }, + airConditioning: { + state: 'OFF', + targetTemperature: { temperatureValue: 22, unitInCar: 'CELSIUS' }, + estimatedDateTimeToReachTargetTemperature: null, + windowHeatingState: { front: 'OFF', rear: 'OFF' }, + }, + positions: { + positions: [{ type: 'VEHICLE', gpsCoordinates: { latitude: 51.0, longitude: 7.0 } }], + errors: [], + }, + health: { capturedAt: '2026-07-22T08:00:00Z', mileageInKm: 5210, warningLights: [] }, + maintenance: { + maintenanceReport: { inspectionDueInDays: 210, inspectionDueInKm: 24790 }, + preferredServicePartner: { name: 'Autohaus Test GmbH' }, + }, +}; diff --git a/tests/fixtures/skoda/idk_login_page.js b/tests/fixtures/skoda/idk_login_page.js new file mode 100644 index 00000000..94687c05 --- /dev/null +++ b/tests/fixtures/skoda/idk_login_page.js @@ -0,0 +1,19 @@ +'use strict'; +// Synthetic VW identity login page (structure per myskoda reference). +// Replace with a redacted live capture after the Task-3 spike if it deviates. +const emailPage = `Login + +`; + +const passwordPage = emailPage + .replace('hmac-abc', 'hmac-def') + .replace('"postAction":"login/identifier"', '"postAction":"login/authenticate"'); + +module.exports = { emailPage, passwordPage }; diff --git a/tests/helpers/setup.js b/tests/helpers/setup.js index ddad3bac..63a353af 100644 --- a/tests/helpers/setup.js +++ b/tests/helpers/setup.js @@ -78,6 +78,7 @@ async function setup() { share_links: true, access_windows: true, midea_integration: true, + skoda_integration: true, }); app = createApp(); diff --git a/tests/skoda_accounts.test.js b/tests/skoda_accounts.test.js new file mode 100644 index 00000000..f82eabf9 --- /dev/null +++ b/tests/skoda_accounts.test.js @@ -0,0 +1,77 @@ +'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('createAccount encrypts password at rest and redacts list', () => { + const acc = accounts.createAccount({ email: 'me@example.com', password: 'geheim' }); + assert.equal(acc.email, 'me@example.com'); + assert.equal(acc.has_credentials, true); + assert.equal('password' in acc, false); + const row = getDb().prepare('SELECT password_enc FROM skoda_accounts WHERE id = ?').get(acc.id); + assert.notEqual(row.password_enc, 'geheim'); + assert.match(row.password_enc, /^[0-9a-f]{24}:[0-9a-f]{32}:/); // iv:tag:enc format +}); + +test('duplicate email raises SKODA_ACCOUNT_EXISTS', () => { + accounts.createAccount({ email: 'a@b.c', password: 'x' }); + assert.throws(() => accounts.createAccount({ email: 'a@b.c', password: 'y' }), (e) => e.code === 'SKODA_ACCOUNT_EXISTS'); +}); + +test('empty or malformed fields raise SKODA_VALIDATION', () => { + assert.throws(() => accounts.createAccount({ email: '', password: 'x' }), (e) => e.code === 'SKODA_VALIDATION'); + assert.throws(() => accounts.createAccount({ email: 'keine-mail', password: 'x' }), (e) => e.code === 'SKODA_VALIDATION'); + assert.throws(() => accounts.createAccount({ email: 'a@b.c', password: '' }), (e) => e.code === 'SKODA_VALIDATION'); + // ReDoS guard: an overlong string must be rejected in bounded time (RFC 5321 cap) + assert.throws(() => accounts.createAccount({ email: `${'a'.repeat(9000)}@x`, password: 'x' }), (e) => e.code === 'SKODA_VALIDATION'); +}); + +test('setStatus caps status_detail length at 300 chars', () => { + const acc = accounts.createAccount({ email: 'cap@x.y', password: 'pw' }); + accounts.setStatus(acc.id, 'error', 'x'.repeat(1000)); + assert.equal(accounts.listAccounts().find((a) => a.id === acc.id).status_detail.length, 300); +}); + +test('getAccountWithSecrets decrypts password and session roundtrip', () => { + const acc = accounts.createAccount({ email: 'a@b.c', password: 'pw1' }); + accounts.saveSession(acc.id, { accessToken: 'AT', refreshToken: 'RT' }); + const full = accounts.getAccountWithSecrets(acc.id); + assert.equal(full.password, 'pw1'); + assert.deepEqual(full.session, { accessToken: 'AT', refreshToken: 'RT' }); +}); + +test('setStatus stores backoff and retry time; updatePassword resets them', () => { + const acc = accounts.createAccount({ email: 'a@b.c', password: 'pw1' }); + accounts.setStatus(acc.id, 'rate_limited', 'HTTP 429', { backoffMin: 60, nextRetryAt: '2026-07-22T12:00:00Z' }); + let listed = accounts.listAccounts()[0]; + assert.equal(listed.status, 'rate_limited'); + assert.equal(listed.next_retry_at, '2026-07-22T12:00:00Z'); + accounts.updatePassword(acc.id, 'pw2'); + listed = accounts.listAccounts()[0]; + assert.equal(listed.status, 'ok'); + assert.equal(listed.next_retry_at, null); + assert.equal(accounts.getAccountWithSecrets(acc.id).password, 'pw2'); +}); + +test('removeAccount cascades vehicles and owners', () => { + const acc = accounts.createAccount({ email: 'a@b.c', password: 'pw' }); + const db = getDb(); + db.prepare('INSERT INTO skoda_vehicles (account_id, vin) VALUES (?, ?)').run(acc.id, 'TMBX'); + const veh = db.prepare('SELECT id FROM skoda_vehicles WHERE vin = ?').get('TMBX'); + const admin = db.prepare("SELECT id FROM users WHERE role = 'admin'").get(); + db.prepare('INSERT INTO skoda_vehicle_owners (skoda_vehicle_id, user_id) VALUES (?, ?)').run(veh.id, admin.id); + accounts.removeAccount(acc.id); + assert.equal(db.prepare('SELECT COUNT(*) c FROM skoda_vehicles').get().c, 0); + assert.equal(db.prepare('SELECT COUNT(*) c FROM skoda_vehicle_owners').get().c, 0); +}); diff --git a/tests/skoda_api.test.js b/tests/skoda_api.test.js new file mode 100644 index 00000000..dc62c83e --- /dev/null +++ b/tests/skoda_api.test.js @@ -0,0 +1,132 @@ +'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 ctx; let accounts; let skoda; let getDb; + +before(async () => { + ctx = await setup(); + accounts = require('../src/services/skoda/skodaAccounts'); + skoda = require('../src/services/skoda'); + ({ getDb } = require('../src/db/connection')); +}); +after(async () => { skoda.stopPolling(); await teardown(); }); +beforeEach(() => { + skoda._resetForTest(); + for (const a of accounts.listAccounts()) accounts.removeAccount(a.id); +}); + +function seedVehicle() { + const acc = accounts.createAccount({ email: 'v@x.y', password: 'pw' }); + const db = getDb(); + db.prepare("INSERT INTO skoda_vehicles (account_id, vin, name, model, state_json, fetched_at, image, image_url) VALUES (?, 'TMBAPI', 'Elroq', 'Elroq', '{\"soc\":50}', datetime('now'), x'89504e47', 'u')").run(acc.id); + return db.prepare("SELECT id FROM skoda_vehicles WHERE vin='TMBAPI'").get().id; +} + +test('GET /api/v1/skoda returns accounts, vehicles, poll interval', async () => { + seedVehicle(); + const res = await ctx.agent.get('/api/v1/skoda'); + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(res.body.accounts.length, 1); + assert.equal('password_enc' in res.body.accounts[0], false); + assert.equal(res.body.vehicles[0].vin, 'TMBAPI'); + assert.equal(res.body.vehicles[0].state.soc, 50); + assert.equal(typeof res.body.poll_interval_min, 'number'); +}); + +test('POST /accounts validates and creates', async () => { + let res = await ctx.agent.post('/api/v1/skoda/accounts').set('x-csrf-token', ctx.csrfToken).send({ email: '', password: 'x' }); + assert.equal(res.status, 400); + res = await ctx.agent.post('/api/v1/skoda/accounts').set('x-csrf-token', ctx.csrfToken).send({ email: 'n@x.y', password: 'pw' }); + assert.equal(res.status, 201); + assert.equal(res.body.ok, true); + assert.equal('password' in res.body.account, false); + assert.equal('password_enc' in res.body.account, false); + assert.equal('session_enc' in res.body.account, false); + assert.equal(res.body.account.has_credentials, true); + res = await ctx.agent.post('/api/v1/skoda/accounts').set('x-csrf-token', ctx.csrfToken).send({ email: 'n@x.y', password: 'pw' }); + assert.equal(res.status, 409); +}); + +test('owners PUT validates users and vehicle', async () => { + const vid = seedVehicle(); + const admin = getDb().prepare("SELECT id FROM users WHERE role='admin'").get(); + let res = await ctx.agent.put(`/api/v1/skoda/vehicles/${vid}/owners`).set('x-csrf-token', ctx.csrfToken).send({ user_ids: [admin.id] }); + assert.equal(res.status, 200); + res = await ctx.agent.put(`/api/v1/skoda/vehicles/${vid}/owners`).set('x-csrf-token', ctx.csrfToken).send({ user_ids: [999999] }); + assert.equal(res.status, 400); + res = await ctx.agent.put('/api/v1/skoda/vehicles/999999/owners').set('x-csrf-token', ctx.csrfToken).send({ user_ids: [] }); + assert.equal(res.status, 404); +}); + +test('owners PUT rejects non-array user_ids with 400', async () => { + const vid = seedVehicle(); + const res = await ctx.agent.put(`/api/v1/skoda/vehicles/${vid}/owners`).set('x-csrf-token', ctx.csrfToken).send({ user_ids: 'nope' }); + assert.equal(res.status, 400); + assert.equal(res.body.code, 'SKODA_VALIDATION'); +}); + +test('PUT /accounts/:id updates password, DELETE removes account', async () => { + const created = await ctx.agent.post('/api/v1/skoda/accounts').set('x-csrf-token', ctx.csrfToken).send({ email: 'p@x.y', password: 'pw' }); + const id = created.body.account.id; + let res = await ctx.agent.put(`/api/v1/skoda/accounts/${id}`).set('x-csrf-token', ctx.csrfToken).send({ password: 'pw2' }); + assert.equal(res.status, 200); + res = await ctx.agent.put('/api/v1/skoda/accounts/999999').set('x-csrf-token', ctx.csrfToken).send({ password: 'x' }); + assert.equal(res.status, 404); + res = await ctx.agent.delete(`/api/v1/skoda/accounts/${id}`).set('x-csrf-token', ctx.csrfToken); + assert.equal(res.status, 200); + assert.equal(accounts.listAccounts().length, 0); +}); + +test('POST /accounts/:id/sync triggers a (mocked) sync', async () => { + const { mock } = require('node:test'); + const acc = accounts.createAccount({ email: 'sync@x.y', password: 'pw' }); + const m = mock.method(skoda, 'syncAccount', async () => ({ ok: true, vehicles: 0 })); + const res = await ctx.agent.post(`/api/v1/skoda/accounts/${acc.id}/sync`).set('x-csrf-token', ctx.csrfToken).send({}); + assert.equal(res.status, 200); + assert.equal(m.mock.callCount(), 1); + m.mock.restore(); +}); + +test('vehicle image served admin-only with content type', async () => { + const vid = seedVehicle(); + const res = await ctx.agent.get(`/api/v1/skoda/vehicles/${vid}/image`); + assert.equal(res.status, 200); + assert.match(res.headers['content-type'], /image\/png/); + const missing = await ctx.agent.get('/api/v1/skoda/vehicles/999999/image'); + assert.equal(missing.status, 404); +}); + +test('refresh cooldown maps to 429', async () => { + const { mock } = require('node:test'); + const vid = seedVehicle(); + // Mocked: the cooldown logic itself is covered in skoda_sync.test.js — here + // we only verify the error-code -> HTTP-status mapping, without any network. + const err = Object.assign(new Error('refresh cooldown active'), { code: 'SKODA_REFRESH_COOLDOWN' }); + const m = mock.method(skoda, 'refreshVehicle', async () => { throw err; }); + const res = await ctx.agent.post(`/api/v1/skoda/vehicles/${vid}/refresh`).set('x-csrf-token', ctx.csrfToken).send({}); + assert.equal(res.status, 429); + assert.equal(res.body.code, 'SKODA_REFRESH_COOLDOWN'); + m.mock.restore(); +}); + +test('settings PUT validates range and persists', async () => { + const { mock } = require('node:test'); + let res = await ctx.agent.put('/api/v1/skoda/settings').set('x-csrf-token', ctx.csrfToken).send({ poll_interval_min: 3 }); + assert.equal(res.status, 400); + const m = mock.method(skoda, 'syncAccount', async () => ({ ok: true, vehicles: 0 })); + res = await ctx.agent.put('/api/v1/skoda/settings').set('x-csrf-token', ctx.csrfToken).send({ poll_interval_min: 30 }); + assert.equal(res.status, 200); + assert.equal(require('../src/services/settings').get('skoda_poll_interval_min'), '30'); + assert.equal(m.mock.callCount(), 0); + m.mock.restore(); +}); + +test('unauthenticated request is rejected', async () => { + const supertest = require('supertest'); + const res = await supertest(ctx.app).get('/api/v1/skoda'); + assert.equal(res.status, 401); +}); diff --git a/tests/skoda_auth.test.js b/tests/skoda_auth.test.js new file mode 100644 index 00000000..eb51b64a --- /dev/null +++ b/tests/skoda_auth.test.js @@ -0,0 +1,108 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const auth = require('../src/services/skoda/skodaAuth'); +const { emailPage, passwordPage } = require('./fixtures/skoda/idk_login_page'); + +test('generatePkce returns base64url verifier and matching S256 challenge', () => { + const { verifier, challenge } = auth.generatePkce(); + assert.match(verifier, /^[A-Za-z0-9_-]{43}$/); + const expected = crypto.createHash('sha256').update(verifier).digest('base64url'); + assert.equal(challenge, expected); +}); + +test('parseIdk extracts csrf token and template model', () => { + const idk = auth.parseIdk(emailPage); + assert.equal(idk.csrfToken, 'csrf-123'); + assert.equal(idk.templateModel.hmac, 'hmac-abc'); + assert.equal(idk.templateModel.relayState, 'relay-xyz'); + assert.equal(idk.templateModel.postAction, 'login/identifier'); +}); + +test('parseIdk throws SKODA_AUTH_FLOW_CHANGED on unexpected html', () => { + assert.throws(() => auth.parseIdk('maintenance'), (e) => e.code === 'SKODA_AUTH_FLOW_CHANGED'); +}); + +test('parseFragment reads code from myskoda redirect', () => { + const params = auth.parseFragment('myskoda://redirect/login/#code=THECODE&token_type=bearer&id_token=IDT'); + assert.equal(params.code, 'THECODE'); + assert.equal(params.id_token, 'IDT'); +}); + +function htmlRes(body) { + return { status: 200, headers: new Headers({ 'content-type': 'text/html' }), text: async () => body, json: async () => ({}) }; +} +function redirectRes(location, setCookies = []) { + const h = new Headers({ location }); + for (const c of setCookies) h.append('set-cookie', c); + return { status: 302, headers: h, text: async () => '', json: async () => ({}) }; +} +function jsonRes(obj, status = 200) { + return { status, headers: new Headers({ 'content-type': 'application/json' }), json: async () => obj, text: async () => JSON.stringify(obj) }; +} + +test('login walks the full flow and exchanges the code', async () => { + const seen = []; + const fetchImpl = async (url, opts = {}) => { + seen.push({ url, method: opts.method || 'GET', body: opts.body }); + if (url.startsWith(auth.IDENT_BASE + '/oidc/v1/authorize')) return htmlRes(emailPage); + if (url.includes('/login/identifier')) return htmlRes(passwordPage); + if (url.includes('/login/authenticate')) return redirectRes(auth.IDENT_BASE + '/oidc/v1/oauth/sso?x=1', ['SESSION=s1']); + if (url.includes('/oidc/v1/oauth/sso')) return redirectRes('myskoda://redirect/login/#code=THECODE&id_token=IDT'); + if (url.startsWith(auth.API_BASE + '/api/v1/authentication/exchange-authorization-code')) { + return jsonRes({ accessToken: 'AT', refreshToken: 'RT', idToken: 'IDT' }); + } + throw new Error('unexpected url ' + url); + }; + const tokens = await auth.login('a@b.c', 'pw', { fetchImpl }); + assert.deepEqual(tokens, { accessToken: 'AT', refreshToken: 'RT', idToken: 'IDT' }); + const exchange = seen.find((s) => s.url.includes('exchange-authorization-code')); + const body = JSON.parse(exchange.body); + assert.equal(body.code, 'THECODE'); + assert.equal(body.redirectUri, auth.REDIRECT_URI); + assert.ok(body.verifier); + const identifierPost = seen.find((s) => s.url.includes('/login/identifier')); + assert.match(identifierPost.body, /email=a%40b.c/); + assert.match(identifierPost.body, /hmac=hmac-abc/); + const authPost = seen.find((s) => s.url.includes('/login/authenticate')); + assert.match(authPost.body, /hmac=hmac-def/); + assert.match(authPost.body, /password=pw/); +}); + +test('login maps terms-and-conditions redirect to SKODA_TERMS_REQUIRED', async () => { + const fetchImpl = async (url, opts = {}) => { + if (url.startsWith(auth.IDENT_BASE + '/oidc/v1/authorize')) return htmlRes(emailPage); + if (url.includes('/login/identifier')) return htmlRes(passwordPage); + if (url.includes('/login/authenticate')) return redirectRes(auth.IDENT_BASE + '/signin-service/v1/terms-and-conditions?x=1'); + return htmlRes('terms'); + }; + await assert.rejects(auth.login('a@b.c', 'pw', { fetchImpl }), (e) => e.code === 'SKODA_TERMS_REQUIRED'); +}); + +test('login maps wrong password (re-rendered login page) to SKODA_LOGIN_FAILED', async () => { + const fetchImpl = async (url, opts = {}) => { + if (url.startsWith(auth.IDENT_BASE + '/oidc/v1/authorize')) return htmlRes(emailPage); + if (url.includes('/login/identifier')) return htmlRes(passwordPage); + if (url.includes('/login/authenticate')) return htmlRes(passwordPage); // no redirect => login failed + throw new Error('unexpected ' + url); + }; + await assert.rejects(auth.login('a@b.c', 'wrong', { fetchImpl }), (e) => e.code === 'SKODA_LOGIN_FAILED'); +}); + +test('refresh posts refresh token and returns new tokens', async () => { + let seenBody = null; + const fetchImpl = async (url, opts = {}) => { + assert.ok(url.startsWith(auth.API_BASE + '/api/v1/authentication/refresh-token')); + seenBody = JSON.parse(opts.body); + return jsonRes({ accessToken: 'AT2', refreshToken: 'RT2', idToken: 'IDT2' }); + }; + const tokens = await auth.refresh('RT1', { fetchImpl }); + assert.equal(seenBody.token, 'RT1'); + assert.equal(tokens.accessToken, 'AT2'); +}); + +test('refresh maps 429 to SKODA_RATE_LIMITED', async () => { + const fetchImpl = async () => jsonRes({}, 429); + await assert.rejects(auth.refresh('RT1', { fetchImpl }), (e) => e.code === 'SKODA_RATE_LIMITED'); +}); diff --git a/tests/skoda_client.test.js b/tests/skoda_client.test.js new file mode 100644 index 00000000..c0b1e8e5 --- /dev/null +++ b/tests/skoda_client.test.js @@ -0,0 +1,133 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fx = require('./fixtures/skoda/api_responses'); +const { SkodaClient, SkodaApiError, normalizeVehicleState } = require('../src/services/skoda/skodaClient'); +const { API_BASE } = require('../src/services/skoda/skodaAuth'); + +function jsonRes(obj, status = 200) { + return { status, ok: status < 400, headers: new Headers({ 'content-type': 'application/json' }), json: async () => obj, arrayBuffer: async () => new ArrayBuffer(0) }; +} + +function makeClient(routes, { session = { accessToken: 'AT', refreshToken: 'RT' } } = {}) { + const saved = []; + const calls = []; + const fetchImpl = async (url, opts = {}) => { + calls.push({ url, auth: (opts.headers || {}).authorization }); + for (const [match, resOrFn] of routes) { + if (url.includes(match)) return typeof resOrFn === 'function' ? resOrFn(url, opts) : resOrFn; + } + throw new Error('unexpected url ' + url); + }; + const client = new SkodaClient({ getSession: () => session, saveSession: (s) => { saved.push(s); session = s; }, fetchImpl }); + return { client, saved, calls }; +} + +test('garage sends bearer token and returns json', async () => { + const { client, calls } = makeClient([['/api/v2/garage', jsonRes(fx.garage)]]); + const garage = await client.garage(); + assert.equal(garage.vehicles[0].vin, 'TMBTESTVIN000001'); + assert.equal(calls[0].auth, 'Bearer AT'); +}); + +test('401 triggers exactly one refresh then retry', async () => { + let statusCalls = 0; + const { client, saved, calls } = makeClient([ + ['/api/v2/vehicle-status/TMBTESTVIN000001', () => (statusCalls++ === 0 ? jsonRes({}, 401) : jsonRes(fx.status))], + ['/api/v1/authentication/refresh-token', jsonRes({ accessToken: 'AT2', refreshToken: 'RT2', idToken: 'ID2' })], + ]); + const status = await client.vehicleStatus('TMBTESTVIN000001'); + assert.equal(status.overall.locked, 'YES'); + assert.equal(statusCalls, 2); + assert.equal(saved[0].accessToken, 'AT2'); // refreshed session persisted + assert.equal(calls[calls.length - 1].auth, 'Bearer AT2'); // retried request uses refreshed token +}); + +test('second 401 after refresh raises SKODA_UNAUTHORIZED', async () => { + const { client } = makeClient([ + ['/api/v2/vehicle-status/', jsonRes({}, 401)], + ['/api/v1/authentication/refresh-token', jsonRes({ accessToken: 'AT2', refreshToken: 'RT2', idToken: 'ID2' })], + ]); + await assert.rejects(client.vehicleStatus('X'), (e) => e.code === 'SKODA_UNAUTHORIZED'); +}); + +test('failing refresh maps to SKODA_UNAUTHORIZED (no foreign error class)', async () => { + const { client } = makeClient([ + ['/api/v2/vehicle-status/', jsonRes({}, 401)], + ['/api/v1/authentication/refresh-token', jsonRes({}, 403)], + ]); + await assert.rejects(client.vehicleStatus('X'), (e) => e.code === 'SKODA_UNAUTHORIZED' && e.name === 'SkodaApiError'); +}); + +test('429 raises SKODA_RATE_LIMITED', async () => { + const { client } = makeClient([['/api/v1/charging/', jsonRes({}, 429)]]); + await assert.rejects(client.charging('X'), (e) => e.code === 'SKODA_RATE_LIMITED'); +}); + +test('normalizeVehicleState maps all fixture parts', () => { + const state = normalizeVehicleState({ + status: fx.status, drivingRange: fx.drivingRange, charging: fx.charging, + airConditioning: fx.airConditioning, position: fx.positions, health: fx.health, maintenance: fx.maintenance, + }); + assert.equal(state.locked, true); + assert.equal(state.doorsOpen, false); + assert.equal(state.lightsOn, false); + assert.equal(state.detail.trunk, 'CLOSED'); + assert.equal(state.soc, 74); + assert.equal(state.rangeKm, 310); + assert.equal(state.charging.state, 'CHARGING'); + assert.equal(state.charging.powerKw, 10.5); + assert.equal(state.charging.remainingMin, 95); + assert.equal(state.charging.targetPercent, 80); + assert.equal(state.charging.cableConnected, true); + assert.equal(state.climate.state, 'OFF'); + assert.equal(state.climate.targetC, 22); + assert.equal(state.climate.windowHeating, false); + assert.equal(state.position.lat, 51.0); + assert.equal(state.health.mileageKm, 5210); + assert.equal(state.maintenance.dueInDays, 210); + assert.equal(state.maintenance.partner, 'Autohaus Test GmbH'); + assert.equal(state.capturedAt, '2026-07-22T08:00:00Z'); +}); + +test('renderImage rejects non-allowlisted or non-https hosts', async () => { + const { client } = makeClient([]); + await assert.rejects(client.renderImage('https://evil.example/x.png'), (e) => e.code === 'SKODA_API_ERROR'); + await assert.rejects(client.renderImage('http://ip-modcwp.azureedge.net/x.png'), (e) => e.code === 'SKODA_API_ERROR'); + await assert.rejects(client.renderImage('nicht-mal-eine-url'), (e) => e.code === 'SKODA_API_ERROR'); +}); + +test('normalizeVehicleState tolerates missing parts with nulls', () => { + const state = normalizeVehicleState({ status: null, drivingRange: null, charging: null, airConditioning: null, position: null, health: null, maintenance: null }); + assert.equal(state.locked, null); + assert.equal(state.soc, null); + assert.deepEqual(state.health.warnings, []); + assert.equal(state.position, null); +}); + +test('climate remainingMin derives from minutes field or estimated datetime', () => { + const base = { state: 'HEATING', targetTemperature: { temperatureValue: 22 } }; + let state = normalizeVehicleState({ airConditioning: { ...base, remainingTimeToReachTargetTemperatureInMinutes: 12 } }); + assert.equal(state.climate.remainingMin, 12); + const eta = new Date(Date.now() + 30 * 60000).toISOString(); + state = normalizeVehicleState({ airConditioning: { ...base, estimatedDateTimeToReachTargetTemperature: eta } }); + assert.ok(state.climate.remainingMin >= 29 && state.climate.remainingMin <= 31); + state = normalizeVehicleState({ airConditioning: base }); + assert.equal(state.climate.remainingMin, null); +}); + +test('fetchFullState survives one failing endpoint', async () => { + const routes = [ + ['/api/v2/vehicle-status/V/driving-range', jsonRes(fx.drivingRange)], + ['/api/v2/vehicle-status/V', jsonRes(fx.status)], + ['/api/v1/charging/V', jsonRes({}, 500)], // this one fails + ['/api/v2/air-conditioning/V', jsonRes(fx.airConditioning)], + ['/api/v1/maps/positions', jsonRes(fx.positions)], + ['/api/v1/vehicle-health-report/warning-lights/V', jsonRes(fx.health)], + ['/api/v3/vehicle-maintenance/vehicles/V', jsonRes(fx.maintenance)], + ]; + const { client } = makeClient(routes); + const { state } = await client.fetchFullState('V'); + assert.equal(state.soc, 74); // from drivingRange + assert.equal(state.charging.state, null); // failed part -> nulls +}); diff --git a/tests/skoda_http.test.js b/tests/skoda_http.test.js new file mode 100644 index 00000000..2dfcf5be --- /dev/null +++ b/tests/skoda_http.test.js @@ -0,0 +1,64 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { CookieJar, requestWithJar, followRedirects } = require('../src/services/skoda/skodaHttp'); + +function fakeRes({ status = 200, headers = {}, setCookies = [] } = {}) { + const h = new Headers(headers); + for (const c of setCookies) h.append('set-cookie', c); + return { status, headers: h }; +} + +test('CookieJar stores cookies per host and builds header', () => { + const jar = new CookieJar(); + jar.storeFrom(fakeRes({ setCookies: ['SESSION=abc; Path=/; HttpOnly', 'csrf=x1; Path=/'] }), 'https://identity.vwgroup.io/oidc/v1/authorize'); + jar.storeFrom(fakeRes({ setCookies: ['other=zzz'] }), 'https://mysmob.api.connect.skoda-auto.cz/x'); + assert.equal(jar.headerFor('https://identity.vwgroup.io/signin'), 'SESSION=abc; csrf=x1'); + assert.equal(jar.headerFor('https://mysmob.api.connect.skoda-auto.cz/y'), 'other=zzz'); + assert.equal(jar.headerFor('https://example.com/'), null); +}); + +test('CookieJar overwrites cookie with same name', () => { + const jar = new CookieJar(); + jar.storeFrom(fakeRes({ setCookies: ['SESSION=old'] }), 'https://a.example/'); + jar.storeFrom(fakeRes({ setCookies: ['SESSION=new'] }), 'https://a.example/'); + assert.equal(jar.headerFor('https://a.example/'), 'SESSION=new'); +}); + +test('requestWithJar sends cookie header and stores new cookies', async () => { + const jar = new CookieJar(); + jar.storeFrom(fakeRes({ setCookies: ['a=1'] }), 'https://a.example/'); + let seenHeaders = null; + const fetchImpl = async (url, opts) => { seenHeaders = opts.headers; return fakeRes({ setCookies: ['b=2'] }); }; + await requestWithJar(jar, 'https://a.example/next', {}, fetchImpl); + assert.equal(seenHeaders.cookie, 'a=1'); + assert.equal(jar.headerFor('https://a.example/'), 'a=1; b=2'); +}); + +test('followRedirects follows 302 chain and stops at stopPrefix', async () => { + const jar = new CookieJar(); + const hops = [ + fakeRes({ status: 302, headers: { location: 'https://b.example/step2' }, setCookies: ['s1=1'] }), + fakeRes({ status: 302, headers: { location: '/step3' } }), + fakeRes({ status: 302, headers: { location: 'myskoda://redirect/login/#code=THECODE' } }), + ]; + let calls = []; + const fetchImpl = async (url, opts) => { calls.push({ url, method: opts.method || 'GET' }); return hops.shift(); }; + const { location } = await followRedirects(jar, 'https://a.example/start', { method: 'POST', body: 'x' }, + { stopPrefix: 'myskoda://', fetchImpl }); + assert.equal(location, 'myskoda://redirect/login/#code=THECODE'); + assert.equal(calls.length, 3); + assert.equal(calls[0].method, 'POST'); + assert.equal(calls[1].method, 'GET'); // Redirects werden als GET gefolgt + assert.equal(calls[1].url, 'https://b.example/step2'); + assert.equal(calls[2].url, 'https://b.example/step3'); // relative Location aufgelöst +}); + +test('followRedirects throws after maxHops', async () => { + const jar = new CookieJar(); + const fetchImpl = async () => fakeRes({ status: 302, headers: { location: 'https://a.example/loop' } }); + await assert.rejects( + followRedirects(jar, 'https://a.example/', {}, { maxHops: 3, fetchImpl }), + /too many redirects/ + ); +}); diff --git a/tests/skoda_i18n.test.js b/tests/skoda_i18n.test.js new file mode 100644 index 00000000..100c844f --- /dev/null +++ b/tests/skoda_i18n.test.js @@ -0,0 +1,33 @@ +'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 KEYS = [ + 'skoda.title', 'skoda.accounts.title', 'skoda.accounts.add', 'skoda.accounts.email', + 'skoda.accounts.password', 'skoda.accounts.status.ok', 'skoda.accounts.status.login_failed', + 'skoda.accounts.status.rate_limited', 'skoda.accounts.status.error', 'skoda.accounts.remove', + 'skoda.accounts.change_password', 'skoda.vehicles.title', 'skoda.vehicles.empty', + 'skoda.vehicle.soc', 'skoda.vehicle.range', 'skoda.vehicle.locked', 'skoda.vehicle.unlocked', + 'skoda.vehicle.refresh', 'skoda.vehicle.owners', 'skoda.vehicle.fetched', 'skoda.vehicle.mileage', + 'skoda.settings.poll_interval', 'skoda.owner.save', 'skoda.owner.title', + 'nav.skoda', 'skoda.error.cooldown', 'skoda.error.generic', +]; + +test('all skoda keys exist in de and en', () => { + for (const k of KEYS) { + assert.ok(de[k] && de[k].trim(), `de missing ${k}`); + assert.ok(en[k] && en[k].trim(), `en missing ${k}`); + } +}); + +test('client-side keys are in all three layout GC.t whitelists', () => { + const CLIENT_KEYS = KEYS.filter((k) => k.startsWith('skoda.')); + for (const theme of ['aurora', 'default', 'pro']) { + const layout = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'layout.njk'), 'utf8'); + for (const k of CLIENT_KEYS) assert.ok(layout.includes(`'${k}'`), `${theme} layout missing ${k}`); + } +}); diff --git a/tests/skoda_owners.test.js b/tests/skoda_owners.test.js new file mode 100644 index 00000000..d3d4c10e --- /dev/null +++ b/tests/skoda_owners.test.js @@ -0,0 +1,57 @@ +'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 owners, getDb, vehicleId, adminId, db; + +before(async () => { + await setup(); + owners = require('../src/services/skoda/skodaOwners'); + ({ getDb } = require('../src/db/connection')); + db = getDb(); + db.prepare("INSERT INTO skoda_accounts (email, password_enc) VALUES ('o@x.y', 'enc')").run(); + const acc = db.prepare("SELECT id FROM skoda_accounts WHERE email='o@x.y'").get(); + db.prepare("INSERT INTO skoda_vehicles (account_id, vin) VALUES (?, 'TMBOWN')").run(acc.id); + vehicleId = db.prepare("SELECT id FROM skoda_vehicles WHERE vin='TMBOWN'").get().id; + adminId = db.prepare("SELECT id FROM users WHERE role='admin'").get().id; +}); + +after(async () => { await teardown(); }); + +beforeEach(() => { owners.removeAllForVehicle(vehicleId); }); + +test('setOwners replaces assignment and isOwner reflects it', () => { + owners.setOwners(vehicleId, [adminId]); + assert.equal(owners.isOwner(vehicleId, adminId), true); + assert.deepEqual(owners.vehiclesOwnedBy(adminId), [vehicleId]); + owners.setOwners(vehicleId, []); + assert.equal(owners.isOwner(vehicleId, adminId), false); +}); + +test('unknown user rejected before write', () => { + assert.throws(() => owners.setOwners(vehicleId, [999999]), (e) => e.code === 'SKODA_OWNER_UNKNOWN_USER'); + assert.equal(owners.ownersOf(vehicleId).length, 0); +}); + +test('unknown vehicle rejected', () => { + assert.throws(() => owners.setOwners(999999, [adminId]), (e) => e.code === 'SKODA_VEHICLE_NOT_FOUND'); +}); + +test('ownersOf returns id and username', () => { + owners.setOwners(vehicleId, [adminId]); + const list = owners.ownersOf(vehicleId); + assert.equal(list.length, 1); + assert.equal(list[0].id, adminId); + assert.ok(list[0].username); +}); + +test('deleting a user removes their skoda owner rows', async () => { + const users = require('../src/services/users'); + const u = await users.create({ username: 'skoda-owner-tmp', password: 'pw12345678', role: 'user' }); + owners.setOwners(vehicleId, [u.id]); + users.remove(u.id); + assert.deepEqual(owners.vehiclesOwnedBy(u.id), []); +}); diff --git a/tests/skoda_page.test.js b/tests/skoda_page.test.js new file mode 100644 index 00000000..f1ad692b --- /dev/null +++ b/tests/skoda_page.test.js @@ -0,0 +1,16 @@ +'use strict'; +const { test, before, after } = 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; +before(async () => { ctx = await setup(); }); +after(async () => { await teardown(); }); + +test('GET /skoda renders the admin page', async () => { + const res = await ctx.agent.get('/skoda'); + assert.equal(res.status, 200); + assert.match(res.text, /skoda-page/); + assert.match(res.text, /\/js\/skoda\.js/); +}); diff --git a/tests/skoda_schema.test.js b/tests/skoda_schema.test.js new file mode 100644 index 00000000..b01d0a75 --- /dev/null +++ b/tests/skoda_schema.test.js @@ -0,0 +1,32 @@ +'use strict'; +const { test, before, after } = 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 getDb; + +before(async () => { await setup(); ({ getDb } = require('../src/db/connection')); }); +after(async () => { await teardown(); }); + +test('skoda tables exist with expected columns', () => { + const db = getDb(); + 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'] + ); + assert.deepEqual( + cols('skoda_vehicles').sort(), + ['account_id', 'created_at', 'fetched_at', 'id', 'image', 'image_url', 'model', 'name', 'state_json', 'vin'] + ); + assert.deepEqual(cols('skoda_vehicle_owners').sort(), ['created_at', 'skoda_vehicle_id', 'user_id']); +}); + +test('vin is unique', () => { + const db = getDb(); + db.prepare("INSERT INTO skoda_accounts (email, password_enc) VALUES ('u@x.y', 'enc')").run(); + const acc = db.prepare("SELECT id FROM skoda_accounts WHERE email = 'u@x.y'").get(); + db.prepare("INSERT INTO skoda_vehicles (account_id, vin) VALUES (?, 'TMB1')").run(acc.id); + assert.throws(() => db.prepare("INSERT INTO skoda_vehicles (account_id, vin) VALUES (?, 'TMB1')").run(acc.id)); +}); diff --git a/tests/skoda_sync.test.js b/tests/skoda_sync.test.js new file mode 100644 index 00000000..8a689403 --- /dev/null +++ b/tests/skoda_sync.test.js @@ -0,0 +1,155 @@ +'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'); +const fx = require('./fixtures/skoda/api_responses'); +let skoda; let accounts; let getDb; + +function jsonRes(obj, status = 200) { + return { status, ok: status < 400, headers: new Headers(), json: async () => obj, text: async () => JSON.stringify(obj), arrayBuffer: async () => new TextEncoder().encode('PNG').buffer }; +} + +// fetchImpl serving a full happy-path account: login already has a session, so +// only API calls happen. Login-flow paths are exercised in skoda_auth tests. +function apiFetch({ fail = {} } = {}) { + return async (url) => { + if (fail[Object.keys(fail).find((k) => url.includes(k))]) return jsonRes({}, fail[Object.keys(fail).find((k) => url.includes(k))]); + if (url.includes('/api/v2/garage/vehicles/')) return jsonRes(fx.garage.vehicles[0]); + if (url.includes('/api/v2/garage')) return jsonRes(fx.garage); + if (url.includes('/driving-range')) return jsonRes(fx.drivingRange); + if (url.includes('/api/v2/vehicle-status/')) return jsonRes(fx.status); + if (url.includes('/api/v1/charging/')) return jsonRes(fx.charging); + if (url.includes('/api/v2/air-conditioning/')) return jsonRes(fx.airConditioning); + if (url.includes('/api/v1/maps/positions')) return jsonRes(fx.positions); + if (url.includes('/warning-lights/')) return jsonRes(fx.health); + if (url.includes('/vehicle-maintenance/')) return jsonRes(fx.maintenance); + if (url.includes('render1.png')) return jsonRes({}); + throw new Error('unexpected ' + url); + }; +} + +before(async () => { + await setup(); + skoda = require('../src/services/skoda'); + accounts = require('../src/services/skoda/skodaAccounts'); + ({ getDb } = require('../src/db/connection')); +}); +after(async () => { skoda.stopPolling(); await teardown(); }); +beforeEach(() => { + skoda._resetForTest(); + for (const a of accounts.listAccounts()) accounts.removeAccount(a.id); +}); + +function seedAccountWithSession() { + const acc = accounts.createAccount({ email: 's@x.y', password: 'pw' }); + accounts.saveSession(acc.id, { accessToken: 'AT', refreshToken: 'RT' }); + return acc; +} + +test('syncAccount upserts vehicle with normalized state and image', async () => { + const acc = seedAccountWithSession(); + const result = await skoda.syncAccount(acc.id, { fetchImpl: apiFetch() }); + assert.equal(result.ok, true); + assert.equal(result.vehicles, 1); + const status = skoda.getStatus(); + const v = status.vehicles[0]; + assert.equal(v.vin, 'TMBTESTVIN000001'); + assert.equal(v.name, 'Elroq'); + assert.equal(v.state.soc, 74); + assert.ok(v.fetched_at); + assert.equal(v.has_image, true); + assert.equal('image' in v, false); // blob not in redacted listing + const img = skoda.getVehicleImage(v.id); + assert.ok(Buffer.isBuffer(img.image)); +}); + +test('sync twice does not duplicate vehicles', async () => { + const acc = seedAccountWithSession(); + await skoda.syncAccount(acc.id, { fetchImpl: apiFetch() }); + await skoda.syncAccount(acc.id, { fetchImpl: apiFetch() }); + assert.equal(getDb().prepare('SELECT COUNT(*) c FROM skoda_vehicles').get().c, 1); +}); + +test('429 sets rate_limited with 60min backoff, doubling capped at 240', async () => { + const acc = seedAccountWithSession(); + await skoda.syncAccount(acc.id, { fetchImpl: apiFetch({ fail: { '/api/v2/garage': 429 } }) }); + let a = accounts.listAccounts()[0]; + assert.equal(a.status, 'rate_limited'); + assert.ok(a.next_retry_at); + assert.equal(accounts.getAccountWithSecrets(acc.id).backoff_min, 60); + await skoda.syncAll({ fetchImpl: apiFetch({ fail: { '/api/v2/garage': 429 } }), ignoreRetryAt: true }); + assert.equal(accounts.getAccountWithSecrets(acc.id).backoff_min, 120); + await skoda.syncAll({ fetchImpl: apiFetch({ fail: { '/api/v2/garage': 429 } }), ignoreRetryAt: true }); + await skoda.syncAll({ fetchImpl: apiFetch({ fail: { '/api/v2/garage': 429 } }), ignoreRetryAt: true }); + assert.equal(accounts.getAccountWithSecrets(acc.id).backoff_min, 240); // capped +}); + +test('syncAll skips rate_limited account before next_retry_at and success resets backoff', async () => { + const acc = seedAccountWithSession(); + accounts.setStatus(acc.id, 'rate_limited', '429', { backoffMin: 60, nextRetryAt: '2999-01-01T00:00:00Z' }); + let called = false; + await skoda.syncAll({ fetchImpl: async (u) => { called = true; return apiFetch()(u); } }); + assert.equal(called, false); + accounts.setStatus(acc.id, 'rate_limited', '429', { backoffMin: 60, nextRetryAt: '2000-01-01T00:00:00Z' }); + await skoda.syncAll({ fetchImpl: apiFetch() }); + const a = accounts.listAccounts()[0]; + assert.equal(a.status, 'ok'); + assert.equal(accounts.getAccountWithSecrets(acc.id).backoff_min, 0); +}); + +test('refreshVehicle enforces 5 minute cooldown', async () => { + const acc = seedAccountWithSession(); + await skoda.syncAccount(acc.id, { fetchImpl: apiFetch() }); + const v = skoda.getStatus().vehicles[0]; + await skoda.refreshVehicle(v.id, { fetchImpl: apiFetch() }); + await assert.rejects(skoda.refreshVehicle(v.id, { fetchImpl: apiFetch() }), (e) => e.code === 'SKODA_REFRESH_COOLDOWN'); +}); + +test('concurrent syncs of the same account are serialized (account lock)', async () => { + const acc = seedAccountWithSession(); + let inFlight = 0; let maxInFlight = 0; + const slowFetch = async (url) => { + inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight -= 1; + return apiFetch()(url); + }; + await Promise.all([ + skoda.syncAccount(acc.id, { fetchImpl: slowFetch }), + skoda.syncAccount(acc.id, { fetchImpl: slowFetch }), + ]); + assert.equal(maxInFlight, 1); +}); + +test('vehicle missing from a later garage response keeps its data (spec rule)', async () => { + const acc = seedAccountWithSession(); + await skoda.syncAccount(acc.id, { fetchImpl: apiFetch() }); + const emptyGarage = async (url) => ( + url.includes('/api/v2/garage') && !url.includes('/garage/vehicles/') + ? jsonRes({ vehicles: [] }) + : apiFetch()(url) + ); + await skoda.syncAccount(acc.id, { fetchImpl: emptyGarage }); + const v = skoda.getStatus().vehicles[0]; + assert.equal(v.vin, 'TMBTESTVIN000001'); + assert.ok(v.state); + assert.ok(v.fetched_at); +}); + +test('corrupt session_enc marks account as error without breaking syncAll', async () => { + const acc = seedAccountWithSession(); + getDb().prepare('UPDATE skoda_accounts SET session_enc = ? WHERE id = ?').run('kaputt', acc.id); + await skoda.syncAll({ fetchImpl: apiFetch() }); // must not throw + assert.equal(accounts.listAccounts()[0].status, 'error'); +}); + +test('pollIntervalMs respects setting with a 5 minute floor', () => { + const settings = require('../src/services/settings'); + settings.set('skoda_poll_interval_min', '30'); + assert.equal(skoda.pollIntervalMs(), 30 * 60000); + settings.set('skoda_poll_interval_min', '1'); + assert.equal(skoda.pollIntervalMs(), 5 * 60000); + settings.set('skoda_poll_interval_min', '15'); +});