Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
- Skoda Connect portal widget (TP2): logged-in portal users see their own
vehicles (lock, battery/charging, climate, position with address, health/
maintenance) read-only, owner-gated, with reverse-geocoded location.
- Skoda Connect vehicle control (TP3): climate, charging, charge limit, window
heating and lock/unlock (S-PIN) from the admin page and the portal (logged-in
owners only), with command validation, S-PIN gate and lock rate-limiting.

---

Expand Down
6 changes: 6 additions & 0 deletions public/css/portal.css
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,12 @@ body::before{
.sensor .v{font-family:var(--font-mono);font-size:19px;font-weight:700}
.sensor .l{color:var(--faint);font-size:11px;margin-top:3px}
.c-sh-msg{margin-top:10px;font-size:13px;color:var(--muted,#90a1b3)}
.skoda-cmds { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 12px; }
.skoda-cmds label { display: inline-flex; align-items: center; gap: 4px; font-size: .85em; color: var(--faint); }
.skoda-cmds button { padding: 5px 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-3); color: inherit; cursor: pointer; font-size: .85em; }
.skoda-cmds input[type="number"] { width: 58px; }
.skoda-cmds button.danger { background: color-mix(in srgb, var(--coral) 20%, transparent); border-color: var(--coral); color: var(--coral); }
.skoda-msg { color: var(--coral); margin-top: 8px; }

/* ============================================================
REDUCED MOTION
Expand Down
4 changes: 4 additions & 0 deletions public/css/skoda.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@
.skoda-card-actions { display: flex; gap: 8px; }
.skoda-owner-item { display: block; padding: 4px 0; }
.skoda-page .btn-sm{padding:6px 11px;font-size:12.5px;border-radius:9px} /* app.css lacks .btn-sm */
.skoda-cmds { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 12px; }
.skoda-cmds label { display: inline-flex; align-items: center; gap: 4px; font-size: .85em; }
.skoda-cmds input[type="number"] { width: 60px; }
.skoda-cmds .btn-danger, .skoda-cmds button.btn-danger { background: var(--danger, #da3633); color: #fff; border-color: var(--danger, #da3633); }
76 changes: 70 additions & 6 deletions public/js/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -884,7 +884,56 @@
return '<span class="skoda-chip' + (active ? ' open' : '') + '">' + escHtml(label) + '</span>';
}

function renderSkodaCard(v) {
// ponytail: watchdog matches skodaCommand's own 3s settle path; 30s only
// guards against a fetch that never resolves (dropped connection etc).
function skodaMsg(text) { var m = document.getElementById('skodaMsg'); if (m) { m.textContent = text; m.style.display = 'block'; } }
function skodaCommand(vehicleId, action, args, el) {
if (action === 'unlock' && !window.confirm(PT.skodaCmdConfirmUnlock)) return;
if (el && el.disabled) return; // in-flight → no command storm
var isBtn = el && el.tagName === 'BUTTON';
var restore = isBtn ? el.textContent : null;
var watchdog = null;
var reset = function () {
if (watchdog) { clearTimeout(watchdog); watchdog = null; }
if (el) { el.disabled = false; if (isBtn && restore != null) el.textContent = restore; }
};
if (el) { el.disabled = true; if (isBtn) el.textContent = PT.skodaCmdRunning; }
watchdog = setTimeout(reset, 30000);
fetch('/api/v1/portal/skoda/vehicles/' + vehicleId + '/command', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action, args: args || {} }),
}).then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (body) {
// reason (login_required/unavailable) comes as data:null,reason WITH ok:true → also a failure
if (!body || !body.ok || body.reason) skodaMsg(PT.skodaCmdFailed);
else setTimeout(hydrateSkoda, 3000);
})
.catch(function () { skodaMsg(PT.skodaCmdFailed); })
.then(function () { clearTimeout(watchdog); setTimeout(reset, 3000); });
}

