import "dart:convert"; import "package:http/http.dart" as http; import "models.dart"; import "servers.dart"; /// Thrown when the API Server returns a non-2xx response. class ApiException implements Exception { final int status; final String message; ApiException(this.status, this.message); @override String toString() => message; } /// One request's destination: the server that was active when it went out, its /// base URL and its token. Pinning all three up front is what keeps a rejection /// attributable — re-reading the active server on the way back would let one /// server's 401 clear a different server's session when a switch lands between /// a call going out and its answer arriving. class _Target { final String id; final String base; final String? token; const _Target(this.id, this.base, this.token); Uri uri(String path) => Uri.parse("$base$path"); Map get authHeaders => {if (token != null) "Authorization": "Bearer $token"}; Map get jsonHeaders => {"Content-Type": "application/json", ...authHeaders}; } /// The single client for the Car Control API Server. Which server the calls go /// to is [serverRegistry]'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 rebuilding the client. On 401 it calls /// [onUnauthorized] with the server that rejected the session. class ApiClient { void Function(String serverId)? onUnauthorized; /// The active server's token — read by the multipart and download helpers, /// which build their own requests. String? get token => serverRegistry.activeToken; /// The base URL the next request will go to. String get baseUrl => serverRegistry.activeBase; _Target _target() => _Target( serverRegistry.activeId, serverRegistry.activeBase, serverRegistry.activeToken, ); Future _send(String method, String path, {Object? body}) async { final target = _target(); final req = http.Request(method, target.uri(path))..headers.addAll(target.jsonHeaders); if (body != null) req.body = jsonEncode(body); final streamed = await http.Client().send(req); final res = await http.Response.fromStream(streamed); if (res.statusCode == 401 && path != "/auth/login") { onUnauthorized?.call(target.id); throw ApiException(401, "Session expired — please log in again."); } if (res.statusCode == 204 || res.body.isEmpty) return null; final data = jsonDecode(res.body); if (res.statusCode < 200 || res.statusCode >= 300) { throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase)); } 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 endpoints relay verbatim, so a duplicate email arrives as a /// per-field error rather than a flat string. String _errorMessage(dynamic data, String? fallback) { if (data is! Map) return fallback ?? "Request failed"; if (data["error"] != null) return data["error"].toString(); final fields = data["data"]; if (fields is Map && fields.isNotEmpty) { final parts = fields.entries.map((e) { final v = e.value; final msg = v is Map && v["message"] != null ? v["message"] : v; return "${e.key}: $msg"; }); return parts.join("; "); } if (data["message"] != null) return data["message"].toString(); return fallback ?? "Request failed"; } // --- auth --- /// Signs in against a named base rather than the active server: the add-server /// sheet checks credentials against the server being added before anything /// switches to it, so a wrong password leaves you where you were. /// /// The API Server proxies login to PocketBase and relays its response /// verbatim, so the user arrives under `record` (PocketBase's name) and the /// token is PocketBase's own — the server no longer mints its own JWT. Future<(String, AuthUser)> loginAt(String base, String email, String password) async { final res = await http.post( Uri.parse("$base/auth/login"), headers: const {"Content-Type": "application/json"}, body: jsonEncode({"email": email, "password": password}), ); final data = res.body.isEmpty ? null : _tryDecode(res.body); if (res.statusCode < 200 || res.statusCode >= 300) { throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase)); } return (data["token"] as String, AuthUser.fromJson(Map.from(data["record"]))); } Future<(String, AuthUser)> login(String email, String password) => loginAt(serverRegistry.activeBase, email, password); // --- cars --- Future> listCars() async { final data = await _send("GET", "/cars") as List; return data.map((e) => Car.fromJson(Map.from(e))).toList(); } Future getCar(String id) async { final data = await _send("GET", "/cars/$id"); return Car.fromJson(Map.from(data)); } Future createCar(Map body) async { final data = await _send("POST", "/cars", body: body); return Car.fromJson(Map.from(data)); } Future updateCar(String id, Map body) async { final data = await _send("PATCH", "/cars/$id", body: body); return Car.fromJson(Map.from(data)); } Future deleteCar(String id) => _send("DELETE", "/cars/$id"); /// What this car's page shows and in which order — its own endpoint rather /// than part of the car PATCH, so saving the car form can never silently /// reveal a hidden tab or undo an arrangement. Every field is optional; only /// the ones passed are written. Future updateCarView( String id, { List? hiddenTabs, List? hiddenFields, List? tabOrder, List? fieldOrder, List? metricOrder, List? hiddenServiceColumns, List? serviceColumnOrder, List? hiddenServiceParts, }) async { final body = { if (hiddenTabs != null) "hiddenTabs": hiddenTabs, if (hiddenFields != null) "hiddenFields": hiddenFields, if (tabOrder != null) "tabOrder": tabOrder, if (fieldOrder != null) "fieldOrder": fieldOrder, if (metricOrder != null) "metricOrder": metricOrder, if (hiddenServiceColumns != null) "hiddenServiceColumns": hiddenServiceColumns, if (serviceColumnOrder != null) "serviceColumnOrder": serviceColumnOrder, if (hiddenServiceParts != null) "hiddenServiceParts": hiddenServiceParts, }; final data = await _send("PUT", "/cars/$id/view", body: body); return Car.fromJson(Map.from(data)); } // --- sharing (owner-only) --- Future> listCarShares(String carId) async { final data = await _send("GET", "/cars/$carId/shares") as List; return data.map((e) => CarShare.fromJson(Map.from(e))).toList(); } Future addCarShare(String carId, String email, String permission) async { final data = await _send("POST", "/cars/$carId/shares", body: {"email": email, "permission": permission}); return CarShare.fromJson(Map.from(data)); } Future removeCarShare(String carId, String userId) => _send("DELETE", "/cars/$carId/shares/$userId"); // --- user management (admin or superadmin) --- // Admins are scoped by the server to their own organization; superadmins see // everyone. Responses are enveloped ({users}/{user}). Future> listUsers() async { final data = await _send("GET", "/users"); final items = (data["users"] ?? []) as List; return items.map((e) => AdminUser.fromJson(Map.from(e))).toList(); } /// Creates a user. [organization] is the superadmin's choice of tenant — an /// empty string deliberately means "no organization". Omit it entirely for an /// admin: the server forces its own org on their members, so sending anything /// would be noise the server ignores. Future createUser({ required String email, required String password, String? name, String role = "user", String? organization, }) async { final data = await _send("POST", "/users", body: { "email": email, "password": password, "name": name ?? "", "role": role, if (organization != null) "organization": organization, }); return AdminUser.fromJson(Map.from(data["user"])); } Future updateUser(String id, {String? name, String? role}) async { final body = {}; if (name != null) body["name"] = name; if (role != null) body["role"] = role; final data = await _send("PATCH", "/users/$id", body: body); return AdminUser.fromJson(Map.from(data["user"])); } /// Password resets are a field on the user PATCH now, not a separate endpoint. Future setUserPassword(String id, String newPassword) => _send("PATCH", "/users/$id", body: {"password": newPassword}); Future deleteUser(String id) => _send("DELETE", "/users/$id"); // --- organizations --- // Listing is manager-only (an admin sees just their own org), but creating is // open to any user who has none — the creator becomes that org's admin in the // same request. Renames and deletes are scoped to the caller's own org unless // they are a superadmin. Responses are enveloped ({organizations}/{organization}). Future> listOrgs() async { final data = await _send("GET", "/orgs"); final items = (data["organizations"] ?? []) as List; return items.map((e) => Organization.fromJson(Map.from(e))).toList(); } Future createOrg(String name) async { final data = await _send("POST", "/orgs", body: {"name": name}); return Organization.fromJson(Map.from(data["organization"])); } Future renameOrg(String id, String name) async { final data = await _send("PATCH", "/orgs/$id", body: {"name": name}); return Organization.fromJson(Map.from(data["organization"])); } Future deleteOrg(String id) => _send("DELETE", "/orgs/$id"); // --- service records --- Future> listCarServices(String carId) async { final data = await _send("GET", "/cars/$carId/service-records") as List; return data.map((e) => ServiceRecord.fromJson(Map.from(e))).toList(); } Future createService(Map body) async { final data = await _send("POST", "/service-records", body: body); return ServiceRecord.fromJson(Map.from(data)); } Future updateService(String id, Map body) async { final data = await _send("PATCH", "/service-records/$id", body: body); return ServiceRecord.fromJson(Map.from(data)); } Future deleteService(String id) => _send("DELETE", "/service-records/$id"); // --- parts --- Future> listCarParts(String carId) async { final data = await _send("GET", "/cars/$carId/parts") as List; return data.map((e) => Part.fromJson(Map.from(e))).toList(); } Future createPart(Map body) async { final data = await _send("POST", "/parts", body: body); return Part.fromJson(Map.from(data)); } Future updatePart(String id, Map body) async { final data = await _send("PATCH", "/parts/$id", body: body); return Part.fromJson(Map.from(data)); } Future deletePart(String id) => _send("DELETE", "/parts/$id"); // --- technical checks --- Future> listCarTechnicalChecks(String carId) async { final data = await _send("GET", "/cars/$carId/technical-checks") as List; return data.map((e) => TechnicalCheck.fromJson(Map.from(e))).toList(); } Future createTechnicalCheck(Map body) async { final data = await _send("POST", "/technical-checks", body: body); return TechnicalCheck.fromJson(Map.from(data)); } Future updateTechnicalCheck(String id, Map body) async { final data = await _send("PATCH", "/technical-checks/$id", body: body); return TechnicalCheck.fromJson(Map.from(data)); } Future deleteTechnicalCheck(String id) => _send("DELETE", "/technical-checks/$id"); // --- fuel --- Future> listCarFuelEntries(String carId) async { final data = await _send("GET", "/cars/$carId/fuel-entries") as List; return data.map((e) => FuelEntry.fromJson(Map.from(e))).toList(); } Future getCarFuelStats(String carId) async { final data = await _send("GET", "/cars/$carId/fuel-stats"); return FuelStats.fromJson(Map.from(data)); } Future createFuelEntry(Map body) async { final data = await _send("POST", "/fuel-entries", body: body); return FuelEntry.fromJson(Map.from(data)); } Future updateFuelEntry(String id, Map body) async { final data = await _send("PATCH", "/fuel-entries/$id", body: body); return FuelEntry.fromJson(Map.from(data)); } Future deleteFuelEntry(String id) => _send("DELETE", "/fuel-entries/$id"); // --- charging sessions --- // The electric counterpart of the fuel entries, on identical terms: the // per-car list plus the derived summary, and CRUD on the flat collection. Future> listCarChargingSessions(String carId) async { final data = await _send("GET", "/cars/$carId/charging-sessions") as List; return data.map((e) => ChargingSession.fromJson(Map.from(e))).toList(); } Future getCarChargingStats(String carId) async { final data = await _send("GET", "/cars/$carId/charging-stats"); return ChargingStats.fromJson(Map.from(data)); } Future createChargingSession(Map body) async { final data = await _send("POST", "/charging-sessions", body: body); return ChargingSession.fromJson(Map.from(data)); } Future updateChargingSession(String id, Map body) async { final data = await _send("PATCH", "/charging-sessions/$id", body: body); return ChargingSession.fromJson(Map.from(data)); } Future deleteChargingSession(String id) => _send("DELETE", "/charging-sessions/$id"); // --- maintenance --- Future> listCarMaintenance(String carId) async { final data = await _send("GET", "/cars/$carId/maintenance") as List; return data.map((e) => MaintenanceEntry.fromJson(Map.from(e))).toList(); } Future createMaintenance(Map body) async { final data = await _send("POST", "/maintenance", body: body); return MaintenanceEntry.fromJson(Map.from(data)); } Future updateMaintenance(String id, Map body) async { final data = await _send("PATCH", "/maintenance/$id", body: body); return MaintenanceEntry.fromJson(Map.from(data)); } Future deleteMaintenance(String id) => _send("DELETE", "/maintenance/$id"); // --- documents --- // The path is /car-documents so it can't be mistaken for the user-facing // account documents other Vault services expose. Future> listCarDocuments(String carId) async { final data = await _send("GET", "/cars/$carId/documents") as List; return data.map((e) => CarDocument.fromJson(Map.from(e))).toList(); } Future createDocument(Map body) async { final data = await _send("POST", "/car-documents", body: body); return CarDocument.fromJson(Map.from(data)); } Future updateDocument(String id, Map body) async { final data = await _send("PATCH", "/car-documents/$id", body: body); return CarDocument.fromJson(Map.from(data)); } Future deleteDocument(String id) => _send("DELETE", "/car-documents/$id"); // --- reminders --- Future> listCarReminders(String carId) async { final data = await _send("GET", "/cars/$carId/reminders") as List; return data.map((e) => Reminder.fromJson(Map.from(e))).toList(); } Future createReminder(Map body) async { final data = await _send("POST", "/reminders", body: body); return Reminder.fromJson(Map.from(data)); } Future updateReminder(String id, Map body) async { final data = await _send("PATCH", "/reminders/$id", body: body); return Reminder.fromJson(Map.from(data)); } Future deleteReminder(String id) => _send("DELETE", "/reminders/$id"); /// Completing a recurring reminder rolls its trigger forward instead of /// closing it out; the server decides which, so the caller just re-reads. Future completeReminder(String id) async { final data = await _send("POST", "/reminders/$id/complete"); return Reminder.fromJson(Map.from(data)); } // --- attachments --- // Every attachable collection takes a file on identical terms, so one set of // helpers is parameterized by the collection's path rather than repeated six // times. See the API's attachments.go. /// Uploads (or replaces) a record's file. Returns the re-read record JSON, so /// the caller decodes it into whichever model it owns. Future> uploadAttachment( String path, String id, List bytes, String filename) async { final target = _target(); final req = http.MultipartRequest("POST", target.uri("$path/$id/file")) ..headers.addAll(target.authHeaders); req.files.add(http.MultipartFile.fromBytes("file", bytes, filename: filename)); final res = await http.Response.fromStream(await req.send()); if (res.statusCode == 401) { onUnauthorized?.call(target.id); throw ApiException(401, "Session expired — please log in again."); } final data = jsonDecode(res.body); if (res.statusCode < 200 || res.statusCode >= 300) { throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase)); } return Map.from(data); } /// The attachment's bytes, or null when there is no file. Never a public URL — /// the server re-checks car access on every fetch. Future?> getAttachmentBytes(String path, String id) async { final target = _target(); final res = await http.get(target.uri("$path/$id/file"), headers: target.authHeaders); if (res.statusCode == 200) return res.bodyBytes; return null; } Future deleteAttachment(String path, String id) => _send("DELETE", "$path/$id/file"); // --- settings: profile / account --- Future getMe() async { final data = await _send("GET", "/me"); return UserProfile.fromJson(Map.from(data)); } Future updateMe(Map patch) async { final data = await _send("PATCH", "/me", body: patch); return UserProfile.fromJson(Map.from(data)); } Future changePassword(String oldPassword, String newPassword) => _send( "POST", "/me/password", body: {"oldPassword": oldPassword, "newPassword": newPassword}, ); Future requestVerification() => _send("POST", "/me/verify/request"); // --- settings: avatar --- Future uploadAvatar(List bytes, String filename) async { final target = _target(); final req = http.MultipartRequest("POST", target.uri("/me/avatar")) ..headers.addAll(target.authHeaders); req.files.add(http.MultipartFile.fromBytes("avatar", bytes, filename: filename)); final res = await http.Response.fromStream(await req.send()); if (res.statusCode == 401) { onUnauthorized?.call(target.id); throw ApiException(401, "Session expired — please log in again."); } final data = jsonDecode(res.body); if (res.statusCode < 200 || res.statusCode >= 300) { final msg = data is Map && data["error"] != null ? data["error"].toString() : res.reasonPhrase; throw ApiException(res.statusCode, msg ?? "Upload failed"); } return UserProfile.fromJson(Map.from(data)); } Future?> getAvatarBytes() async { final target = _target(); final res = await http.get(target.uri("/me/avatar"), headers: target.authHeaders); if (res.statusCode == 200) return res.bodyBytes; return null; } Future deleteAvatar() => _send("DELETE", "/me/avatar"); // --- settings: account deletion --- Future requestAccountDeletion(String confirmEmail) async { final data = await _send("POST", "/me/delete", body: {"confirmEmail": confirmEmail}); final at = (data is Map) ? data["eligibleAt"] : null; return at == null ? null : DateTime.tryParse(at.toString()); } Future cancelAccountDeletion() => _send("POST", "/me/delete/cancel"); Future finalizeAccountDeletion() => _send("DELETE", "/me"); // --- data export / import --- /// The whole account as a JSON file: the profile plus every car the user owns /// with its service records and parts. Cars merely shared with them are not /// included. Returns the bytes and the filename the server named it, which is /// dated — the phone has to write the file itself, so it needs both. Future<(List, String)> exportData() async { final target = _target(); final res = await http.get(target.uri("/me/export"), headers: target.authHeaders); if (res.statusCode == 401) { onUnauthorized?.call(target.id); throw ApiException(401, "Session expired — please log in again."); } if (res.statusCode < 200 || res.statusCode >= 300) { throw ApiException(res.statusCode, _errorMessage(_tryDecode(res.body), res.reasonPhrase)); } return (res.bodyBytes, _filenameFrom(res.headers["content-disposition"])); } /// Adds the cars in a previously exported file. Always creates new records — /// nothing is merged with or overwritten, so importing the same file twice /// leaves two copies rather than one updated one. Future importData(Map payload) async { final data = await _send("POST", "/me/import", body: payload); return ImportResult.fromJson(Map.from(data)); } static dynamic _tryDecode(String body) { try { return jsonDecode(body); } catch (_) { return null; } } /// The filename out of `attachment; filename="…"`, falling back to a plain /// name so the export is still saveable if the header is missing or unquoted. static String _filenameFrom(String? disposition) { if (disposition == null) return "drivervault-export.json"; final m = RegExp(r'filename="?([^";]+)"?').firstMatch(disposition); final name = m?.group(1)?.trim() ?? ""; return name.isEmpty ? "drivervault-export.json" : name; } // --- vehicle providers (the connected-service tab) --- // Every call runs server-side under *this* user's manufacturer account, so a // car shared from someone else only shows provider data when that vehicle is // on this user's account too. A closed gate is a 200 with `unavailable` set // rather than an error: "we asked, and here is why there is nothing". Future> listVehicleProviders() async { final data = await _send("GET", "/vehicle-providers"); final items = (data["providers"] ?? []) as List; return items.map((e) => VehicleProvider.fromJson(Map.from(e))).toList(); } Future> listProviderVehicles(String provider) async { final data = await _send("GET", "/vehicle-providers/${Uri.encodeComponent(provider)}/vehicles"); final items = (data["vehicles"] ?? []) as List; return items.map((e) => ProviderVehicle.fromJson(Map.from(e))).toList(); } Future getCarProvider(String carId) async { final data = await _send("GET", "/cars/$carId/provider"); return ProviderSnapshot.fromJson(Map.from(data)); } /// Links this car to a vehicle on the caller's provider account; an empty /// [provider] unlinks it. Returns the updated car. Future linkCarProvider(String carId, {String provider = "", String vehicleId = ""}) async { final data = await _send("POST", "/cars/$carId/provider", body: {"provider": provider, "vehicleId": vehicleId}); return Car.fromJson(Map.from(data)); } /// Re-applies the provider's data to the car. [include] selects what to take /// (identity, fuelType, dates, odometer); omitted means everything. Future syncCarProvider(String carId, {Map? include}) async { final data = await _send("POST", "/cars/$carId/provider/sync", body: {if (include != null) "include": include}); final car = (data is Map && data["car"] != null) ? data["car"] : data; return Car.fromJson(Map.from(car)); } // --- integrations (per-user plugin settings, superadmin → org → user cascade) --- // Each connector has the same trio: get… returns the resolved view // (effective/own/locked per field, secrets and inherited values masked); save… // writes the caller's editable layer (scope "user" by default, "org" for org // admins); test… runs a live probe under the resolved config. Future getToyota() async { final data = await _send("GET", "/integrations/toyota"); return IntegrationView.fromJson(Map.from(data)); } Future saveToyota(Map body) async { final data = await _send("PUT", "/integrations/toyota", body: body); return IntegrationView.fromJson(Map.from(data)); } Future testToyota() async { final data = await _send("POST", "/integrations/toyota/health"); final h = (data is Map ? data["health"] : null) ?? {}; return IntegrationHealth.fromJson(Map.from(h)); } Future getAnkerSolix() async { final data = await _send("GET", "/integrations/anker-solix"); return IntegrationView.fromJson(Map.from(data)); } Future saveAnkerSolix(Map body) async { final data = await _send("PUT", "/integrations/anker-solix", body: body); return IntegrationView.fromJson(Map.from(data)); } Future testAnkerSolix() async { final data = await _send("POST", "/integrations/anker-solix/health"); final h = (data is Map ? data["health"] : null) ?? {}; return IntegrationHealth.fromJson(Map.from(h)); } /// The chargers on the linked Anker account, fetched server-side under the /// resolved credentials. A closed gate answers 200 with an empty list and the /// reason in `detail`, so this is not an error path. Future listAnkerChargers() async { final data = await _send("GET", "/integrations/anker-solix/chargers"); if (data is! Map) return const AnkerChargerList(); return AnkerChargerList.fromJson(Map.from(data)); } /// Every view the account holds about one charger — the station record, the /// totals, the history, the sessions, the OCPP backend, the cards, the /// sharing, the firmware and the rest, plus its site's views when it has a /// site — asked for one charger at a time, because none of those endpoints /// lists chargers. Future getAnkerChargerDetails(String sn) async { final data = await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/details"); if (data is! Map) return const ChargerDetails(); return ChargerDetails.fromJson(Map.from(data)); } // Greencell (HabuDen EV charger). Same cascade, 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. testGreencell connects to that broker and // broadcasts for devices, so "degraded" means reachable-but-no-charger. Future getGreencell() async { final data = await _send("GET", "/integrations/greencell"); return IntegrationView.fromJson(Map.from(data)); } Future saveGreencell(Map body) async { final data = await _send("PUT", "/integrations/greencell", body: body); return IntegrationView.fromJson(Map.from(data)); } Future testGreencell() async { final data = await _send("POST", "/integrations/greencell/health"); final h = (data is Map ? data["health"] : null) ?? {}; return IntegrationHealth.fromJson(Map.from(h)); } // --- Anker Solix control (per charger) --- // // 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. String _sn(String sn) => Uri.encodeComponent(sn); Future getAnkerControl(String sn) async { final data = await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/control"); return AnkerControl.fromJson(Map.from(data)); } /// (Re)generates the per-charger control token; the plaintext token is returned /// exactly once, so the caller must show it immediately. Future ankerControlToken(String sn) async { final data = await _send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/control/token"); return (data is Map ? _asString(data["token"]) : ""); } Future ankerControlRevoke(String sn) => _send("DELETE", "/integrations/anker-solix/chargers/${_sn(sn)}/control/token"); /// Where the charger answers on the local network. Modbus dials the charger /// rather than waiting to be dialled, so this address is what the mode needs /// in place of a token. Future ankerControlAddress(String sn, String host, int port) => _send( "PUT", "/integrations/anker-solix/chargers/${_sn(sn)}/control/address", body: {"host": host, "port": port}, ); Future ankerControlForgetAddress(String sn) => _send("DELETE", "/integrations/anker-solix/chargers/${_sn(sn)}/control/address"); /// 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. Future ankerControlAction(String sn, String action, [Map body = const {}]) => _send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/$action", body: body); // --- charger providers and the user's own chargers --- // // The garage's import aimed at the wall: a charger on a connected service // becomes one of the caller's own home chargers, and stays one after the // account it came from is disconnected. /// Every charger service, each with whether the caller can use it and, when /// they cannot, the one sentence saying what to do about it. Future> listChargerProviders() async { final data = await _send("GET", "/charger-providers"); final items = (data["providers"] ?? []) as List; return items.map((e) => ChargerProvider.fromJson(Map.from(e))).toList(); } /// The chargers on that account, each annotated with the DriverVault charger /// it is already linked to. Future listProviderChargers(String provider) async { final data = await _send( "GET", "/charger-providers/${Uri.encodeComponent(provider)}/chargers"); if (data is! Map) return const ProviderChargerList(); return ProviderChargerList.fromJson(Map.from(data)); } /// Creates a home charger from one on the account. An empty [name] takes /// whatever the service calls it. Future importProviderCharger(String provider, String chargerId, {String name = ""}) async { final data = await _send( "POST", "/charger-providers/${Uri.encodeComponent(provider)}/import", body: {"chargerId": chargerId, "name": name}, ); final c = (data is Map ? data["charger"] : null) ?? {}; return HomeCharger.fromJson(Map.from(c)); } Future> listHomeChargers() async { final data = await _send("GET", "/home-chargers"); final items = (data["chargers"] ?? []) as List; return items.map((e) => HomeCharger.fromJson(Map.from(e))).toList(); } /// Only the name is editable — everything else describes the hardware and /// comes from the service the charger was imported from. Future renameHomeCharger(String id, String name) async { final data = await _send("PATCH", "/home-chargers/${Uri.encodeComponent(id)}", body: {"name": name}); final c = (data is Map ? data["charger"] : null) ?? {}; return HomeCharger.fromJson(Map.from(c)); } Future deleteHomeCharger(String id) => _send("DELETE", "/home-chargers/${Uri.encodeComponent(id)}"); static String _asString(dynamic v) => v == null ? "" : v.toString(); }