The chargers you own, on the phone as well

The web's Charging page grew a real home half while the phone kept a demo one.
There, a charger is a record imported from a connected service; here it was a
hardcoded row called "Home charger", and the only real thing on the tab was a
single OCPP control card. Modbus had been a working transport for a while, and
the phone had no way to give it the address it needs.

The home tab is now the four cards the web shows, about whichever charger is
picked, and the list of the ones you have imported. Control acts on the charger
and offers what the transport actually has — boost on Modbus, clear-limit and
reset on OCPP. Connection asks for a serial or an address depending on which,
and falls back to a text box for a serial the account does not list. Readings
render the Modbus snapshot the way it gets asked about: the per-phase matrix,
what the charger is doing, what it is set to, what it is, and any alarm word.
Information stands without a control mode at all, because what a charger is is
known either way; beside it the service's own view of whether it is reachable,
asked for when the tab is opened rather than on every build.

Rearranging is the one place the two apps differ, for the reason the car's view
picker already differs: the web drags the tab bar and the card headings, and on
a touch screen the bar owns that gesture and a heading is the fold toggle. Both
arrangements are made in a sheet with handles instead, and still saved to
chargerTabOrder / chargerCardOrder on the profile — so an arrangement made in
either app shows up in the other. Which cards are folded stays on the device.

Settings groups its integrations into the same categories the API Server panel
does, says Online as well as Offline, and shows firmware in a charger's line.
Shared strings are copied out of the web's i18n files rather than retyped, per
TRANSLATIONS.md; only the arrange sheet's own three are written here.