var _skodaCmdBound = false;
function bindSkodaCommands() {
if (_skodaCmdBound) return; _skodaCmdBound = true;
var el = skodaEl(); if (!el) return;
el.addEventListener('click', function (ev) {
var b = ev.target.closest('button[data-cmd]'); if (!b) return;
var box = b.closest('.skoda-cmds'); if (!box) return;
var args = {};
if (b.dataset.cmd === 'ac_start' || b.dataset.cmd === 'ac_temp') {
var inp = box.querySelector('[data-temp-input]');
var tv = Number(inp && inp.value);
args = { temp: (isFinite(tv) && tv) ? tv : 21 };
} else if (b.dataset.temp) args = { temp: Number(b.dataset.temp) };
skodaCommand(Number(box.dataset.veh), b.dataset.cmd, args, b);
});
el.addEventListener('change', function (ev) {
var s = ev.target.closest('select[data-cmd="charge_limit"]'); if (!s) return;
skodaCommand(Number(s.closest('.skoda-cmds').dataset.veh), 'charge_limit', { limit: Number(s.value) }, s);
});
}

function renderSkodaCard(v, loggedIn) {
var s = v.state || {};
var ch = s.charging || {}, cl = s.climate || {}, hl = s.health || {}, mt = s.maintenance || {}, dt = s.detail || {};
var up = function (x) { return String(x || '').toUpperCase(); };
Expand Down Expand Up @@ -928,10 +977,24 @@
+ (mt.dueInKm != null ? ' · ' + numOr(mt.dueInKm, ' km') : '') + '</div>'
+ (mt.partner ? '<div>' + escHtml(PT.skodaPartner) + ': ' + escHtml(mt.partner) + '</div>' : '')
+ (hl.warnings && hl.warnings.length ? '<div>' + escHtml(PT.skodaWarnings) + ': ' + escHtml(hl.warnings.join(', ')) + '</div>' : '')
+ '</details></div>';
+ '</details>'
+ (loggedIn ? '<div class="skoda-cmds" data-veh="' + escHtml(v.id) + '">'
+ '<button data-cmd="ac_start" data-temp="21">' + escHtml(PT.skodaCmdAcOn) + '</button>'
+ '<button data-cmd="ac_stop">' + escHtml(PT.skodaCmdAcOff) + '</button>'
+ '<label>' + escHtml(PT.skodaCmdSetTemp) + ' <input type="number" min="16" max="30" step="1" value="21" data-temp-input></label>'
+ '<button data-cmd="ac_temp">' + escHtml(PT.skodaCmdSetTemp) + '</button>'
+ '<button data-cmd="charge_start">' + escHtml(PT.skodaCmdChargeOn) + '</button>'
+ '<button data-cmd="charge_stop">' + escHtml(PT.skodaCmdChargeOff) + '</button>'
+ '<label>' + escHtml(PT.skodaCmdChargeLimit) + ' <select data-cmd="charge_limit"><option>50</option><option>60</option><option>70</option><option>80</option><option>90</option><option>100</option></select></label>'
+ '<button data-cmd="window_heat_start">' + escHtml(PT.skodaCmdWindowHeat) + '</button>'
+ '<button data-cmd="window_heat_stop">' + escHtml(PT.skodaCmdWindowHeatOff) + '</button>'
+ '<button data-cmd="lock">' + escHtml(PT.skodaCmdLock) + '</button>'
+ '<button data-cmd="unlock" class="danger">' + escHtml(PT.skodaCmdUnlock) + '</button>'
+ '</div>' : '')
+ '</div>';
}

function renderSkoda(vehicles) {
function renderSkoda(vehicles, loggedIn) {
var el = skodaEl(); if (!el) return;
// Preserve which cards had their <details> expanded across the full rebuild,
// so a 120s poll never collapses what the user opened. Tracked by position
Expand All @@ -943,7 +1006,7 @@
var od = oldCards[i].querySelector('details');
wasOpen[i] = !!(od && od.open);
}
el.innerHTML = vehicles.map(renderSkodaCard).join('');
el.innerHTML = vehicles.map(function (v) { return renderSkodaCard(v, loggedIn); }).join('');
var newCards = el.querySelectorAll('.skoda-card');
for (var j = 0; j < newCards.length; j++) {
if (wasOpen[j]) { var nd = newCards[j].querySelector('details'); if (nd) nd.open = true; }
Expand All @@ -954,7 +1017,8 @@
var card = document.querySelector('.c-skoda'); if (!card) return;
fetch('/api/v1/portal/skoda').then(function (r) { return r.status === 404 ? null : r.json(); }).then(function (body) {
if (!body || !body.ok || body.data === null) { card.style.display = 'none'; return; }
renderSkoda(body.data.vehicles);
renderSkoda(body.data.vehicles, body.data.loggedIn);
bindSkodaCommands();
startSkodaPoll(); // start polling only once we have data (mirrors hydrateMidea)
}).catch(function () { card.style.display = 'none'; });
}
Expand All @@ -967,7 +1031,7 @@
// Poll refresh: render on success, keep the last good cards on any
// failure/null — a transient hiccup must never blank the whole section.
fetch('/api/v1/portal/skoda').then(function (r) { return r.json(); }).then(function (body) {
if (body && body.ok && body.data) renderSkoda(body.data.vehicles);
if (body && body.ok && body.data) renderSkoda(body.data.vehicles, body.data.loggedIn);
}).catch(function () {});
}, 120000);
}
Expand Down
63 changes: 63 additions & 0 deletions public/js/skoda.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
const hideModal = (id) => { el(id).style.display = 'none'; };
let current = { accounts: [], vehicles: [] };
let ownerVehicleId = null;
let spinAccountId = null;

