Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
19 changes: 19 additions & 0 deletions public/css/skoda.css
Original file line number Diff line number Diff line change
@@ -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 */
130 changes: 130 additions & 0 deletions public/js/skoda.js
Original file line number Diff line number Diff line change
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `<div class="skoda-account" data-id="${a.id}">
<span class="skoda-account-email">${esc(a.email)}</span>
<span class="skoda-badge skoda-badge-${esc(a.status)}" title="${esc(a.status_detail || '')}">${esc(statusText)}</span>
<button class="btn btn-sm" data-action="password">${T('skoda.accounts.change_password')}</button>
<button class="btn btn-sm btn-danger" data-action="remove">${T('skoda.accounts.remove')}</button>
</div>`;
}

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 `<div class="skoda-card" data-id="${v.id}">
${v.has_image ? `<img class="skoda-card-img" src="/api/v1/skoda/vehicles/${v.id}/image" alt="">` : ''}
<div class="skoda-card-head"><strong>${esc(v.name || v.model || v.vin)}</strong><span class="skoda-vin">${esc(v.vin)}</span></div>
<div class="skoda-card-stats">
<span>${T('skoda.vehicle.soc')}: ${s.soc ?? '—'}${s.soc != null ? '%' : ''}</span>
<span>${T('skoda.vehicle.range')}: ${s.rangeKm ?? '—'}${s.rangeKm != null ? ' km' : ''}</span>
<span>${lock}</span>
<span>${T('skoda.vehicle.mileage')}: ${s.health && s.health.mileageKm != null ? s.health.mileageKm + ' km' : '—'}</span>
</div>
<div class="skoda-card-meta">${esc(fetched)} · ${T('skoda.vehicle.owners')}: ${esc((v.owners || []).map((o) => o.username).join(', ') || '—')}</div>
<div class="skoda-card-actions">
<button class="btn btn-sm" data-action="owners">${T('skoda.vehicle.owners')}</button>
<button class="btn btn-sm" data-action="refresh">${T('skoda.vehicle.refresh')}</button>
</div>
</div>`;
}

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('') || `<p>${T('skoda.vehicles.empty')}</p>`;
}

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) =>
`<label class="skoda-owner-item"><input type="checkbox" value="${u.id}" ${owned.has(u.id) ? 'checked' : ''}> ${esc(u.username)}</label>`).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);
})();
50 changes: 50 additions & 0 deletions scripts/skoda-spike.js
Original file line number Diff line number Diff line change
@@ -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); });
37 changes: 37 additions & 0 deletions src/db/migrationList.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
27 changes: 27 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/routes/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading