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 `
${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 %} +