function accountRow(a) {
const statusKey = `skoda.accounts.status.${a.status}`;
Expand All @@ -36,6 +37,7 @@
<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" data-action="spin">${T('skoda.cmd.spin')}</button>
<button class="btn btn-sm btn-danger" data-action="remove">${T('skoda.accounts.remove')}</button>
</div>`;
}
Expand All @@ -59,9 +61,41 @@
<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 class="skoda-cmds" data-veh="${v.id}">
<button class="btn btn-sm" data-cmd="ac_start" data-temp="21">${T('skoda.cmd.ac_on')}</button>
<button class="btn btn-sm" data-cmd="ac_stop">${T('skoda.cmd.ac_off')}</button>
<label>${T('skoda.cmd.set_temp')} <input type="number" min="16" max="30" step="1" value="21" data-temp-input></label>
<button class="btn btn-sm" data-cmd="ac_temp">${T('skoda.cmd.set_temp')}</button>
<button class="btn btn-sm" data-cmd="charge_start">${T('skoda.cmd.charge_on')}</button>
<button class="btn btn-sm" data-cmd="charge_stop">${T('skoda.cmd.charge_off')}</button>
<button class="btn btn-sm" data-cmd="window_heat_start">${T('skoda.cmd.window_heat_on')}</button>
<button class="btn btn-sm" data-cmd="window_heat_stop">${T('skoda.cmd.window_heat_off')}</button>
<button class="btn btn-sm" data-cmd="lock">${T('skoda.cmd.lock')}</button>
<button class="btn btn-sm btn-danger" data-cmd="unlock">${T('skoda.cmd.unlock')}</button>
<label>${T('skoda.cmd.set_limit')} <select data-cmd="charge_limit"><option>50</option><option>60</option><option>70</option><option>80</option><option>90</option><option>100</option></select></label>
</div>
</div>`;
}

async function command(vehicleId, action, args, el) {
if (action === 'unlock' && !confirm(T('skoda.cmd.confirm_unlock'))) return;
if (el && el.disabled) return; // already in flight → no command storm
var isBtn = el && el.tagName === 'BUTTON';
var restore = isBtn ? el.textContent : null;
var reset = function () { if (el) { el.disabled = false; if (isBtn && restore != null) el.textContent = restore; } };
if (el) { el.disabled = true; if (isBtn) el.textContent = T('skoda.cmd.running'); }
var watchdog = setTimeout(reset, 30000); // hard fallback if the request never settles
try {
await api('POST', `/vehicles/${vehicleId}/command`, { action, args: args || {} });
setTimeout(load, 3000); // let the 30s post-command refresh begin; reload state
} catch (e) {
alert(e.code === 'SKODA_SPIN_REQUIRED' ? T('skoda.cmd.spin') + '?' : (e.message || T('skoda.cmd.failed')));
} finally {
clearTimeout(watchdog);
setTimeout(reset, 3000);
}
}

async function load() {
current = await api('GET', '');
el('skoda-poll-interval').value = current.poll_interval_min;
Expand Down Expand Up @@ -96,6 +130,19 @@
await load();
}
}
if (btn.dataset.action === 'spin') {
spinAccountId = id;
el('skoda-spin-input').value = '';
showModal('skoda-spin-modal');
}
} catch (e) { fail(e); }
});

el('skoda-spin-cancel').addEventListener('click', () => hideModal('skoda-spin-modal'));
el('skoda-spin-save').addEventListener('click', async () => {
try {
await api('PUT', `/accounts/${spinAccountId}/spin`, { spin: el('skoda-spin-input').value });
hideModal('skoda-spin-modal');
} catch (e) { fail(e); }
});

