Files
DriverVault/Web App/web/src/api.js
T
tajniak81andClaude Opus 5 7ccd785742 The charger's address, asked for where control is set up
Modbus mode had no way in from the app: the mode could be picked in Settings,
but the address the server dials had to be PUT by hand. The control card now
asks for it in the place the charger is already being controlled from.

Which provisioning the card shows follows the mode, because the two are not
alternatives to each other — OCPP installs a token into the charger, Modbus
records where the charger is. So does what the card offers: boost is a Modbus
command and appears there, clear-limit and reset are OCPP ones the register map
has no equivalent for and stay behind.

The status tiles read whichever snapshot arrived. An OCPP session counts a meter
in Wh and names a connector state; a Modbus snapshot counts the session's own
energy and names the charger's. Both land in the same two tiles.

When nothing answers, the server has already tried the address and says what it
found, so the card shows that rather than a hint written in advance.

The hint naming Own and Proxy CSMS as the way to control a charger was true
until there was a third mode; it now names all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:27:06 +02:00

381 lines
20 KiB
JavaScript

// Single client for the Car Control API Server. The web app never talks to
// PocketBase directly — only to these endpoints (proxied to the API Server in
// dev via vite.config.js).
//
// Which server the calls go to is servers.js's business: the base URL and the
// bearer token are both read from whichever server is active, resolved fresh on
// every request so switching takes effect without a reload.
import { t } from "./i18n";
import { servers, serverById, baseFor, sessionFor, clearSession, setActive, isConnected, HOME_ID } from "./servers";
// Every request is pinned to the server that was active when it went out: its
// base, its token, and — if it comes back 401 — its session and no other's.
// Re-reading the active server on the way back would let one server's rejection
// clear a different server's session, which is what happens when a page fires
// several calls at once and the switch lands between them.
function target() {
const id = servers.activeId;
const token = sessionFor(id)?.token;
return {
id,
base: baseFor(serverById(id)),
headers: token ? { Authorization: "Bearer " + token } : {},
};
}
async function handleResponse(res, path, serverId) {
// An expired/invalid token ends that server's session — but only that one. A
// remote server timing out shouldn't tip you out of the app, so fall back to
// the home server while it is still connected, and go to the login screen
// only when nothing is left to fall back to.
if (res.status === 401 && path !== "/auth/login") {
clearSession(serverId);
if (servers.activeId === serverId) {
if (serverId !== HOME_ID && isConnected(HOME_ID)) setActive(HOME_ID);
else if (location.pathname !== "/login") location.href = "/login";
}
throw new Error(t("errors.sessionExpired"));
}
if (res.status === 204) return null;
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error(errorMessage(data, res.statusText));
return data;
}
// Digs a human-readable message out of the error shapes in play: this server's
// {error}, and PocketBase's {message, data:{field:{message}}} — which the user
// and organization endpoints relay verbatim, so a duplicate email arrives as a
// per-field error rather than a flat string.
function errorMessage(data, fallback) {
if (!data || typeof data !== "object") return fallback;
if (data.error) return data.error;
const fieldErrors = Object.entries(data.data || {})
.map(([field, e]) => `${field}: ${e?.message || e}`)
.filter(Boolean);
if (fieldErrors.length) return fieldErrors.join("; ");
return data.message || fallback;
}
async function request(path, options = {}) {
const to = target();
const res = await fetch(to.base + path, {
headers: { "Content-Type": "application/json", ...to.headers, ...(options.headers || {}) },
...options,
});
return handleResponse(res, path, to.id);
}
// Like request(), but for multipart/form-data bodies (file uploads) — the
// browser sets its own Content-Type (with boundary), so we must not.
async function requestForm(path, options = {}) {
const to = target();
const res = await fetch(to.base + path, { headers: { ...to.headers }, ...options });
return handleResponse(res, path, to.id);
}
// Fetches a binary response (image, export file) as a Blob, since it needs the
// Authorization header — a plain <img src> or <a href> can't attach one.
async function requestBlob(path) {
const to = target();
const res = await fetch(to.base + path, { headers: to.headers });
if (res.status === 401) return handleResponse(res, path, to.id);
if (!res.ok) throw new Error("Request failed: " + res.statusText);
const filename = (res.headers.get("Content-Disposition") || "").match(/filename="([^"]+)"/)?.[1];
return { blob: await res.blob(), filename };
}
// Attachments. Every record that can carry a file — documents, service records,
// workshop visits, refills, catalog parts — exposes the same three endpoints
// under its own path, so they are built from one place rather than spelled out
// five times.
//
// The file is proxied by the API (PocketBase's files aren't public), so it needs
// the auth header — hence a multipart POST and a Blob fetch rather than a plain
// <a href>. Uploading addresses a record that must already exist; see
// applyAttachment in lib/attachment.js for the order the forms use.
const attachment = (path) => ({
upload: (id, file) => {
const form = new FormData();
form.append("file", file);
return requestForm(`${path}/${id}/file`, { method: "POST", body: form });
},
download: (id) => requestBlob(`${path}/${id}/file`),
remove: (id) => request(`${path}/${id}/file`, { method: "DELETE" }),
});
export const api = {
// Auth. loginAt takes the base explicitly rather than using the active
// server's: connecting to a newly added server must not make it active until
// its credentials have actually worked.
loginAt: async (base, email, password) => {
const res = await fetch(base + "/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
return handleResponse(res, "/auth/login", null);
},
me: () => request("/auth/me"),
// Cars
listCars: () => request("/cars"),
getCar: (id) => request(`/cars/${id}`),
createCar: (body) => request("/cars", { method: "POST", body: JSON.stringify(body) }),
updateCar: (id, body) => request(`/cars/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
// What this car's page shows — {hiddenTabs?, hiddenFields?}, as hidden sets.
// Its own endpoint so an ordinary car edit — which sends every other field —
// can never reveal something switched off. Needs write access, like editing
// the car. Only the sets passed are written.
updateCarView: (id, patch) =>
request(`/cars/${id}/view`, { method: "PUT", body: JSON.stringify(patch) }),
deleteCar: (id) => request(`/cars/${id}`, { method: "DELETE" }),
// Sharing (owner-only). A share grants another user read or write access.
listCarShares: (carId) => request(`/cars/${carId}/shares`),
addCarShare: (carId, email, permission) =>
request(`/cars/${carId}/shares`, { method: "POST", body: JSON.stringify({ email, permission }) }),
removeCarShare: (carId, userId) =>
request(`/cars/${carId}/shares/${userId}`, { method: "DELETE" }),
// Service records
listCarServices: (carId) => request(`/cars/${carId}/service-records`),
createService: (body) =>
request("/service-records", { method: "POST", body: JSON.stringify(body) }),
updateService: (id, body) =>
request(`/service-records/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteService: (id) => request(`/service-records/${id}`, { method: "DELETE" }),
// Technical checks — roadworthiness inspections. nextCheckDate and the expiry
// assessment are derived server-side from the certificate's valid-until date,
// falling back to the car's interval.
listCarTechnicalChecks: (carId) => request(`/cars/${carId}/technical-checks`),
createTechnicalCheck: (body) =>
request("/technical-checks", { method: "POST", body: JSON.stringify(body) }),
updateTechnicalCheck: (id, body) =>
request(`/technical-checks/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteTechnicalCheck: (id) => request(`/technical-checks/${id}`, { method: "DELETE" }),
// Parts
listCarParts: (carId) => request(`/cars/${carId}/parts`),
createPart: (body) => request("/parts", { method: "POST", body: JSON.stringify(body) }),
updatePart: (id, body) =>
request(`/parts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deletePart: (id) => request(`/parts/${id}`, { method: "DELETE" }),
// Fuel. Consumption figures on each entry, and the rollup from fuel-stats, are
// derived server-side from the full history — nothing here is stored.
listCarFuel: (carId) => request(`/cars/${carId}/fuel-entries`),
getCarFuelStats: (carId) => request(`/cars/${carId}/fuel-stats`),
createFuel: (body) => request("/fuel-entries", { method: "POST", body: JSON.stringify(body) }),
updateFuel: (id, body) =>
request(`/fuel-entries/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteFuel: (id) => request(`/fuel-entries/${id}`, { method: "DELETE" }),
// Charging — the EV counterpart of fuel, on the same terms: the kWh/100km
// figures and the charging-stats rollup are derived server-side from the full
// history, so nothing here is stored.
listCarCharging: (carId) => request(`/cars/${carId}/charging-sessions`),
getCarChargingStats: (carId) => request(`/cars/${carId}/charging-stats`),
createCharging: (body) => request("/charging-sessions", { method: "POST", body: JSON.stringify(body) }),
updateCharging: (id, body) =>
request(`/charging-sessions/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteCharging: (id) => request(`/charging-sessions/${id}`, { method: "DELETE" }),
// Maintenance log — workshop visits and repairs (not the service schedule).
listCarMaintenance: (carId) => request(`/cars/${carId}/maintenance`),
createMaintenance: (body) => request("/maintenance", { method: "POST", body: JSON.stringify(body) }),
updateMaintenance: (id, body) =>
request(`/maintenance/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteMaintenance: (id) => request(`/maintenance/${id}`, { method: "DELETE" }),
// Documents — insurance, pollution certificates, … with renewal dates.
listCarDocuments: (carId) => request(`/cars/${carId}/documents`),
createDocument: (body) => request("/car-documents", { method: "POST", body: JSON.stringify(body) }),
updateDocument: (id, body) =>
request(`/car-documents/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteDocument: (id) => request(`/car-documents/${id}`, { method: "DELETE" }),
// Attachments, one per record. Keyed by the same names CarDetail uses for its
// tabs so a table row can reach for the right one generically.
files: {
documents: attachment("/car-documents"),
services: attachment("/service-records"),
technical: attachment("/technical-checks"),
maintenance: attachment("/maintenance"),
fuel: attachment("/fuel-entries"),
charging: attachment("/charging-sessions"),
parts: attachment("/parts"),
},
// Reminders. The list mixes stored reminders with read-only ones derived from
// documents and service records (flagged `auto`; their ids start with "auto:").
listCarReminders: (carId) => request(`/cars/${carId}/reminders`),
createReminder: (body) => request("/reminders", { method: "POST", body: JSON.stringify(body) }),
updateReminder: (id, body) =>
request(`/reminders/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteReminder: (id) => request(`/reminders/${id}`, { method: "DELETE" }),
completeReminder: (id) => request(`/reminders/${id}/complete`, { method: "POST" }),
// Admin — user management (admin or superadmin). Admins are scoped by the
// server to their own organization; superadmins see everyone.
listUsers: () => request("/users").then((r) => r.users),
createUser: (body) =>
request("/users", { method: "POST", body: JSON.stringify(body) }).then((r) => r.user),
updateUser: (id, body) =>
request(`/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }).then((r) => r.user),
// Password resets are a field on the user PATCH now, not a separate endpoint.
setUserPassword: (id, password) =>
request(`/users/${id}`, { method: "PATCH", body: JSON.stringify({ password }) }).then((r) => r.user),
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
// Organizations. Listing is manager-only (an admin sees just their own org),
// but creating is open to any user without one — the creator becomes its
// admin. Renaming and deleting are scoped to the caller's own org unless they
// are a superadmin.
listOrgs: () => request("/orgs").then((r) => r.organizations),
createOrg: (name) =>
request("/orgs", { method: "POST", body: JSON.stringify({ name }) }).then((r) => r.organization),
updateOrg: (id, name) =>
request(`/orgs/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }).then((r) => r.organization),
deleteOrg: (id) => request(`/orgs/${id}`, { method: "DELETE" }),
// Settings — account/profile/appearance
getMe: () => request("/me"),
updateMe: (body) => request("/me", { method: "PATCH", body: JSON.stringify(body) }),
changePassword: (oldPassword, newPassword) =>
request("/me/password", { method: "POST", body: JSON.stringify({ oldPassword, newPassword }) }),
requestVerification: () => request("/me/verify/request", { method: "POST" }),
uploadAvatar: (file) => {
const form = new FormData();
form.append("avatar", file);
return requestForm("/me/avatar", { method: "POST", body: form });
},
deleteAvatar: () => request("/me/avatar", { method: "DELETE" }),
getAvatarBlob: () => requestBlob("/me/avatar"),
// Integrations — per-user plugin settings under the superadmin → org admin →
// user cascade. getToyota returns the resolved view (effective/own/locked per
// field, with secrets and inherited usernames masked); saveToyota writes the
// caller's editable layer (scope "user" by default, "org" for org admins);
// testToyota runs a live login probe under the resolved credentials.
getToyota: () => request("/integrations/toyota"),
saveToyota: (body) => request("/integrations/toyota", { method: "PUT", body: JSON.stringify(body) }),
testToyota: () => request("/integrations/toyota/health", { method: "POST" }),
// Vehicle providers — manufacturer services a car can be created from, and the
// data feed behind a car's provider tab. Every call runs server-side under the
// caller's *own* connected account (the same cascade the Settings integrations
// use), so a car shared from someone else only shows provider data when that
// vehicle is on this user's account too.
//
// listVehicleProviders reports each provider with a `connected` flag and, when
// it isn't, a `detail` sentence explaining what to do about it — the list is
// never an error, so the UI can offer "connect in Settings" instead.
listVehicleProviders: () => request("/vehicle-providers").then((r) => r.providers),
listProviderVehicles: (provider) =>
request(`/vehicle-providers/${encodeURIComponent(provider)}/vehicles`),
// include selects what to pull; omit it entirely to fetch everything available.
importProviderVehicle: (provider, body) =>
request(`/vehicle-providers/${encodeURIComponent(provider)}/import`, {
method: "POST",
body: JSON.stringify(body),
}),
// One car's live provider snapshot: the vehicle record, headline readings, and
// every section the plugin can fetch (each with its flattened fields and the
// raw payload). linkCarProvider attaches an existing car to a vehicle — pass an
// empty provider to detach; syncCarProvider re-applies provider data to the car.
getCarProvider: (carId) => request(`/cars/${carId}/provider`),
linkCarProvider: (carId, body) =>
request(`/cars/${carId}/provider`, { method: "POST", body: JSON.stringify(body) }),
syncCarProvider: (carId, body = {}) =>
request(`/cars/${carId}/provider/sync`, { method: "POST", body: JSON.stringify(body) }),
// Charger providers — the garage's import, aimed at the wall: a charger on a
// connected service (Anker Solix, Greencell) becomes one of the caller's own
// home chargers. listChargerProviders reports each with a `connected` flag and,
// when it isn't, a `detail` sentence saying what to do about it.
listChargerProviders: () => request("/charger-providers").then((r) => r.providers),
listProviderChargers: (provider) =>
request(`/charger-providers/${encodeURIComponent(provider)}/chargers`),
importProviderCharger: (provider, body) =>
request(`/charger-providers/${encodeURIComponent(provider)}/import`, {
method: "POST",
body: JSON.stringify(body),
}),
// The caller's own chargers. Only the name is editable — everything else
// describes the hardware and comes from the service it was imported from.
listHomeChargers: () => request("/home-chargers").then((r) => r.chargers),
renameHomeCharger: (id, name) =>
request(`/home-chargers/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify({ name }),
}),
deleteHomeCharger: (id) => request(`/home-chargers/${encodeURIComponent(id)}`, { method: "DELETE" }),
// Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix
// returns the resolved view (effective/own/locked per field, secrets and
// inherited emails masked); saveAnkerSolix writes the caller's editable layer;
// testAnkerSolix runs a live login probe under the resolved credentials.
getAnkerSolix: () => request("/integrations/anker-solix"),
saveAnkerSolix: (body) => request("/integrations/anker-solix", { method: "PUT", body: JSON.stringify(body) }),
testAnkerSolix: () => request("/integrations/anker-solix/health", { method: "POST" }),
// The chargers on the linked Anker account, fetched server-side under the
// resolved credentials: {chargers, count, boundCount?, detail?}. Answers 200
// with an empty list and a reason when a gate is off, so the caller can show
// the reason rather than an error.
listAnkerChargers: () => request("/integrations/anker-solix/chargers"),
// Anker Solix control (per charger), over whichever transport the user's
// control mode selects. getAnkerControl returns the control mode, connection
// status, and a live status snapshot — an OCPP session snapshot in own/proxy
// mode, a Modbus register snapshot in modbus mode.
//
// The two modes are provisioned differently, and each has its own pair here:
// OCPP needs a token the operator installs into the charger (ankerControlToken
// / ankerControlRevoke), Modbus needs the charger's address on the local
// network (ankerControlAddress / ankerControlForgetAddress). A charger may
// hold both; setting one leaves the other alone.
getAnkerControl: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control`),
ankerControlToken: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/token`, { method: "POST" }),
ankerControlRevoke: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/token`, { method: "DELETE" }),
ankerControlAddress: (sn, host, port) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/address`, {
method: "PUT",
body: JSON.stringify({ host, port }),
}),
ankerControlForgetAddress: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/address`, { method: "DELETE" }),
// One control command. Over OCPP: start, stop, limit, clear-limit,
// availability, reset, unlock, trigger, config. Over Modbus TCP: start, stop,
// limit, boost, phase, timeout, status.
ankerControlAction: (sn, action, body = {}) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/${action}`, {
method: "POST",
body: JSON.stringify(body),
}),
// Greencell (HabuDen EV charger) — same cascade again, but what resolves is an
// MQTT broker rather than a cloud account: the charger publishes to a broker
// the owner runs, and the server joins it as a client. getGreencell returns the
// resolved view (secrets and inherited host/username masked); saveGreencell
// writes the caller's editable layer; testGreencell connects to the broker and
// broadcasts for devices.
getGreencell: () => request("/integrations/greencell"),
saveGreencell: (body) => request("/integrations/greencell", { method: "PUT", body: JSON.stringify(body) }),
testGreencell: () => request("/integrations/greencell/health", { method: "POST" }),
// Settings — advanced / danger zone
exportData: () => requestBlob("/me/export"),
importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }),
requestAccountDeletion: (confirmEmail) =>
request("/me/delete", { method: "POST", body: JSON.stringify({ confirmEmail }) }),
cancelAccountDeletion: () => request("/me/delete/cancel", { method: "POST" }),
finalizeAccountDeletion: () => request("/me", { method: "DELETE" }),
};