Organization writes were superadmin-only, so standing up a tenant needed an out-of-band superadmin. Creating one is now self-service, and an admin manages the org they belong to. - POST /api/orgs is open to any authenticated user. A creator who isn't a superadmin must have no organization yet (a single-valued membership relation means a second one would abandon the first), and is promoted to the new org's admin and first member in the same request. If that promotion fails the org is rolled back, so it is never left stranded with nobody able to administer it. Superadmins still create tenants without joining them. - PATCH/DELETE are manager-gated and scope an admin to their own org. An admin deletes theirs only as its sole member: they are detached and demoted to a plain user before the record goes, so the org is empty when it is removed. Other members still block deletion with a 409. - /api/me now carries organization + organizationName, which the clients need to tell "no org yet" from "org you administer". The panel, Web App (new OrgManager.vue in Settings) and Phone App (new _OrganizationSection) all mirror the server's gates rather than re-deciding them. The Phone App cached its role at login and gates the Users tab on it, so AuthService.adoptRole refreshes that from the profile instead of making a freshly promoted admin sign in again. Covered by orgs_test.go, which drives the real handler + middleware chain against a stand-in PocketBase: promotion, the already-a-member refusal, superadmin staying unattached, the rollback, own-org scoping, the detach-and-demote, and the blocking-member 409. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
499 lines
21 KiB
Dart
499 lines
21 KiB
Dart
import "dart:convert";
|
|
import "package:http/http.dart" as http;
|
|
import "package:shared_preferences/shared_preferences.dart";
|
|
|
|
import "config.dart";
|
|
import "models.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;
|
|
}
|
|
|
|
/// The single client for the Car Control API Server. Holds the bearer token and
|
|
/// attaches it to every request. On 401 it calls [onUnauthorized] so the app can
|
|
/// route back to login.
|
|
class ApiClient {
|
|
String? token;
|
|
void Function()? onUnauthorized;
|
|
|
|
static const _serverKey = "cc_server_url";
|
|
|
|
/// The effective API base URL. Defaults to [kDefaultApiBase]; a saved override
|
|
/// (login screen "Server settings") replaces it via [loadServerUrl].
|
|
String baseUrl = kDefaultApiBase;
|
|
|
|
/// Loads a saved server-URL override, if any. Call before the first request.
|
|
Future<void> loadServerUrl() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final saved = prefs.getString(_serverKey);
|
|
if (saved != null && saved.isNotEmpty) baseUrl = saved;
|
|
}
|
|
|
|
/// The current override URL, or "" when using the default.
|
|
Future<String> serverOverride() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
return prefs.getString(_serverKey) ?? "";
|
|
}
|
|
|
|
/// Persists a server-URL override. Blank clears it (reverts to the default).
|
|
/// Trailing slashes are trimmed.
|
|
Future<void> setServerUrl(String url) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final trimmed = url.trim().replaceAll(RegExp(r"/+$"), "");
|
|
if (trimmed.isEmpty) {
|
|
await prefs.remove(_serverKey);
|
|
baseUrl = kDefaultApiBase;
|
|
} else {
|
|
await prefs.setString(_serverKey, trimmed);
|
|
baseUrl = trimmed;
|
|
}
|
|
}
|
|
|
|
Map<String, String> get _headers => {
|
|
"Content-Type": "application/json",
|
|
if (token != null) "Authorization": "Bearer $token",
|
|
};
|
|
|
|
Uri _uri(String path) => Uri.parse("$baseUrl$path");
|
|
|
|
Future<dynamic> _send(String method, String path, {Object? body}) async {
|
|
final req = http.Request(method, _uri(path))..headers.addAll(_headers);
|
|
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();
|
|
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 ---
|
|
/// 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)> login(String email, String password) async {
|
|
final data = await _send("POST", "/auth/login", body: {"email": email, "password": password});
|
|
return (data["token"] as String, AuthUser.fromJson(Map<String, dynamic>.from(data["record"])));
|
|
}
|
|
|
|
// --- cars ---
|
|
Future<List<Car>> listCars() async {
|
|
final data = await _send("GET", "/cars") as List;
|
|
return data.map((e) => Car.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<Car> getCar(String id) async {
|
|
final data = await _send("GET", "/cars/$id");
|
|
return Car.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<Car> createCar(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/cars", body: body);
|
|
return Car.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<Car> updateCar(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/cars/$id", body: body);
|
|
return Car.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> deleteCar(String id) => _send("DELETE", "/cars/$id");
|
|
|
|
// --- sharing (owner-only) ---
|
|
Future<List<CarShare>> listCarShares(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/shares") as List;
|
|
return data.map((e) => CarShare.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<CarShare> 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<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> 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<List<AdminUser>> listUsers() async {
|
|
final data = await _send("GET", "/users");
|
|
final items = (data["users"] ?? []) as List;
|
|
return items.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<AdminUser> createUser(
|
|
{required String email, required String password, String? name, String role = "user"}) async {
|
|
final data = await _send("POST", "/users",
|
|
body: {"email": email, "password": password, "name": name ?? "", "role": role});
|
|
return AdminUser.fromJson(Map<String, dynamic>.from(data["user"]));
|
|
}
|
|
|
|
Future<AdminUser> updateUser(String id, {String? name, String? role}) async {
|
|
final body = <String, dynamic>{};
|
|
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<String, dynamic>.from(data["user"]));
|
|
}
|
|
|
|
/// Password resets are a field on the user PATCH now, not a separate endpoint.
|
|
Future<void> setUserPassword(String id, String newPassword) =>
|
|
_send("PATCH", "/users/$id", body: {"password": newPassword});
|
|
|
|
Future<void> 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<List<Organization>> listOrgs() async {
|
|
final data = await _send("GET", "/orgs");
|
|
final items = (data["organizations"] ?? []) as List;
|
|
return items.map((e) => Organization.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<Organization> createOrg(String name) async {
|
|
final data = await _send("POST", "/orgs", body: {"name": name});
|
|
return Organization.fromJson(Map<String, dynamic>.from(data["organization"]));
|
|
}
|
|
|
|
Future<Organization> renameOrg(String id, String name) async {
|
|
final data = await _send("PATCH", "/orgs/$id", body: {"name": name});
|
|
return Organization.fromJson(Map<String, dynamic>.from(data["organization"]));
|
|
}
|
|
|
|
Future<void> deleteOrg(String id) => _send("DELETE", "/orgs/$id");
|
|
|
|
// --- service records ---
|
|
Future<List<ServiceRecord>> listCarServices(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/service-records") as List;
|
|
return data.map((e) => ServiceRecord.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<ServiceRecord> createService(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/service-records", body: body);
|
|
return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<ServiceRecord> updateService(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/service-records/$id", body: body);
|
|
return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> deleteService(String id) => _send("DELETE", "/service-records/$id");
|
|
|
|
// --- parts ---
|
|
Future<List<Part>> listCarParts(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/parts") as List;
|
|
return data.map((e) => Part.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<Part> createPart(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/parts", body: body);
|
|
return Part.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<Part> updatePart(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/parts/$id", body: body);
|
|
return Part.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> deletePart(String id) => _send("DELETE", "/parts/$id");
|
|
|
|
// --- technical checks ---
|
|
Future<List<TechnicalCheck>> listCarTechnicalChecks(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/technical-checks") as List;
|
|
return data.map((e) => TechnicalCheck.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<TechnicalCheck> createTechnicalCheck(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/technical-checks", body: body);
|
|
return TechnicalCheck.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<TechnicalCheck> updateTechnicalCheck(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/technical-checks/$id", body: body);
|
|
return TechnicalCheck.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> deleteTechnicalCheck(String id) => _send("DELETE", "/technical-checks/$id");
|
|
|
|
// --- fuel ---
|
|
Future<List<FuelEntry>> listCarFuelEntries(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/fuel-entries") as List;
|
|
return data.map((e) => FuelEntry.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<FuelStats> getCarFuelStats(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/fuel-stats");
|
|
return FuelStats.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<FuelEntry> createFuelEntry(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/fuel-entries", body: body);
|
|
return FuelEntry.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<FuelEntry> updateFuelEntry(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/fuel-entries/$id", body: body);
|
|
return FuelEntry.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> deleteFuelEntry(String id) => _send("DELETE", "/fuel-entries/$id");
|
|
|
|
// --- maintenance ---
|
|
Future<List<MaintenanceEntry>> listCarMaintenance(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/maintenance") as List;
|
|
return data.map((e) => MaintenanceEntry.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<MaintenanceEntry> createMaintenance(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/maintenance", body: body);
|
|
return MaintenanceEntry.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<MaintenanceEntry> updateMaintenance(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/maintenance/$id", body: body);
|
|
return MaintenanceEntry.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> 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<List<CarDocument>> listCarDocuments(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/documents") as List;
|
|
return data.map((e) => CarDocument.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<CarDocument> createDocument(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/car-documents", body: body);
|
|
return CarDocument.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<CarDocument> updateDocument(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/car-documents/$id", body: body);
|
|
return CarDocument.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> deleteDocument(String id) => _send("DELETE", "/car-documents/$id");
|
|
|
|
// --- reminders ---
|
|
Future<List<Reminder>> listCarReminders(String carId) async {
|
|
final data = await _send("GET", "/cars/$carId/reminders") as List;
|
|
return data.map((e) => Reminder.fromJson(Map<String, dynamic>.from(e))).toList();
|
|
}
|
|
|
|
Future<Reminder> createReminder(Map<String, dynamic> body) async {
|
|
final data = await _send("POST", "/reminders", body: body);
|
|
return Reminder.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<Reminder> updateReminder(String id, Map<String, dynamic> body) async {
|
|
final data = await _send("PATCH", "/reminders/$id", body: body);
|
|
return Reminder.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> 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<Reminder> completeReminder(String id) async {
|
|
final data = await _send("POST", "/reminders/$id/complete");
|
|
return Reminder.fromJson(Map<String, dynamic>.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<Map<String, dynamic>> uploadAttachment(
|
|
String path, String id, List<int> bytes, String filename) async {
|
|
final req = http.MultipartRequest("POST", _uri("$path/$id/file"));
|
|
if (token != null) req.headers["Authorization"] = "Bearer $token";
|
|
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();
|
|
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<String, dynamic>.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<List<int>?> getAttachmentBytes(String path, String id) async {
|
|
final res = await http.get(_uri("$path/$id/file"),
|
|
headers: {if (token != null) "Authorization": "Bearer $token"});
|
|
if (res.statusCode == 200) return res.bodyBytes;
|
|
return null;
|
|
}
|
|
|
|
Future<void> deleteAttachment(String path, String id) => _send("DELETE", "$path/$id/file");
|
|
|
|
// --- settings: profile / account ---
|
|
Future<UserProfile> getMe() async {
|
|
final data = await _send("GET", "/me");
|
|
return UserProfile.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<UserProfile> updateMe(Map<String, dynamic> patch) async {
|
|
final data = await _send("PATCH", "/me", body: patch);
|
|
return UserProfile.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<void> changePassword(String oldPassword, String newPassword) => _send(
|
|
"POST",
|
|
"/me/password",
|
|
body: {"oldPassword": oldPassword, "newPassword": newPassword},
|
|
);
|
|
|
|
Future<void> requestVerification() => _send("POST", "/me/verify/request");
|
|
|
|
// --- settings: avatar ---
|
|
Future<UserProfile> uploadAvatar(List<int> bytes, String filename) async {
|
|
final req = http.MultipartRequest("POST", _uri("/me/avatar"));
|
|
if (token != null) req.headers["Authorization"] = "Bearer $token";
|
|
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();
|
|
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<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<List<int>?> getAvatarBytes() async {
|
|
final res = await http.get(_uri("/me/avatar"),
|
|
headers: {if (token != null) "Authorization": "Bearer $token"});
|
|
if (res.statusCode == 200) return res.bodyBytes;
|
|
return null;
|
|
}
|
|
|
|
Future<void> deleteAvatar() => _send("DELETE", "/me/avatar");
|
|
|
|
// --- settings: account deletion ---
|
|
Future<DateTime?> 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<void> cancelAccountDeletion() => _send("POST", "/me/delete/cancel");
|
|
Future<void> finalizeAccountDeletion() => _send("DELETE", "/me");
|
|
|
|
// --- integrations (per-user plugin settings, superadmin → org → user cascade) ---
|
|
// getToyota/getAnkerSolix return the resolved view (effective/own/locked per
|
|
// field, secrets and inherited values masked); saveToyota/saveAnkerSolix write
|
|
// the caller's editable layer (scope "user" by default, "org" for org admins);
|
|
// testToyota/testAnkerSolix run a live login probe under the resolved creds.
|
|
Future<IntegrationView> getToyota() async {
|
|
final data = await _send("GET", "/integrations/toyota");
|
|
return IntegrationView.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<IntegrationView> saveToyota(Map<String, dynamic> body) async {
|
|
final data = await _send("PUT", "/integrations/toyota", body: body);
|
|
return IntegrationView.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<IntegrationHealth> testToyota() async {
|
|
final data = await _send("POST", "/integrations/toyota/health");
|
|
final h = (data is Map ? data["health"] : null) ?? {};
|
|
return IntegrationHealth.fromJson(Map<String, dynamic>.from(h));
|
|
}
|
|
|
|
Future<IntegrationView> getAnkerSolix() async {
|
|
final data = await _send("GET", "/integrations/anker-solix");
|
|
return IntegrationView.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<IntegrationView> saveAnkerSolix(Map<String, dynamic> body) async {
|
|
final data = await _send("PUT", "/integrations/anker-solix", body: body);
|
|
return IntegrationView.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
Future<IntegrationHealth> testAnkerSolix() async {
|
|
final data = await _send("POST", "/integrations/anker-solix/health");
|
|
final h = (data is Map ? data["health"] : null) ?? {};
|
|
return IntegrationHealth.fromJson(Map<String, dynamic>.from(h));
|
|
}
|
|
|
|
// --- 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 (returned exactly once); ankerControlRevoke deletes it;
|
|
// ankerControlAction issues one OCPP command (start/stop/limit/clear-limit/reset/…).
|
|
String _sn(String sn) => Uri.encodeComponent(sn);
|
|
|
|
Future<AnkerControl> getAnkerControl(String sn) async {
|
|
final data = await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/control");
|
|
return AnkerControl.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
|
|
/// (Re)generates the per-charger control token; the plaintext token is returned
|
|
/// exactly once, so the caller must show it immediately.
|
|
Future<String> 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<void> ankerControlRevoke(String sn) =>
|
|
_send("DELETE", "/integrations/anker-solix/chargers/${_sn(sn)}/control/token");
|
|
|
|
Future<void> ankerControlAction(String sn, String action, [Map<String, dynamic> body = const {}]) =>
|
|
_send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/$action", body: body);
|
|
|
|
static String _asString(dynamic v) => v == null ? "" : v.toString();
|
|
}
|