Expand All @@ -115,6 +162,22 @@
} catch (e) { fail(e); }
});

el('skoda-vehicles').addEventListener('click', (ev) => {
const b = ev.target.closest('button[data-cmd]'); if (!b) return;
const box = b.closest('.skoda-cmds'); const veh = Number(box.dataset.veh);
let args = {};
if (b.dataset.cmd === 'ac_start' || b.dataset.cmd === 'ac_temp') {
var inp = box.querySelector('[data-temp-input]');
var tv = Number(inp && inp.value);
args = { temp: (isFinite(tv) && tv) ? tv : 21 };
} else if (b.dataset.temp) args = { temp: Number(b.dataset.temp) };
command(veh, b.dataset.cmd, args, b);
});
el('skoda-vehicles').addEventListener('change', (ev) => {
const s = ev.target.closest('select[data-cmd="charge_limit"]'); if (!s) return;
command(Number(s.closest('.skoda-cmds').dataset.veh), 'charge_limit', { limit: Number(s.value) }, s);
});

el('skoda-owner-cancel').addEventListener('click', () => hideModal('skoda-owner-modal'));
el('skoda-owner-save').addEventListener('click', async () => {
const ids = [...el('skoda-owner-list').querySelectorAll('input:checked')].map((i) => Number(i.value));
Expand Down
6 changes: 6 additions & 0 deletions src/db/migrationList.js
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,12 @@ const migrations = [
CREATE INDEX IF NOT EXISTS idx_skoda_owners_user ON skoda_vehicle_owners(user_id);`,
detect: (db) => tableExists(db, 'skoda_accounts'),
},
{
version: 67,
name: 'skoda_account_spin',
sql: `ALTER TABLE skoda_accounts ADD COLUMN spin_enc TEXT;`,
detect: (db) => hasColumn(db, 'skoda_accounts', 'spin_enc'),
},
];

module.exports = { migrations };
28 changes: 28 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2226,6 +2226,21 @@
"skoda.owner.save": "Speichern",
"skoda.error.cooldown": "Bitte warten — Aktualisierung erst in ein paar Minuten wieder möglich.",
"skoda.error.generic": "Aktion fehlgeschlagen",
"skoda.cmd.ac_on": "Klima an",
"skoda.cmd.ac_off": "Klima aus",
"skoda.cmd.charge_on": "Laden",
"skoda.cmd.charge_off": "Laden stoppen",
"skoda.cmd.window_heat_on": "Scheibenheizung an",
"skoda.cmd.window_heat_off": "Scheibenheizung aus",
"skoda.cmd.lock": "Verriegeln",
"skoda.cmd.unlock": "Entriegeln",
"skoda.cmd.set_temp": "Zieltemperatur",
"skoda.cmd.set_limit": "Ladelimit",
"skoda.cmd.confirm_unlock": "Fahrzeug wirklich entriegeln?",
"skoda.cmd.running": "läuft…",
"skoda.cmd.failed": "Befehl fehlgeschlagen",
"skoda.cmd.spin": "S-PIN",
"skoda.cmd.spin_set": "S-PIN speichern",
"portal.midea.fan": "Lüfter",
"portal.midea.fan_auto": "Auto",
"portal.midea.fan_silent": "Silent",
Expand Down Expand Up @@ -2260,6 +2275,19 @@
"portal.skoda.lights_on": "Licht an",
"portal.skoda.climate_on": "An",
"portal.skoda.climate_off": "Aus",
"portal.skoda.cmd_ac_on": "Klima an",
"portal.skoda.cmd_ac_off": "Klima aus",
"portal.skoda.cmd_set_temp": "Zieltemperatur",
"portal.skoda.cmd_charge_on": "Laden",
"portal.skoda.cmd_charge_off": "Laden stoppen",
"portal.skoda.cmd_charge_limit": "Ladelimit",
"portal.skoda.cmd_window_heat": "Scheibenheizung an",
"portal.skoda.cmd_window_heat_off": "Scheibenheizung aus",
"portal.skoda.cmd_lock": "Verriegeln",
"portal.skoda.cmd_unlock": "Entriegeln",
"portal.skoda.cmd_confirm_unlock": "Fahrzeug wirklich entriegeln?",
"portal.skoda.cmd_running": "läuft…",
"portal.skoda.cmd_failed": "Befehl fehlgeschlagen",
"portal.smarthome.title": "Smart Home",
"portal.smarthome.power": "Ein/Aus",
"portal.smarthome.brightness": "Helligkeit",
Expand Down
28 changes: 28 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2282,6 +2282,21 @@
"skoda.owner.save": "Save",
"skoda.error.cooldown": "Please wait — refresh available again in a few minutes.",
"skoda.error.generic": "Action failed",
"skoda.cmd.ac_on": "AC on",
"skoda.cmd.ac_off": "AC off",
"skoda.cmd.charge_on": "Charge",
"skoda.cmd.charge_off": "Stop charging",
"skoda.cmd.window_heat_on": "Window heating on",
"skoda.cmd.window_heat_off": "Window heating off",
"skoda.cmd.lock": "Lock",
"skoda.cmd.unlock": "Unlock",
"skoda.cmd.set_temp": "Target temp",
"skoda.cmd.set_limit": "Charge limit",
"skoda.cmd.confirm_unlock": "Really unlock the vehicle?",
"skoda.cmd.running": "running…",
"skoda.cmd.failed": "Command failed",
"skoda.cmd.spin": "S-PIN",
"skoda.cmd.spin_set": "Save S-PIN",
"portal.midea.fan": "Fan",
"portal.midea.fan_auto": "Auto",
"portal.midea.fan_silent": "Silent",
Expand Down Expand Up @@ -2316,6 +2331,19 @@
"portal.skoda.lights_on": "Lights on",
"portal.skoda.climate_on": "On",
"portal.skoda.climate_off": "Off",
"portal.skoda.cmd_ac_on": "AC on",
"portal.skoda.cmd_ac_off": "AC off",
"portal.skoda.cmd_set_temp": "Target temp",
"portal.skoda.cmd_charge_on": "Charge",
"portal.skoda.cmd_charge_off": "Stop charging",
"portal.skoda.cmd_charge_limit": "Charge limit",
"portal.skoda.cmd_window_heat": "Window heating on",
"portal.skoda.cmd_window_heat_off": "Window heating off",
"portal.skoda.cmd_lock": "Lock",
"portal.skoda.cmd_unlock": "Unlock",
"portal.skoda.cmd_confirm_unlock": "Really unlock the vehicle?",
"portal.skoda.cmd_running": "running…",
"portal.skoda.cmd_failed": "Command failed",
"portal.smarthome.title": "Smart Home",
"portal.smarthome.power": "Power",
"portal.smarthome.brightness": "Brightness",
Expand Down
19 changes: 19 additions & 0 deletions src/routes/api/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const skoda = require('../../services/skoda');
const skodaOwners = require('../../services/skoda/skodaOwners');
const skodaVehicles = require('../../services/skoda/skodaVehicles');
const skodaPortal = require('../../services/skoda/skodaPortal');
const skodaControl = require('../../services/skoda/skodaControl');

const router = Router();

Expand Down Expand Up @@ -343,6 +344,24 @@ router.get('/skoda/vehicles/:id/image', (req, res) => {
}
});

// POST /skoda/vehicles/:id/command — control, login-required + owner-gated.
const SKODA_CMD_STATUS = { SKODA_UNKNOWN_COMMAND: 400, SKODA_VALIDATION: 400, SKODA_SPIN_REQUIRED: 409, SKODA_NO_SESSION: 409, SKODA_COMMAND_RATE_LIMIT: 429, SKODA_RATE_LIMITED: 429, SKODA_VEHICLE_NOT_FOUND: 404 };
router.post('/skoda/vehicles/:id/command', async (req, res) => {
try {
if (!portalConfig().widgets.skoda) return res.status(404).json({ ok: false });
if (skodaUnavailable()) return res.json({ ok: true, data: null, reason: 'unavailable' });
// Not logged in → 200 + reason (exakt wie POST /midea/:id/state), NICHT 401.
if (!req.portalLoggedIn) return res.json({ ok: true, data: null, reason: 'login_required' });
const id = Number(req.params.id);
if (!skodaOwners.isOwner(id, req.session.userId)) return res.status(403).json({ ok: false, error: 'SKODA_NOT_OWNER' });
await skodaControl.runCommand(id, req.body.action, req.body.args || {});
res.json({ ok: true });
} catch (err) {
const status = SKODA_CMD_STATUS[err.code] || 502;
res.status(status).json({ ok: false, error: err.code || 'command failed' });
}
});

function smarthomeUnavailable() {
return !license.hasFeature('smarthome');
}
Expand Down
Loading
Loading