The readings and information cards look up some sixty keys by name at render
time, so models_format_test now guards those the way it guards the car's — an
enum value is deliberately left out, since a charger may report a number this
release has never heard of and falling back to it is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-02 08:27:16 +02:00
co-authored by Claude Opus 5
parent 3c64d6e84c
commit 641f427db4
11 changed files with 2961 additions and 339 deletions
+300 -8
View File
@@ -882,6 +882,13 @@ class UserProfile {
final String role; // user | admin
final String organization; // org record id ("" = belongs to no organization)
final String organizationName; // resolved name ("" when unset/unresolvable)
/// The charging page's arrangements — which order its tabs and its cards are
/// in. Kept on the profile rather than on the device because they are layout
/// choices that should follow the account, the way the garage order does.
final List<String> chargerTabOrder;
final List<String> chargerCardOrder;
final DateTime? deletionRequestedAt;
UserProfile({
@@ -899,6 +906,8 @@ class UserProfile {
required this.role,
this.organization = "",
this.organizationName = "",
this.chargerTabOrder = const [],
this.chargerCardOrder = const [],
required this.deletionRequestedAt,
});
@@ -917,6 +926,8 @@ class UserProfile {
role: j["role"] == null ? "user" : _asStr(j["role"]),
organization: _asStr(j["organization"]),
organizationName: _asStr(j["organizationName"]),
chargerTabOrder: _asStrList(j["chargerTabOrder"]),
chargerCardOrder: _asStrList(j["chargerCardOrder"]),
deletionRequestedAt: j["deletionRequestedAt"] == null
? null
: DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
@@ -1041,17 +1052,37 @@ class IntegrationHealth {
);
}
/// A charger's OCPP control view, from …/chargers/{sn}/control. Serves both the
/// Settings provisioning card (endpoint/token/connection) and the Charging home
/// tab's live control (connector status + meter).
/// A charger's control view, from …/chargers/{sn}/control — over whichever
/// transport the user's control mode selects. Serves the Settings provisioning
/// card (endpoint/token/connection) and the Charging home tab's live control.
///
/// The two transports are provisioned differently and report differently. OCPP
/// waits for the charger to dial us, so what it needs is a token installed into
/// the charger and what it reports is a session snapshot counting a meter in
/// watt-hours. Modbus dials the charger, so what it needs is an address on the
/// local network and what it reports is the whole register map — including a
/// session's own energy, which is not the same number as a meter reading.
class AnkerControl {
final String endpoint; // OCPP backend URL to point the charger at
final bool hasToken; // a per-charger token has been generated
final String tokenHint; // last chars of the token, for display
final bool connected; // the charger is connected to the control backend
final bool connected; // the charger is reachable over the active transport
final String controlMode; // off | own | proxy | modbus
final String connectorStatus; // OCPP connector status, e.g. "Charging"
final int meterWh; // last meter reading in watt-hours
final int meterWh; // last OCPP meter reading in watt-hours
/// Where the charger lives on the local network (Modbus mode only). Blank
/// until an address is saved, which is what the Charging page asks for.
final String modbusHost;
final int modbusPort;
/// The server's own sentence about why there is nothing to control — no
/// address saved yet, or a charger that did not answer at the one there is.
/// Better than a generic hint, because the server has already tried.
final String detail;
/// The Modbus register snapshot, when the active transport produced one.
final ModbusStatus? modbus;
const AnkerControl({
this.endpoint = "",
@@ -1061,23 +1092,86 @@ class AnkerControl {
this.controlMode = "off",
this.connectorStatus = "",
this.meterWh = 0,
this.modbusHost = "",
this.modbusPort = 502,
this.detail = "",
this.modbus,
});
factory AnkerControl.fromJson(Map<String, dynamic> j) {
final status = j["status"];
final s = status is Map ? Map<String, dynamic>.from(status) : const {};
final s = status is Map ? Map<String, dynamic>.from(status) : const <String, dynamic>{};
final mode = _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]);
final port = _asInt(j["modbusPort"]);
return AnkerControl(
endpoint: _asStr(j["endpoint"]),
hasToken: _asBool(j["hasToken"]),
tokenHint: _asStr(j["tokenHint"]),
connected: _asBool(j["connected"]),
controlMode: _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]),
controlMode: mode,
connectorStatus: _asStr(s["connectorStatus"]),
meterWh: _asInt(s["meterWh"]),
modbusHost: _asStr(j["modbusHost"]),
modbusPort: port == 0 ? 502 : port,
detail: _asStr(j["detail"]),
modbus: mode == "modbus" && status is Map
? ModbusStatus(Map<String, dynamic>.from(status))
: null,
);
}
double get meterKwh => meterWh / 1000.0;
bool get isModbus => controlMode == "modbus";
/// The energy this card shows. The two transports word a charging session
/// differently — an OCPP snapshot counts a meter, a Modbus one counts the
/// session — and both land in the same tile.
double get meterKwh => (isModbus ? (modbus?.sessionWh ?? 0) : meterWh) / 1000.0;
/// What the charger says it is doing, in whichever transport's words.
String get statusLabel {
final label = isModbus ? (modbus?.statusDesc ?? "") : connectorStatus;
return label.isEmpty ? "" : label;
}
}
/// The Anker charger's Modbus register snapshot.
///
/// A wrapper over the raw JSON rather than forty declared fields: the register
/// map is the server's to describe, every value is optional (a charger on older
/// firmware answers a shorter block), and a reading added to the snapshot
/// upstream surfaces here without a change to this file. The getters name what
/// the Charging page reads, and each keeps null distinct from zero — a relay
/// that reported no temperature is not a relay at 0 °C.
class ModbusStatus {
final Map<String, dynamic> raw;
const ModbusStatus(this.raw);
double? number(String key) => _asDoubleOrNull(raw[key]);
int? integer(String key) => _asIntOrNull(raw[key]);
bool? flag(String key) => raw[key] is bool ? raw[key] as bool : null;
String text(String key) => _asStr(raw[key]);
/// The control block, which the charger answers as its own object.
Map<String, dynamic> get settings {
final v = raw["settings"];
return v is Map ? Map<String, dynamic>.from(v) : const {};
}
double? setting(String key) => _asDoubleOrNull(settings[key]);
int? settingInt(String key) => _asIntOrNull(settings[key]);
bool? settingFlag(String key) => settings[key] is bool ? settings[key] as bool : null;
String get statusDesc => text("statusDesc");
int get sessionWh => _asInt(raw["sessionWh"]);
/// The alarm words, as they arrive: the spec defers what the individual bits
/// mean to a list Anker does not publish, so which word is set is still the
/// thing to report.
bool get alarm => _asBool(raw["alarm"]);
List<int> get alarms {
final v = raw["alarms"];
return v is List ? v.map(_asInt).toList() : const [];
}
}
/// One EV charger on the linked Anker account, from …/anker-solix/chargers. The
@@ -1088,6 +1182,7 @@ class AnkerCharger {
final String sn;
final String name;
final String model;
final String firmware;
final String siteName;
final String statusDesc; // charging | standby | … as the cloud names it
final bool? online; // null when no view reported a connection state
@@ -1096,6 +1191,7 @@ class AnkerCharger {
required this.sn,
this.name = "",
this.model = "",
this.firmware = "",
this.siteName = "",
this.statusDesc = "",
this.online,
@@ -1105,6 +1201,7 @@ class AnkerCharger {
sn: _asStr(j["sn"]),
name: _asStr(j["name"]),
model: _asStr(j["model"]),
firmware: _asStr(j["firmware"]),
siteName: _asStr(j["siteName"]),
statusDesc: _asStr(j["statusDesc"]),
online: j["online"] is bool ? j["online"] as bool : null,
@@ -1139,6 +1236,201 @@ class AnkerChargerList {
}
}
// --- home chargers (the user's own wallbox) ---------------------------------
//
// The garage's import, aimed at the wall: a charger on a connected service
// becomes a record of the user's own, and stays one after the account it came
// from is disconnected. It belongs to the person rather than to a car — it
// charges whichever car is plugged into it, and it outlives any of them.
/// One charger the user owns, from GET /home-chargers.
class HomeCharger {
final String id;
final String name;
final String serial;
final String vendor; // "Anker Solix", "Greencell"
final String model; // "A5191"
final String siteName; // the system it belongs to, where it has one
final double powerKw;
final String connector;
/// The service it was imported from and that service's own id for it. Both
/// blank for a charger added by hand; set only by the import, so a rename
/// cannot break the link.
final String provider;
final String providerChargerId;
final String created;
const HomeCharger({
required this.id,
this.name = "",
this.serial = "",
this.vendor = "",
this.model = "",
this.siteName = "",
this.powerKw = 0,
this.connector = "",
this.provider = "",
this.providerChargerId = "",
this.created = "",
});
factory HomeCharger.fromJson(Map<String, dynamic> j) => HomeCharger(
id: _asStr(j["id"]),
name: _asStr(j["name"]),
serial: _asStr(j["serial"]),
vendor: _asStr(j["vendor"]),
model: _asStr(j["model"]),
siteName: _asStr(j["siteName"]),
powerKw: _asDouble(j["powerKw"]),
connector: _asStr(j["connector"]),
provider: _asStr(j["provider"]),
providerChargerId: _asStr(j["providerChargerId"]),
created: _asStr(j["created"]),
);
/// The line under the name in a list: what the charger is, in as many of the
/// three terms as the record actually holds.
String get subtitle =>
[serial, model, siteName].where((s) => s.isNotEmpty).join(" · ");
}
/// One charger service the user could import from, from GET /charger-providers.
/// [detail] says, in one sentence, what to do about a closed gate — the same
/// shape the vehicle providers use.
class ChargerProvider {
final String id;
final String label; // "Anker Solix"
final String service; // "Anker Solix cloud"
final bool connected;
final String detail;
const ChargerProvider({
required this.id,
this.label = "",
this.service = "",
this.connected = false,
this.detail = "",
});
factory ChargerProvider.fromJson(Map<String, dynamic> j) => ChargerProvider(
id: _asStr(j["id"]),
label: _asStr(j["label"]),
service: _asStr(j["service"]),
connected: _asBool(j["connected"]),
detail: _asStr(j["detail"]),
);
}
/// One charger as the service that has it describes it, from
/// /charger-providers/{provider}/chargers.
///
/// This is the live half: a record says what a charger *is*, and only the
/// service it came from knows whether it is reachable right now. The import
/// sheet lists these to pick from; the Charging page holds them beside the
/// imported records to say which are online.
class ProviderCharger {
final String id; // the provider's own id — the serial, for both services
final String name;
final String vendor;
final String model;
final String firmware;
final String siteId;
final String siteName;
final String status; // the service's own word for its state
final bool? online; // null when the service reported no connection state
/// How the charger is registered on the account — standalone, inside a
/// system, or merely bound to it. A charger can be several at once.
final List<String> sources;
/// The charge power as the service words it. The unit is upstream's, so it is
/// relayed verbatim rather than given one here.
final String power;
final int? ocppStatus;
final String ocppStatusDesc;
/// Set when this charger is already in DriverVault, so the import never
/// offers the same one twice.
final String linkedChargerId;
const ProviderCharger({
required this.id,
this.name = "",
this.vendor = "",
this.model = "",
this.firmware = "",
this.siteId = "",
this.siteName = "",
this.status = "",
this.online,
this.sources = const [],
this.power = "",
this.ocppStatus,
this.ocppStatusDesc = "",
this.linkedChargerId = "",
});
factory ProviderCharger.fromJson(Map<String, dynamic> j) => ProviderCharger(
id: _asStr(j["id"]),
name: _asStr(j["name"]),
vendor: _asStr(j["vendor"]),
model: _asStr(j["model"]),
firmware: _asStr(j["firmware"]),
siteId: _asStr(j["siteId"]),
siteName: _asStr(j["siteName"]),
status: _asStr(j["status"]),
online: j["online"] is bool ? j["online"] as bool : null,
sources: _asStrList(j["sources"]),
power: _asStr(j["power"]),
ocppStatus: _asIntOrNull(j["ocppStatus"]),
ocppStatusDesc: _asStr(j["ocppStatusDesc"]),
linkedChargerId: _asStr(j["linkedChargerId"]),
);
String get subtitle =>
[vendor, model, siteName].where((s) => s.isNotEmpty).join(" · ");
/// The OCPP connector state as the *service* sees it — the cloud's own
/// reading, not our CSMS's. It words it when it can and numbers it when it
/// cannot.
String get ocppLabel {
if (ocppStatusDesc.isNotEmpty) return ocppStatusDesc;
return ocppStatus == null ? "" : "$ocppStatus";
}
}
/// The chargers on one provider account, plus the reason the list may be empty:
/// a gate that is off answers 200 with nothing and a sentence, so the UI can say
/// "connect this in Settings" rather than show a blank panel.
class ProviderChargerList {
final List<ProviderCharger> chargers;
final bool unavailable;
final String detail;
const ProviderChargerList({
this.chargers = const [],
this.unavailable = false,
this.detail = "",
});
factory ProviderChargerList.fromJson(Map<String, dynamic> j) {
final raw = j["chargers"];
return ProviderChargerList(
chargers: raw is List
? raw
.whereType<Map>()
.map((c) => ProviderCharger.fromJson(Map<String, dynamic>.from(c)))
.where((c) => c.id.isNotEmpty)
.toList()
: const [],
unavailable: _asBool(j["unavailable"]),
detail: _asStr(j["detail"]),
);
}
}
// --- vehicle providers (the connected-service tab) ---------------------------
//
// A provider is a manufacturer service a car can be linked to (MyToyota today).