get_user_bind_and_not_in_station_evchargers is the only list the connector ever asked for, and its name says exactly what it withholds. A charger that belongs to a system is not in it. Its userBindEvChargersCount, though, counts every charger bound to the account — so an owner with two chargers in a system got "authenticated; 2 EV charger(s) bound to account" from the health probe and an empty list from the capability that is supposed to show them. A working login that finds nothing. So the capability now asks every view the cloud has and merges them by serial. The standalone list still answers for chargers standing on their own; get_site_list walks the systems and reads each one through get_scen_info, falling back to get_system_running_info where that is silent — the power-service / HES split charger-state already knows; and get_relate_and_bind_devices contributes model, firmware and the Wi-Fi flag, and discovers anything in the A519 family that the first two missed. Whichever way a charger was registered, one of the three has it. The merge is first-writer-wins per field rather than last view overwriting: the standalone record knows the name, the site record knows the live state, and neither should blank what the other established. A view that fails is a warning on the document instead of an error on the call, because one dead endpoint should not cost the chargers the other two found. Only losing all three is a failure. When nothing comes back at all the response says so in its own words and names the remaining suspect — country picks the regional server, and the wrong one authenticates happily and shows an empty account. The other half of "not showing any chargers" was that neither client ever showed a list. The serial was a text box, and the number is printed on a charger hanging on a wall. Both apps now list what the account holds — name, serial, model, site, state, an offline badge — and hand the serial to the OCPP control card instead of asking anyone to go and read it. Where control is off the list still stands on its own, as the answer to the first question an owner has after entering credentials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
343 lines
18 KiB
JavaScript
343 lines
18 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) }),
|
|
|
|
// 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 OCPP control (per charger). getAnkerControl returns the control
|
|
// mode, connection status, provisioning endpoint + token, and a live status
|
|
// snapshot; ankerControlToken (re)generates the per-charger token the operator
|
|
// installs into the charger; ankerControlAction issues one OCPP command
|
|
// (start/stop/limit/clear-limit/availability/reset/unlock/trigger/config).
|
|
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" }),
|
|
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" }),
|
|
};
|