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:
co-authored by
Claude Opus 5
parent
3c64d6e84c
commit
641f427db4
+300
-8
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user