iobroker.vw-connect
Version:
Adapter for VW Connect
410 lines (379 loc) • 14.1 kB
JavaScript
;
/**
* Tibber Data API client.
*
* Tibber is a commercial third party that has direct OAuth access to the
* VW Group fleet API and exposes vehicle telemetry (SoC, range, plug,
* charging) under their Data API. This client implements the auth-code +
* PKCE OAuth2 flow, mirrors what evcc does in its Tibber vehicle plugin
* (see PR evcc-io/evcc#30487), and reads the device endpoint.
*
* docs: https://data-api.tibber.com/docs/
* auth: https://thewall.tibber.com/connect/authorize (PKCE required)
* token: https://thewall.tibber.com/connect/token
* data: https://data-api.tibber.com/v1
*
* Tibber's authorize endpoint requires a `code_challenge` (S256 PKCE).
* To keep the user setup simple and the URL pre-computable in the admin
* UI, we use a FIXED verifier baked into the adapter. This is technically
* weaker than per-flow random PKCE but is acceptable here:
* - the token endpoint also requires the client_secret, so a leaked
* verifier alone is not enough for an attacker.
* - rotating the verifier per flow would require a backend round-trip
* for the admin UI, which ioBroker config pages don't have.
* Same trade-off as embedded mobile apps where the verifier is in the
* APK.
*/
const https = require("https");
const { URLSearchParams } = require("url");
const TOKEN_URL = "https://thewall.tibber.com/connect/token";
const AUTH_URL = "https://thewall.tibber.com/connect/authorize";
const API_BASE = "https://data-api.tibber.com/v1";
// Adapter always uses http://localhost/ — that's the redirect URI users
// register in their Tibber OAuth client. Tibber matches it byte-exact
// (with trailing slash).
const REDIRECT_URI = "http://localhost/";
const SCOPES = [
"openid",
"profile",
"email",
"offline_access",
"data-api-user-read",
"data-api-homes-read",
"data-api-vehicles-read",
"data-api-chargers-read",
"data-api-energy-systems-read",
"data-api-thermostats-read",
"data-api-inverters-read",
].join(" ");
// Fixed PKCE pair. Verifier is the secret known only to this adapter,
// challenge is the SHA-256 base64url hash that gets sent to Tibber's
// authorize endpoint. See module header for the rationale.
//
// IMPORTANT: PKCE_CHALLENGE is duplicated in admin/index_m.html (TIBBER_CHALLENGE
// in the inline script that builds the authorize URL for the user). If you
// change one, change the other or the OAuth flow silently fails with
// invalid_grant on the exchange step.
// grep -rn 'Oey1jcnhbUa' lib/ admin/
const PKCE_VERIFIER = "9865PlBfOdFKw3itj8kQSAFA0oVs6AVX5oMo5tr7Nts11e9YUHx0_BJrTryw_D7C";
const PKCE_CHALLENGE = "Oey1jcnhbUa_fxI9A2NtdVrIk-QxD-9ARobHcVpOj7A";
// --- Tibber Data API capability ids (verbatim from evcc PR #30487) -------
const CAP_SOC = "storage.stateOfCharge"; // %
const CAP_TARGET_SOC = "storage.targetStateOfCharge"; // %
const CAP_RANGE = "range.remaining"; // distance, typically m
const CAP_CONNECTOR = "connector.status"; // connected/disconnected/unknown
const CAP_CHARGING = "charging.status"; // charging/idle/unknown
const KM_PER_MILE = 1.609344;
// --- HTTP helpers --------------------------------------------------------
function request(url, opts) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = https.request(
{
method: opts.method || "GET",
hostname: u.hostname,
path: u.pathname + u.search,
headers: opts.headers || {},
// Don't hang forever on a stalled Tibber backend — a missing
// timeout means we rely on the OS TCP timeout (often minutes).
timeout: 30000,
},
(resp) => {
let body = "";
resp.on("data", (c) => (body += c));
resp.on("end", () => resolve({ status: resp.statusCode, body }));
},
);
req.on("error", reject);
req.on("timeout", () => {
req.destroy(new Error(`Tibber request timed out after 30s: ${url}`));
});
if (opts.body) req.write(opts.body);
req.end();
});
}
async function postForm(url, params) {
const r = await request(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams(params).toString(),
});
if (r.status !== 200) {
throw new Error(`POST ${url} -> HTTP ${r.status} body=${r.body.slice(0, 300)}`);
}
try {
return JSON.parse(r.body);
} catch (err) {
throw new Error(`Invalid JSON from ${url}: ${err.message}`, { cause: err });
}
}
async function getJson(url, accessToken) {
const r = await request(url, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
if (r.status === 401) {
const e = new Error(`GET ${url} -> HTTP 401 (unauthorized)`);
e.code = "UNAUTHORIZED";
throw e;
}
if (r.status !== 200) {
throw new Error(`GET ${url} -> HTTP ${r.status} body=${r.body.slice(0, 300)}`);
}
try {
return JSON.parse(r.body);
} catch (err) {
throw new Error(`Invalid JSON from ${url}: ${err.message}`, { cause: err });
}
}
// --- public helpers ------------------------------------------------------
function buildAuthorizeUrl(clientId) {
const p = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: REDIRECT_URI,
scope: SCOPES,
state: "iobroker",
code_challenge: PKCE_CHALLENGE,
code_challenge_method: "S256",
});
return `${AUTH_URL}?${p.toString()}`;
}
/**
* Exchange an authorization code (from the browser-redirect URL) for
* access_token + refresh_token. Caller is responsible for persisting the
* refresh_token (Tibber rotates it on every refresh).
*/
async function exchangeCode(clientId, clientSecret, code) {
return postForm(TOKEN_URL, {
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
client_id: clientId,
client_secret: clientSecret,
code_verifier: PKCE_VERIFIER,
});
}
/**
* Refresh tokens. Returns {access_token, refresh_token, expires_in, ...}.
* The new refresh_token replaces the old one (which Tibber invalidates).
*/
async function refreshTokens(clientId, clientSecret, refreshToken) {
return postForm(TOKEN_URL, {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
});
}
// --- API surface ---------------------------------------------------------
class TibberClient {
/**
* @param {object} opts
* @param {string} opts.clientId
* @param {string} opts.clientSecret
* @param {string} opts.refreshToken Persisted across restarts.
* @param {function} opts.onRefreshToken Called with the NEW refresh_token
* every time refreshTokens is called.
* Must persist it durably.
* @param {object} [opts.log]
*/
constructor(opts) {
this.clientId = opts.clientId;
this.clientSecret = opts.clientSecret;
this.refreshToken = opts.refreshToken;
this.onRefreshToken = opts.onRefreshToken || (() => {});
this.log = opts.log || { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
this._accessToken = null;
this._accessExpiresAt = 0;
}
/**
* Returns a valid access token, refreshing if needed. Persists the new
* refresh_token via the onRefreshToken callback.
*/
async _accessTokenFresh() {
// 60s safety margin so we don't issue a request just as the token
// expires.
if (this._accessToken && Date.now() < this._accessExpiresAt - 60000) {
return this._accessToken;
}
if (!this.refreshToken) {
throw new Error("Tibber: no refresh token available — run the OAuth flow first");
}
const tokens = await refreshTokens(this.clientId, this.clientSecret, this.refreshToken);
if (!tokens.access_token || !tokens.refresh_token) {
throw new Error(`Tibber refresh response missing tokens: ${JSON.stringify(tokens)}`);
}
this._accessToken = tokens.access_token;
this._accessExpiresAt = Date.now() + (tokens.expires_in || 3600) * 1000;
this.refreshToken = tokens.refresh_token;
try {
await this.onRefreshToken(tokens.refresh_token);
} catch (err) {
this.log.warn(`Tibber: refresh-token persistence failed: ${err.message || err}`);
}
this.log.debug(
`[tibber] refreshed access token, expires in ${tokens.expires_in}s, rotated refresh token`,
);
return this._accessToken;
}
async _get(path) {
const token = await this._accessTokenFresh();
try {
return await getJson(`${API_BASE}${path}`, token);
} catch (err) {
// 401 means our cached access token went stale unexpectedly. Force
// a refresh + retry once.
if (err && err.code === "UNAUTHORIZED") {
this._accessToken = null;
this._accessExpiresAt = 0;
const token2 = await this._accessTokenFresh();
return getJson(`${API_BASE}${path}`, token2);
}
throw err;
}
}
async listHomes() {
const r = await this._get("/homes");
return Array.isArray(r) ? r : (r && Array.isArray(r.homes)) ? r.homes : [];
}
async listDevices(homeId) {
const r = await this._get(`/homes/${encodeURIComponent(homeId)}/devices`);
return Array.isArray(r) ? r : (r && Array.isArray(r.devices)) ? r.devices : [];
}
async getDevice(homeId, deviceId) {
return this._get(`/homes/${encodeURIComponent(homeId)}/devices/${encodeURIComponent(deviceId)}`);
}
/**
* Walk all homes and return de-duplicated devices (vehicles only when the
* external_id has a vendor:vin shape, others ignored). Mirrors
* vehicle/tibber/api.go in evcc.
*/
async listVehicles() {
const homes = await this.listHomes();
const seen = new Set();
const out = [];
for (const h of homes) {
let devices;
try {
devices = await this.listDevices(h.id);
} catch (err) {
this.log.warn(`[tibber] listDevices(${h.id}) failed: ${err.message || err}`);
continue;
}
for (const d of devices) {
const key = d.id || d.externalId || d.external_id;
if (!key || seen.has(key)) continue;
seen.add(key);
out.push({ ...d, homeId: h.id, homeName: h.name || h.displayName });
}
}
return out;
}
}
// --- capability extraction (mirrors evcc tibber/api.go) ------------------
function _findCap(detail, id) {
const caps = (detail && (detail.capabilities || detail.Capabilities)) || [];
for (const c of caps) {
if (c.id === id || c.ID === id) return c;
}
return null;
}
function _capNumber(detail, id) {
const c = _findCap(detail, id);
if (!c) return null;
const v = c.value !== undefined ? c.value : c.Value;
if (v === null || v === undefined) return null;
const n = typeof v === "number" ? v : parseFloat(v);
return Number.isFinite(n) ? n : null;
}
function _capString(detail, id) {
const c = _findCap(detail, id);
if (!c) return null;
const v = c.value !== undefined ? c.value : c.Value;
return typeof v === "string" ? v : null;
}
function _capUnit(detail, id) {
const c = _findCap(detail, id);
if (!c) return null;
return c.unit || c.Unit || null;
}
/**
* Convert a raw Tibber device-detail response into a flat object suitable
* for json2iob, with extras (range_km, plug/charging strings) the user
* actually wants.
*/
function normalizeDevice(detail) {
const out = {
id: detail.id || null,
externalId: detail.externalId || detail.external_id || null,
homeId: detail.homeId || null,
homeName: detail.homeName || null,
info: detail.info || detail.Info || {},
soc: _capNumber(detail, CAP_SOC),
targetSoc: _capNumber(detail, CAP_TARGET_SOC),
plugStatus: _capString(detail, CAP_CONNECTOR),
chargingStatus: _capString(detail, CAP_CHARGING),
};
// Range: API delivers in meters (default), miles or km depending on unit.
// evcc normalises to km.
const rRaw = _capNumber(detail, CAP_RANGE);
if (rRaw !== null) {
const unit = (_capUnit(detail, CAP_RANGE) || "").toLowerCase();
if (unit === "m") out.rangeKm = rRaw / 1000;
else if (unit === "mi" || unit === "mile" || unit === "miles") out.rangeKm = rRaw * KM_PER_MILE;
else if (unit === "km" || unit === "kilometre" || unit === "kilometres") out.rangeKm = rRaw;
else out.rangeKm = rRaw / 1000; // assume m as default per evcc comment
} else {
out.rangeKm = null;
}
// Pass through every capability raw too (under "capabilities") so users
// who want exotic fields don't have to fork the adapter.
const rawCaps = {};
for (const c of detail.capabilities || detail.Capabilities || []) {
const id = c.id || c.ID;
if (!id) continue;
rawCaps[id.replace(/\./g, "_")] = c.value !== undefined ? c.value : c.Value;
}
out.capabilities = rawCaps;
return out;
}
/**
* Extract a usable, ioBroker-safe identifier from the Tibber device's
* external_id. Format observed in the wild is "vendor:VIN" (e.g.
* "tesla:5YJSA1E26MF1234567"); we strip the optional "vendor:" prefix.
*
* Returns the bare VIN (or whatever the external_id was after the
* colon), with characters that ioBroker rejects in object IDs replaced
* with underscores. Returns null if there's nothing usable so the
* caller falls back to the device UUID.
*/
function vinFromDevice(d) {
const ext = d.externalId || d.external_id || "";
if (!ext) return null;
const sep = ext.indexOf(":");
const candidate = (sep >= 0 ? ext.slice(sep + 1) : ext).trim();
if (!candidate) return null;
// ioBroker forbids ".", "*", "?", "[", "]", whitespace and a few more
// in object IDs. Be conservative — keep alnum + dash + underscore.
return candidate.replace(/[^A-Za-z0-9_-]+/g, "_");
}
module.exports = {
TibberClient,
buildAuthorizeUrl,
exchangeCode,
refreshTokens,
normalizeDevice,
vinFromDevice,
REDIRECT_URI,
SCOPES,
PKCE_CHALLENGE,
// for tests only
_internal: { request, postForm, getJson },
};