Compare commits

...
3 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 4ff6242c8f The last message in the map, and it reboots the charger
0108 was the one thing in the MQTT inventory nobody had wired: the device
power mode, whose single documented value restarts the charger. It is the
only way to reboot a charger that is on neither a CSMS nor the local
network — which is most of them — so the cloud transport sends it now,
and "reset" reaches it too, since that is what the OCPP path has always
called the same act.

Nothing waits for a confirmation: the device that would send it is the
device rebooting, so the command answers at once and says the charger
drops off the cloud for about a minute. The gate is unchanged and now
covers both spellings — an explicit confirm plus a password step-up,
audited either way. Modbus still refuses, because no register does this,
but its refusal now names both transports that can rather than only the
CSMS.

Both clients already had the reset button and its password prompt; they
were hidden in every mode that reads the device, which is why the cloud
never showed one. Modbus is now the only mode without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 23:15:12 +02:00
tajniak81andClaude Opus 5 b2d333a63f The charger was never asked what it is set to
The trigger buys telemetry and only telemetry, so a charger that has been
read a hundred times and commanded none reports amps, volts and nothing
else: no schedule, no balancing, no Modbus server, not even its firmware.
The message that asks for that half is 0040, and the reference keeps it
commented out because the app sends its timestamp without a value type.
The app is what the charger answers, so the oddity is reproduced rather
than corrected — sent when the settings half is missing or older than ten
minutes, waited four seconds for, and after three unanswered requests
still sent but no longer waited on.

The three settings the panel has and the writer did not — swipe up, swipe
down, smart touch — are writable now, which is all eleven of the 0100
commands. Nothing else in the map was missing: every named field of every
message was already decoded, and the raw keys the card shows are fields
the reference does not name either.

Both cards drop a row with nothing in it, which turned a charger that
reports only its ceiling into a charger that reports no current range at
all. Half a range is still a bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 23:05:40 +02:00
tajniak81andClaude Opus 5 3f9d5b943f Four questions asked of an account that answers twenty-one
The connector called ten endpoints of the read surface the map lists, and
the charger card showed four views. Everything else an EV charger can
reach is now a capability too: the sessions and the history, the savings,
the sharing, the binding, the group, the Wi-Fi, the firmware and its
update log, the tamper records, the site's own detail, price, networks
and energy — plus the vehicle catalogue, dynamic pricing, the currencies
and the notification views. Thirty-eight endpoints, one action each. The
two message views are GET, so the request path grew a GET half that shares
the login retry with the POST one.

The per-charger fan-out asks all of them, six at a time rather than one
after another, and a charger that belongs to a site brings that site's
four views with it once the by-serial lookup has found it. A view that
answers with nothing now says so instead of vanishing: the station record
is empty for a standalone charger because it has no station, which is an
answer worth reading. And "source 0" in the OCPP box carries the address
the account's endpoint list gives it.

Anker's account-level writes stay out, as do the endpoints whose payloads
were only ever read out of the app package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 22:53:36 +02:00
24 changed files with 1169 additions and 95 deletions
@@ -545,9 +545,11 @@ func (s *Server) handleAnkerChargers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
// GET /api/integrations/anker-solix/chargers/{sn}/details — every per-charger
// view the account holds: the station record, the charging totals, the OCPP
// backend and the RFID cards. Gated exactly like the charger list, and answered
// GET /api/integrations/anker-solix/chargers/{sn}/details — 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 binding, the
// firmware and the rest, plus the site's own views when the charger belongs to
// one. Gated exactly like the charger list, and answered
// the same way when a gate is off: 200 with nothing and the reason, because a
// disconnected integration is a normal state with an answer.
func (s *Server) handleAnkerChargerDetails(w http.ResponseWriter, r *http.Request) {
@@ -604,7 +604,7 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request
operative = *body.Operative
}
status, err = sess.ChangeAvailability(ctx, body.ConnectorID, operative)
case "reset":
case "reset", "restart":
status, err = sess.Reset(ctx, body.Hard)
case "unlock":
status, err = sess.UnlockConnector(ctx, body.ConnectorID)
@@ -649,7 +649,10 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request
// releases the cable lock — actions that require an explicit confirm:true and a
// password re-authentication.
func isDestructiveAction(action string) bool {
return action == "reset" || action == "unlock"
// "restart" is the cloud's name for the same act as OCPP's "reset": both
// reboot the charger, so both pass through the confirmation and the password
// step-up rather than one slipping past because it is spelled differently.
return action == "reset" || action == "restart" || action == "unlock"
}
// reauthenticate verifies the caller's password against PocketBase (a sudo-style
@@ -43,7 +43,13 @@ func (s *Server) ankerModbusAction(w http.ResponseWriter, r *http.Request, who *
// Actions the register map has no equivalent for. Saying which transport is
// missing them beats a bare "unknown action" the caller cannot act on.
switch action {
case "reset", "unlock", "availability", "trigger", "config":
case "reset", "restart":
// No register reboots the charger. Both of the other transports can, so
// the refusal names them rather than only the CSMS.
writeError(w, http.StatusBadRequest,
"no register reboots the charger; the local Modbus connection cannot restart it. Switch the control mode to Anker cloud (MQTT) or a CSMS mode to use it.")
return
case "unlock", "availability", "trigger", "config":
writeError(w, http.StatusBadRequest,
"\""+action+"\" is an OCPP command; the local Modbus connection cannot send it. Switch the control mode to a CSMS mode to use it.")
return
@@ -18,11 +18,11 @@ package api
// or certificated on the customer's side; what it costs instead is a dependency
// on Anker's cloud being up, and on an unofficial protocol.
//
// The command set is the charger's, not OCPP's: start, stop, boost, skip-delay
// and a current limit, plus the one thing neither other transport can do at all
// — writing the charger's own configuration, which is what "settings" is for.
// Everything the register map or the CSMS can do that this cannot is refused by
// name rather than as an unknown action.
// The command set is the charger's, not OCPP's: start, stop, boost, skip-delay,
// a current limit and a restart, plus the one thing neither other transport can
// do at all — writing the charger's own configuration, which is what "settings"
// is for. Everything the register map or the CSMS can do that this cannot is
// refused by name rather than as an unknown action.
import (
"context"
@@ -59,7 +59,7 @@ func (s *Server) ankerMqttAction(w http.ResponseWriter, r *http.Request, who *ca
// Actions this transport has no equivalent for. Naming the transport that
// does have them beats a bare "unknown action" the caller cannot act on.
switch action {
case "reset", "unlock", "availability", "trigger", "config":
case "unlock", "availability", "trigger", "config":
writeError(w, http.StatusBadRequest,
"\""+action+"\" is an OCPP command; the Anker cloud connection cannot send it. Switch the control mode to a CSMS mode to use it.")
return
@@ -96,6 +96,11 @@ func (s *Server) ankerMqttAction(w http.ResponseWriter, r *http.Request, who *ca
case "limit":
params["amps"] = body.Amps
payload["command"], payload["amps"] = "limit", body.Amps
case "reset", "restart":
// The charger's own restart, the cloud's answer to the OCPP reset. It has
// already been through the confirmation and the password step-up upstairs,
// like any other reboot.
payload["command"] = "restart"
case "settings":
// The values themselves are audited, not just the fact of a write: a
// setting that changes what the charger will draw, or whether it answers on
@@ -24,7 +24,6 @@ func refuse(t *testing.T, action string, body ankerControlBody) *httptest.Respon
// unknown.
func TestAnkerMqttActionNamesTheTransportThatCan(t *testing.T) {
for _, tc := range []struct{ action, want string }{
{"reset", "CSMS"},
{"unlock", "CSMS"},
{"availability", "CSMS"},
{"config", "CSMS"},
@@ -43,6 +42,21 @@ func TestAnkerMqttActionNamesTheTransportThatCan(t *testing.T) {
}
}
// A reboot is a reboot under either name and over any transport: both go through
// the confirmation and the password step-up, and nothing else does.
func TestBothNamesForARebootAreGated(t *testing.T) {
for _, action := range []string{"reset", "restart", "unlock"} {
if !isDestructiveAction(action) {
t.Errorf("%s should need a confirmation and a password", action)
}
}
for _, action := range []string{"start", "stop", "limit", "boost", "settings", "status"} {
if isDestructiveAction(action) {
t.Errorf("%s should not demand a password", action)
}
}
}
func TestAnkerMqttActionRejectsUnknownActions(t *testing.T) {
rec := refuse(t, "explode", ankerControlBody{})
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "unknown control action") {
@@ -47,6 +47,7 @@ import (
"fmt"
"io"
"net/http"
neturl "net/url"
"sort"
"strings"
"sync"
@@ -88,6 +89,69 @@ const (
epUserVehicles = "power_service/v1/app/vehicle/get_vehicle_list" // vehicles registered for smart charging
epVehicleDetail = "power_service/v1/app/vehicle/get_vehicle_detail" // details for one registered vehicle
// The rest of the read surface an EV charger can reach, from the same source
// as the endpoints above (anker-solix-api's apitypes.py, filtered to what a
// charger touches) and catalogued in anker-api-map.html. Every one of these
// reads; the account-level writes the app makes — vehicles, site prices,
// sharing invites, quiet hours, auto-upgrade — are deliberately not here, and
// neither is any endpoint whose payload was only ever read out of the app
// package rather than called ("unmapped" in the map).
// Finding and identifying a charger.
epSiteDetailBySN = "power_service/v1/site/get_site_detail_by_sn" // the site a charger belongs to, from its serial alone
epSiteDetail = "power_service/v1/site/get_site_detail" // site detail; answers for shared accounts too
epSiteHomepage = "power_service/v1/site/get_site_homepage" // the app home screen's own view of every site
epUserDevices = "power_service/v1/site/list_user_devices" // owned devices, lighter than the bound-device view
epDeviceBindDetails = "power_service/v1/app/get_device_bind_details" // binding details for a list of serials
epGroupDevices = "power_service/v1/app/group/get_group_devices" // whether a charger is grouped with sub-devices
epSiteWifiList = "power_service/v1/site/get_wifi_info_list" // networks the site can see
epDeviceProductInfo = "charging_hes_svc/get_device_product_info" // product info for HES-family devices
epInstallInfo = "charging_hes_svc/get_install_info" // where the system was installed
epChargerWifiInfo = "charging_hes_svc/get_wifi_info" // the charger's own Wi-Fi details
// Charging sessions — where the completed history lives.
epChargingOrders = "power_service/v1/app/order/get_charging_order_list" // every session in a date range
epChargingOrder = "power_service/v1/app/order/get_charging_order_detail" // one session, with its chart points and vehicle
epOrderSecDetail = "power_service/v1/app/order/get_charging_order_sec_detail" // second-resolution detail for a session
epOrderSecPreview = "power_service/v1/app/order/get_charging_order_sec_preview" // preview of the same
// Energy, price and what it cost.
epEnergyStatistics = "charging_hes_svc/get_energy_statistics" // energy stats with the EV charger as its own source
epDeviceIncome = "power_service/v1/app/device/get_device_income" // income / savings figures per device
epSitePrice = "power_service/v1/site/get_site_price" // the site's power price and CO2 factor
epCurrencyList = "power_service/v1/currency/get_list" // supported currencies
epSiteDataExport = "power_service/v1/site/site_data_exported" // filename and URL for a CSV export
epDynPriceCheck = "power_service/v1/dynamic_price/check_available" // which sites have dynamic pricing at all
epDynPriceOptions = "power_service/v1/dynamic_price/support_option" // price providers for a product code
epDynPriceDetail = "power_service/v1/dynamic_price/price_detail" // the actual price curve
// The vehicle catalogue behind smart charging.
epVehicleBrands = "power_service/v1/app/get_brand_list" // vehicle brands
epVehicleModels = "power_service/v1/app/get_models" // models for a brand
epVehicleYears = "power_service/v1/app/get_model_years" // production years for a model
epVehicleSpecs = "power_service/v1/app/get_model_list" // what Anker knows about that exact car
// Sharing.
epSharedDevice = "app/devicerelation/get_shared_device" // who a charger is currently shared with
// Firmware.
epOtaBatchCheck = "app/ota/batch/check_update" // latest available version per serial
epOtaInfo = "power_service/v1/app/compatible/get_ota_info" // current OTA status
epOtaUpdate = "power_service/v1/app/compatible/get_ota_update" // details of an available update
epUpgradeRecords = "power_service/v1/app/get_upgrade_record" // firmware update history
epUpgradeRecord = "power_service/v1/app/check_upgrade_record" // one update record, three views of it
epAutoUpgrade = "power_service/v1/app/get_auto_upgrade" // which devices auto-update
// Notifications, and the charging events behind them. The first two are the
// only endpoints in this connector Anker serves over GET.
epMessageUnread = "power_service/v1/get_message_unread" // whether anything is waiting (GET)
epMessages = "power_service/v1/get_message" // the messages themselves (GET, last_time)
epMessageNotDisturb = "power_service/v1/get_message_not_disturb" // current quiet-hours settings
epMessageSNList = "power_service/v1/get_message_sn_list" // which devices produce messages at all
// Health and faults.
epTamperRecords = "power_service/v1/device/get_tamper_records" // tamper records for a device
// The cloud MQTT broker's own credentials endpoint is epMqttInfo, declared in
// cloudmqtt.go next to the transport that uses it.
)
@@ -181,7 +245,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
AuthType: plugins.AuthBasic,
Capabilities: []plugins.Capability{
{ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "Every EV charger on the account, merged from the standalone, per-site, bound-device and per-charger station views, each charger carrying every field those views reported (see chargers.go)."},
{ID: "charger-details", Method: "POST", Endpoint: epStationInfo, Description: "Every per-charger view the account holds — the station record, the charging totals, the OCPP backend and the RFID cards — each relayed as the fields it sent (needs sn)."},
{ID: "charger-details", Method: "POST", Endpoint: epStationInfo, Description: "Every view the account holds about one charger — the station record, the totals, the history and sessions, the savings, the OCPP backend and the endpoints that name its source, the cards, the sharing, the binding, the group, the Wi-Fi, the firmware, the tamper log, and the site's own views when it belongs to one — each relayed as the fields it sent (needs sn)."},
{ID: "charger-status", Method: "POST", Endpoint: epStationInfo, Description: "Live station/status info for one charger (needs sn; optional featuretype 1 or 2)."},
{ID: "charger-state", Method: "POST", Endpoint: epSceneInfo, Description: "Normalized live state of a site's EV chargers: status, operational mode and the modes it can be switched to (needs siteId; optional sn)."},
{ID: "site-status", Method: "POST", Endpoint: epSceneInfo, Description: "Live site view; EV chargers appear under charging_pile_info (needs siteId)."},
@@ -195,8 +259,51 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
{ID: "sites", Method: "POST", Endpoint: epSiteList, Description: "Sites (systems) registered to the account."},
{ID: "vehicles", Method: "POST", Endpoint: epUserVehicles, Description: "Vehicles registered for smart charging."},
{ID: "vehicle", Method: "POST", Endpoint: epVehicleDetail, Description: "Details for one registered vehicle (needs vehicleId)."},
// The rest of the read surface an EV charger can reach, one action per
// endpoint, so anything the account will say can be asked for without
// a new capability being invented for it first. The account-level
// writes the app makes and the endpoints nobody has ever called stay
// out (see the endpoint block above).
{ID: "site-by-sn", Method: "POST", Endpoint: epSiteDetailBySN, Description: "The site a charger belongs to, from its serial alone (needs sn)."},
{ID: "site-detail", Method: "POST", Endpoint: epSiteDetail, Description: "Site detail; answers for shared accounts too (needs siteId)."},
{ID: "site-homepage", Method: "POST", Endpoint: epSiteHomepage, Description: "The app home screen's own view of every site."},
{ID: "user-devices", Method: "POST", Endpoint: epUserDevices, Description: "Owned devices, a lighter list than the bound-device view."},
{ID: "bind-details", Method: "POST", Endpoint: epDeviceBindDetails, Description: "Binding details for one or more serials (needs sn, or sns)."},
{ID: "group-devices", Method: "POST", Endpoint: epGroupDevices, Description: "Whether a charger is grouped with sub-devices (needs sn)."},
{ID: "site-wifi", Method: "POST", Endpoint: epSiteWifiList, Description: "The networks a site can see (needs siteId)."},
{ID: "charger-wifi", Method: "POST", Endpoint: epChargerWifiInfo, Description: "The charger's own Wi-Fi details (needs sn)."},
{ID: "product-info", Method: "POST", Endpoint: epDeviceProductInfo, Description: "Product info for HES-family devices (optional sn)."},
{ID: "install-info", Method: "POST", Endpoint: epInstallInfo, Description: "Where the system was installed (optional sn, siteId)."},
{ID: "charging-orders", Method: "POST", Endpoint: epChargingOrders, Description: "Every charging session in a date range - a plainer list than the paged stats one (needs sn; optional startTime)."},
{ID: "charging-order", Method: "POST", Endpoint: epChargingOrder, Description: "One session with its chart points and the vehicle it charged (needs sn and orderId)."},
{ID: "order-seconds", Method: "POST", Endpoint: epOrderSecDetail, Description: "Second-resolution detail for one session (needs orderId; optional startTime)."},
{ID: "order-seconds-preview", Method: "POST", Endpoint: epOrderSecPreview, Description: "Preview of the second-resolution session detail (needs orderId)."},
{ID: "energy-statistics", Method: "POST", Endpoint: epEnergyStatistics, Description: "Energy statistics with the EV charger as its own source (optional sn, siteId)."},
{ID: "device-income", Method: "POST", Endpoint: epDeviceIncome, Description: "Income / savings figures for one charger (needs sn; optional startTime)."},
{ID: "site-price", Method: "POST", Endpoint: epSitePrice, Description: "A site's power price and CO2 factor (needs siteId)."},
{ID: "currencies", Method: "POST", Endpoint: epCurrencyList, Description: "The currencies the cloud supports."},
{ID: "site-export", Method: "POST", Endpoint: epSiteDataExport, Description: "Filename and URL for a site's CSV export, where the system supports one (needs siteId; optional startTime, endTime)."},
{ID: "dynamic-price-available", Method: "POST", Endpoint: epDynPriceCheck, Description: "Which sites have dynamic pricing at all."},
{ID: "dynamic-price-options", Method: "POST", Endpoint: epDynPriceOptions, Description: "Price providers for a product code and the login country (needs devicePn)."},
{ID: "dynamic-price", Method: "POST", Endpoint: epDynPriceDetail, Description: "The dynamic price curve smart charging would schedule against (needs area and company; optional date, sn)."},
{ID: "vehicle-brands", Method: "POST", Endpoint: epVehicleBrands, Description: "Vehicle brands - the first picker behind adding a car."},
{ID: "vehicle-models", Method: "POST", Endpoint: epVehicleModels, Description: "Models for a brand (needs brand)."},
{ID: "vehicle-years", Method: "POST", Endpoint: epVehicleYears, Description: "Production years for a model (needs brand and model)."},
{ID: "vehicle-specs", Method: "POST", Endpoint: epVehicleSpecs, Description: "What Anker knows about one exact car (needs brand, model and year)."},
{ID: "shared-with", Method: "POST", Endpoint: epSharedDevice, Description: "Who a charger is currently shared with (needs sn)."},
{ID: "ota-check", Method: "POST", Endpoint: epOtaBatchCheck, Description: "The latest firmware available per serial (needs sn, or sns)."},
{ID: "ota-info", Method: "POST", Endpoint: epOtaInfo, Description: "Current OTA status for one or more serials (needs sn, or sns)."},
{ID: "ota-update", Method: "POST", Endpoint: epOtaUpdate, Description: "Details of an available update (needs sn, or sns)."},
{ID: "upgrade-records", Method: "POST", Endpoint: epUpgradeRecords, Description: "Firmware update history (optional sn)."},
{ID: "upgrade-record", Method: "POST", Endpoint: epUpgradeRecord, Description: "One update record, in one of three views (needs sn; optional type 1-3)."},
{ID: "auto-upgrade", Method: "POST", Endpoint: epAutoUpgrade, Description: "Which devices update themselves."},
{ID: "messages-unread", Method: "GET", Endpoint: epMessageUnread, Description: "Whether any notification is waiting."},
{ID: "messages", Method: "GET", Endpoint: epMessages, Description: "The notifications themselves (optional lastTime cursor)."},
{ID: "quiet-hours", Method: "POST", Endpoint: epMessageNotDisturb, Description: "The account's quiet-hours settings, and the charging events they cover."},
{ID: "message-devices", Method: "POST", Endpoint: epMessageSNList, Description: "Which devices produce notifications at all."},
{ID: "tamper-records", Method: "POST", Endpoint: epTamperRecords, Description: "Tamper records for one device (needs sn; optional page, pageSize)."},
{ID: "mqtt-status", Method: "POST", Endpoint: epMqttInfo, Description: "Live state of one charger over Anker's cloud MQTT broker — the path to a charger the server cannot reach (needs sn)."},
{ID: "mqtt-command", Method: "POST", Endpoint: epMqttInfo, Description: "Control one charger over Anker's cloud MQTT broker: start, stop, boost, skip-delay, limit (with amps) or trigger (needs sn and command)."},
{ID: "mqtt-command", Method: "POST", Endpoint: epMqttInfo, Description: "Control one charger over Anker's cloud MQTT broker: start, stop, boost, skip-delay, limit (with amps), trigger or restart (needs sn and command)."},
{ID: "mqtt-settings", Method: "POST", Endpoint: epMqttInfo, Description: "Write one charger's settings over Anker's cloud MQTT broker — current ceiling, switches, schedules, load balancing and solar charging (needs sn and settings)."},
},
ConfigFields: []plugins.ConfigField{
@@ -294,6 +401,26 @@ type invokeParams struct {
StartDate string `json:"startDate"` // YYYY-MM-DD, or YYYY-MM / YYYY for month / year
EndDate string `json:"endDate"`
// The wider read surface. Session history addresses a session by id, the
// vehicle catalogue walks brand -> model -> year, dynamic pricing is asked
// per area and provider, and a few views take several serials at once. Each
// field is read only by the actions that name it.
SNs []string `json:"sns"` // several serials at once (binding details, OTA)
OrderID string `json:"orderId"` // one charging session
StartTime string `json:"startTime"` // upstream's own epoch/date field, passed through
EndTime string `json:"endTime"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Type int `json:"type"` // upgrade-record view, 1-3
LastTime string `json:"lastTime"` // message cursor, format undocumented upstream
Brand string `json:"brand"`
Model string `json:"model"`
Year string `json:"year"`
DevicePN string `json:"devicePn"` // product code, for dynamic-price providers
Area string `json:"area"`
Company string `json:"company"`
Date string `json:"date"`
// The cloud MQTT actions: which command to send, the current ceiling "limit"
// carries, and the settings "mqtt-settings" writes, by the names the snapshot
// reports them under.
@@ -352,6 +479,7 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
var (
endpoint string
payload map[string]any
query map[string]string // set instead of payload for the two GET views
needSN bool
needSite bool
)
@@ -403,6 +531,147 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
}
endpoint = epVehicleDetail
payload = map[string]any{"vehicle_id": pp.VehicleID}
// --- the rest of the read surface ---------------------------------------
// Bodies are upstream's own field names, taken from the endpoint map; a
// field the caller left empty is still sent, because the cloud treats an
// empty string as "no filter" on every one of these.
case "site-by-sn":
endpoint, needSN = epSiteDetailBySN, true
payload = map[string]any{"device_sn": pp.SN}
case "site-detail":
endpoint, needSite = epSiteDetail, true
payload = map[string]any{"site_id": pp.SiteID}
case "site-homepage":
endpoint, payload = epSiteHomepage, map[string]any{}
case "user-devices":
endpoint, payload = epUserDevices, map[string]any{}
case "bind-details", "ota-check", "ota-info", "ota-update":
// The four views that ask about a list of serials rather than one. Both
// spellings are accepted: sns for several, sn for the usual one.
sns := serialList(pp)
if len(sns) == 0 {
return nil, fmt.Errorf("anker-solix: action %q requires an sn (or sns)", action)
}
switch action {
case "bind-details":
endpoint = epDeviceBindDetails
case "ota-check":
endpoint = epOtaBatchCheck
case "ota-info":
endpoint = epOtaInfo
default:
endpoint = epOtaUpdate
}
payload = map[string]any{"device_sn_list": sns}
case "group-devices":
endpoint, needSN = epGroupDevices, true
payload = map[string]any{"device_sn": pp.SN}
case "site-wifi":
endpoint, needSite = epSiteWifiList, true
payload = map[string]any{"site_id": pp.SiteID}
case "charger-wifi":
endpoint, needSN = epChargerWifiInfo, true
payload = map[string]any{"evChargerSn": pp.SN}
case "product-info":
endpoint = epDeviceProductInfo
payload = map[string]any{"evChargerSn": pp.SN}
case "install-info":
endpoint = epInstallInfo
payload = map[string]any{"evChargerSn": pp.SN, "siteId": pp.SiteID}
case "charging-orders":
endpoint, needSN = epChargingOrders, true
payload = map[string]any{"device_sn": pp.SN, "start_time": pp.StartTime}
case "charging-order":
if pp.OrderID == "" {
return nil, fmt.Errorf("anker-solix: action %q requires an orderId", action)
}
endpoint, needSN = epChargingOrder, true
payload = map[string]any{"device_sn": pp.SN, "order_id": pp.OrderID}
case "order-seconds", "order-seconds-preview":
if pp.OrderID == "" {
return nil, fmt.Errorf("anker-solix: action %q requires an orderId", action)
}
if action == "order-seconds" {
endpoint = epOrderSecDetail
payload = map[string]any{"order_id": pp.OrderID, "start_time": pp.StartTime}
} else {
endpoint = epOrderSecPreview
payload = map[string]any{"order_id": pp.OrderID}
}
case "energy-statistics":
endpoint = epEnergyStatistics
payload = map[string]any{"sourceType": "evCharger", "evChargerSn": pp.SN, "siteId": pp.SiteID}
case "device-income":
endpoint, needSN = epDeviceIncome, true
payload = map[string]any{"device_sn": pp.SN, "start_time": pp.StartTime}
case "site-price":
endpoint, needSite = epSitePrice, true
payload = map[string]any{"site_id": pp.SiteID}
case "currencies":
endpoint, payload = epCurrencyList, map[string]any{}
case "site-export":
endpoint, needSite = epSiteDataExport, true
payload = map[string]any{"site_id": pp.SiteID, "start_time": pp.StartTime, "end_time": pp.EndTime}
case "dynamic-price-available":
endpoint, payload = epDynPriceCheck, map[string]any{}
case "dynamic-price-options":
if pp.DevicePN == "" {
return nil, fmt.Errorf("anker-solix: action %q requires a devicePn (the charger's product code)", action)
}
endpoint = epDynPriceOptions
payload = map[string]any{"device_pn": pp.DevicePN}
case "dynamic-price":
if pp.Area == "" || pp.Company == "" {
return nil, fmt.Errorf("anker-solix: action %q requires an area and a company (from dynamic-price-options)", action)
}
endpoint = epDynPriceDetail
payload = map[string]any{"area": pp.Area, "company": pp.Company, "date": pp.Date, "device_sn": pp.SN}
case "vehicle-brands":
endpoint, payload = epVehicleBrands, map[string]any{}
case "vehicle-models":
if pp.Brand == "" {
return nil, fmt.Errorf("anker-solix: action %q requires a brand", action)
}
endpoint = epVehicleModels
payload = map[string]any{"brand_name": pp.Brand}
case "vehicle-years":
if pp.Brand == "" || pp.Model == "" {
return nil, fmt.Errorf("anker-solix: action %q requires a brand and a model", action)
}
endpoint = epVehicleYears
payload = map[string]any{"brand_name": pp.Brand, "model_name": pp.Model}
case "vehicle-specs":
if pp.Brand == "" || pp.Model == "" || pp.Year == "" {
return nil, fmt.Errorf("anker-solix: action %q requires a brand, a model and a year", action)
}
endpoint = epVehicleSpecs
payload = map[string]any{"brand_name": pp.Brand, "model_name": pp.Model, "productive_year": pp.Year}
case "shared-with":
endpoint, needSN = epSharedDevice, true
payload = map[string]any{"device_sn": pp.SN}
case "upgrade-records":
endpoint = epUpgradeRecords
payload = map[string]any{"device_sn": pp.SN}
case "upgrade-record":
endpoint, needSN = epUpgradeRecord, true
payload = map[string]any{"device_sn": pp.SN, "type": upgradeRecordView(pp.Type)}
case "auto-upgrade":
endpoint, payload = epAutoUpgrade, map[string]any{}
case "messages-unread":
endpoint, query = epMessageUnread, map[string]string{}
case "messages":
endpoint, query = epMessages, map[string]string{}
if pp.LastTime != "" {
query["last_time"] = pp.LastTime
}
case "quiet-hours":
endpoint, payload = epMessageNotDisturb, map[string]any{}
case "message-devices":
endpoint, payload = epMessageSNList, map[string]any{}
case "tamper-records":
endpoint, needSN = epTamperRecords, true
payload = map[string]any{"device_sn": pp.SN, "page_num": pageNum(pp.Page), "page_size": pageSize(pp.PageSize)}
default:
return nil, fmt.Errorf("anker-solix: unknown action %q", action)
}
@@ -413,6 +682,14 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action)
}
// Two of these are GET views; everything else is a POST with a JSON body.
if query != nil {
body, err := p.apiGet(ctx, endpoint, query)
if err != nil {
return nil, err
}
return json.RawMessage(body), nil
}
body, err := p.apiRequest(ctx, endpoint, payload)
if err != nil {
return nil, err
@@ -420,6 +697,48 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
return json.RawMessage(body), nil
}
// serialList is the serials a multi-serial view is asked about: the several it
// was given, or the single one every other action takes, so a caller never has
// to know which spelling an endpoint wanted.
func serialList(pp invokeParams) []string {
out := make([]string, 0, len(pp.SNs)+1)
for _, sn := range pp.SNs {
if sn = strings.TrimSpace(sn); sn != "" {
out = append(out, sn)
}
}
if len(out) == 0 && pp.SN != "" {
out = append(out, pp.SN)
}
return out
}
// upgradeRecordView clamps check_upgrade_record's view selector, which upstream
// documents as 1-3 and nothing more; 1 is what the app asks for.
func upgradeRecordView(t int) int {
if t < 1 || t > 3 {
return 1
}
return t
}
// pageNum and pageSize give the paged views a first page and a readable page
// when the caller does not care, rather than sending a zero the cloud reads as
// "no page at all".
func pageNum(n int) int {
if n < 1 {
return 1
}
return n
}
func pageSize(n int) int {
if n < 1 || n > 100 {
return 20
}
return n
}
// energyRange normalizes the energy_analysis period, defaulting to a week like
// the reference implementation's EV-charger poll.
func energyRange(r string) string {
@@ -844,13 +1163,31 @@ func (p *Plugin) storeToken(body []byte) error {
// is rejected. It returns the raw response body; a non-zero API code or non-2xx
// status is returned as an error.
func (p *Plugin) apiRequest(ctx context.Context, endpoint string, payload map[string]any) ([]byte, error) {
return p.apiCall(ctx, endpoint, func(tok tokenInfo, gmt string) ([]byte, int, error) {
return p.doRequest(ctx, endpoint, payload, tok.authToken, tok.gtoken, gmt)
})
}
// apiGet is apiRequest for the two message views, the only endpoints in this
// connector Anker serves over GET: same headers, same credential, same one
// retry after a fresh login — a query string instead of a body.
func (p *Plugin) apiGet(ctx context.Context, endpoint string, query map[string]string) ([]byte, error) {
return p.apiCall(ctx, endpoint, func(tok tokenInfo, gmt string) ([]byte, int, error) {
return p.doGet(ctx, endpoint, query, tok.authToken, tok.gtoken, gmt)
})
}
// apiCall is what both of those share: a token, one attempt, and — when the
// cloud rejects the token — a fresh login and exactly one more. There is no
// refresh token, so a rejected token can only be answered with a new login.
func (p *Plugin) apiCall(ctx context.Context, endpoint string, send func(tokenInfo, string) ([]byte, int, error)) ([]byte, error) {
tok, err := p.ensureToken(ctx)
if err != nil {
return nil, err
}
gmt, _ := timezone()
body, status, err := p.doRequest(ctx, endpoint, payload, tok.authToken, tok.gtoken, gmt)
body, status, err := send(tok, gmt)
if err != nil {
return nil, err
}
@@ -869,7 +1206,7 @@ func (p *Plugin) apiRequest(ctx context.Context, endpoint string, payload map[st
if lerr != nil {
return nil, lerr
}
body, status, err = p.doRequest(ctx, endpoint, payload, newTok.authToken, newTok.gtoken, gmt)
body, status, err = send(newTok, gmt)
if err != nil {
return nil, err
}
@@ -896,16 +1233,7 @@ func (p *Plugin) doRequest(ctx context.Context, endpoint string, payload map[str
if err != nil {
return nil, 0, err
}
req.Header.Set("content-type", "application/json")
req.Header.Set("model-type", "DESKTOP")
req.Header.Set("app-name", "anker_power")
req.Header.Set("os-type", "android")
req.Header.Set("country", p.countryId)
req.Header.Set("timezone", gmt)
if authToken != "" {
req.Header.Set("x-auth-token", authToken)
req.Header.Set("gtoken", gtoken)
}
ankerHeaders(req, p.countryId, authToken, gtoken, gmt)
resp, err := p.client.Do(req)
if err != nil {
@@ -916,6 +1244,47 @@ func (p *Plugin) doRequest(ctx context.Context, endpoint string, payload map[str
return respBody, resp.StatusCode, nil
}
// doGet issues a single GET with the same headers a POST carries. Only the two
// message views are served this way.
func (p *Plugin) doGet(ctx context.Context, endpoint string, query map[string]string, authToken, gtoken, gmt string) ([]byte, int, error) {
url := p.apiBase + "/" + endpoint
if len(query) > 0 {
vals := neturl.Values{}
for k, v := range query {
vals.Set(k, v)
}
url += "?" + vals.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
ankerHeaders(req, p.countryId, authToken, gtoken, gmt)
resp, err := p.client.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("anker-solix: request %s: %w", endpoint, err)
}
defer drain(resp)
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
return respBody, resp.StatusCode, nil
}
// ankerHeaders sets the identity every request carries. When authToken is empty
// the credential headers are omitted (the login request).
func ankerHeaders(req *http.Request, countryId, authToken, gtoken, gmt string) {
req.Header.Set("content-type", "application/json")
req.Header.Set("model-type", "DESKTOP")
req.Header.Set("app-name", "anker_power")
req.Header.Set("os-type", "android")
req.Header.Set("country", countryId)
req.Header.Set("timezone", gmt)
if authToken != "" {
req.Header.Set("x-auth-token", authToken)
req.Header.Set("gtoken", gtoken)
}
}
// ---- crypto & small helpers --------------------------------------------------
// encryptPassword AES-256-CBC encrypts the password under the ECDH shared secret
@@ -3,11 +3,18 @@ package ankersolix
// The account's other views of one charger.
//
// The merged inventory (chargers.go) answers "what chargers are there", and it
// asks the views that list them. Four more endpoints answer only when a serial
// is named: the station record the app opens on a charger, its cumulative
// charging totals, which OCPP backend it is pointed at, and the RFID cards
// authorised on it. None of them lists a charger, so none belongs in the merge —
// and none of them was reachable from the app at all until this capability.
// asks the views that list them. Everything else the cloud will say about one
// charger answers only when a serial is named the station record, the
// charging totals and the completed sessions, the OCPP backend, the RFID cards,
// the binding, the sharing, the firmware, the Wi-Fi, the tamper log — so none of
// them can be part of the merge, and none of them was reachable from the app at
// all until this capability.
//
// Every read the endpoint map lists for one charger is asked here, rather than a
// chosen few: which of them an account can answer is itself the finding, and a
// view nobody asks for is a field nobody can see. A charger that belongs to a
// site brings four more with it — the site is only known once the first pass has
// asked for it, so the site's own views are a second pass.
//
// What they answer with is not documented, by Anker or by the reference: the
// endpoints are known, their payloads are not. So each view is relayed as the
@@ -19,15 +26,24 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
)
// chargerDetailView is one endpoint's answer about one charger. A view that
// fails carries its reason instead of its fields: an account that is not the
// owner cannot read the cards, which is a fact about the account rather than an
// error in the read.
// error in the read. A view that answers with nothing carries neither, and says
// so by being empty — a standalone charger has no station record, and that is
// worth seeing too.
//
// Note is the one thing said in DriverVault's own words rather than the cloud's:
// a number the account elsewhere gives a meaning to, resolved. It never replaces
// a field, so the cloud's own keys stay exactly as they arrived.
type chargerDetailView struct {
ID string `json:"id"`
Attrs map[string]string `json:"attrs,omitempty"`
Note string `json:"note,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -36,6 +52,20 @@ type chargerDetailsDoc struct {
Views []chargerDetailView `json:"views"`
}
// detailRequest is one view to ask for: the id it is reported under, the
// endpoint, and the body it takes.
type detailRequest struct {
id string
endpoint string
payload map[string]any
}
// detailFanout is how many of these are in flight at once. The cloud is a
// stranger's API being asked twenty questions about one charger; a handful at a
// time keeps the whole card under a couple of seconds without arriving as a
// burst that looks like something to rate-limit.
const detailFanout = 6
// chargerDetails asks every per-charger endpoint and returns what each answered.
// One failing view is reported in place; only losing all of them is an error,
// for the same reason the inventory works that way — a charger the cloud will
@@ -45,35 +75,162 @@ func (p *Plugin) chargerDetails(ctx context.Context, sn string) (json.RawMessage
return nil, err
}
views := []struct {
id string
endpoint string
payload map[string]any
}{
// Everything that needs only the serial. Ordered as the card reads them:
// what it is doing, what it has done, who may use it, what it is, and the
// housekeeping the account keeps about it.
views := []detailRequest{
{"station", epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 1}},
{"stationFeature", epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 2}},
{"totals", epChargeStats, map[string]any{
"device_sn": sn, "date_type": "all", "start_date": "", "end_date": ""}},
{"orders", epChargeStatsList, map[string]any{
"device_sn": sn, "order_status": 1, "date_type": "all",
"start_date": "", "end_date": "", "page": 0, "page_size": 10}},
{"sessions", epChargingOrders, map[string]any{"device_sn": sn, "start_time": ""}},
{"income", epDeviceIncome, map[string]any{"device_sn": sn, "start_time": ""}},
{"ocpp", epOcppInfo, map[string]any{"device_sn": sn}},
{"ocppEndpoints", epOcppEndpoints, map[string]any{}},
{"rfid", epRfidCards, map[string]any{"device_sn": sn}},
{"shared", epSharedDevice, map[string]any{"device_sn": sn}},
{"binding", epDeviceBindDetails, map[string]any{"device_sn_list": []string{sn}}},
{"group", epGroupDevices, map[string]any{"device_sn": sn}},
{"wifi", epChargerWifiInfo, map[string]any{"evChargerSn": sn}},
{"ota", epOtaInfo, map[string]any{"device_sn_list": []string{sn}}},
{"upgrades", epUpgradeRecords, map[string]any{"device_sn": sn}},
{"tamper", epTamperRecords, map[string]any{"device_sn": sn, "page_num": 1, "page_size": 20}},
{"siteBySn", epSiteDetailBySN, map[string]any{"device_sn": sn}},
}
doc := chargerDetailsDoc{SN: sn, Views: make([]chargerDetailView, 0, len(views))}
failed := 0
for _, v := range views {
out := chargerDetailView{ID: v.id}
body, err := p.apiRequest(ctx, v.endpoint, v.payload)
if err != nil {
out.Error, failed = shorten(err.Error()), failed+1
} else {
out.Attrs = map[string]string{}
flattenInto(out.Attrs, "", dataValue(body))
}
doc.Views = append(doc.Views, out)
out := p.askViews(ctx, views)
// The site's own views, once the first pass has found which site this is.
// A standalone charger has none, and asking anyway would be four errors
// about a site that does not exist.
if siteID := siteIDFrom(out); siteID != "" {
out = append(out, p.askViews(ctx, []detailRequest{
{"site", epSiteDetail, map[string]any{"site_id": siteID}},
{"sitePrice", epSitePrice, map[string]any{"site_id": siteID}},
{"siteWifi", epSiteWifiList, map[string]any{"site_id": siteID}},
{"energy", epEnergyAnalysis, map[string]any{
"site_id": siteID, "device_sn": sn, "device_type": "ev_charger",
"type": energyRange(""), "start_time": "", "end_time": ""}},
})...)
}
if failed == len(views) {
// Which OCPP backend "source 0" actually is, from the list the account
// publishes for exactly that purpose.
noteOcppSource(out)
failed := 0
for _, v := range out {
if v.Error != "" {
failed++
}
}
if failed == len(out) {
return nil, fmt.Errorf("anker-solix: charger %s: no per-charger view answered", sn)
}
return json.Marshal(doc)
return json.Marshal(chargerDetailsDoc{SN: sn, Views: out})
}
// askViews asks a batch of views at once and returns them in the order given —
// concurrency is for the waiting, not for the reading, and a card whose boxes
// reorder between refreshes is a card nobody can read against itself.
func (p *Plugin) askViews(ctx context.Context, reqs []detailRequest) []chargerDetailView {
out := make([]chargerDetailView, len(reqs))
sem := make(chan struct{}, detailFanout)
var wg sync.WaitGroup
for i, req := range reqs {
wg.Add(1)
go func(i int, req detailRequest) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
view := chargerDetailView{ID: req.id}
body, err := p.apiRequest(ctx, req.endpoint, req.payload)
if err != nil {
view.Error = shorten(err.Error())
} else {
attrs := map[string]string{}
flattenInto(attrs, "", dataValue(body))
if len(attrs) > 0 {
view.Attrs = attrs
}
}
out[i] = view
}(i, req)
}
wg.Wait()
return out
}
// siteIDFrom reads the site this charger belongs to out of the by-serial lookup,
// under whichever key that view used for it.
func siteIDFrom(views []chargerDetailView) string {
for _, v := range views {
if v.ID != "siteBySn" {
continue
}
for _, key := range []string{"site_id", "siteId", "station_id", "site_info.site_id"} {
if id := strings.TrimSpace(v.Attrs[key]); id != "" {
return id
}
}
}
return ""
}
// noteOcppSource names the OCPP backend the charger is pointed at. The per-
// charger view reports it as a bare number ("source 0"); the account's endpoint
// list is where those numbers have addresses, so the two are read together and
// the answer is said once, as the view's note. When the list does not carry the
// number, nothing is said — a guessed backend is worse than a plain number.
func noteOcppSource(views []chargerDetailView) {
var ocpp *chargerDetailView
var endpoints map[string]string
for i := range views {
switch views[i].ID {
case "ocpp":
ocpp = &views[i]
case "ocppEndpoints":
endpoints = views[i].Attrs
}
}
if ocpp == nil || len(ocpp.Attrs) == 0 || len(endpoints) == 0 {
return
}
source := strings.TrimSpace(ocpp.Attrs["source"])
if source == "" {
return
}
if url := endpointForSource(endpoints, source); url != "" {
ocpp.Note = "source " + source + " = " + url
}
}
// endpointForSource finds the address listed under a source number. The list
// arrives flattened — list[0].source, list[0].url and whatever else it carries —
// so the entry whose source matches is found first, and its address taken from
// that same entry.
func endpointForSource(endpoints map[string]string, source string) string {
prefix := ""
for key, val := range endpoints {
if !strings.HasSuffix(key, ".source") || strings.TrimSpace(val) != source {
continue
}
prefix = strings.TrimSuffix(key, ".source")
break
}
if prefix == "" {
return ""
}
for _, field := range []string{".url", ".endpoint", ".address", ".ocpp_url", ".server_url", ".name"} {
if v := strings.TrimSpace(endpoints[prefix+field]); v != "" {
return v
}
}
return ""
}
// dataValue returns a response's "data", whatever shape it came in: these views
@@ -0,0 +1,97 @@
package ankersolix
import "testing"
// The site views are only worth asking for once the by-serial lookup has found a
// site, and that lookup names it differently depending on which shape answered.
func TestSiteIDFromReadsWhicheverKeyTheLookupUsed(t *testing.T) {
cases := []struct {
name string
attrs map[string]string
want string
}{
{"snake case", map[string]string{"site_id": "s-1"}, "s-1"},
{"camel case", map[string]string{"siteId": "s-2"}, "s-2"},
{"nested", map[string]string{"site_info.site_id": "s-3"}, "s-3"},
{"standalone charger", map[string]string{"device_sn": "AT1"}, ""},
{"blank is not an answer", map[string]string{"site_id": " "}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
views := []chargerDetailView{{ID: "ocpp", Attrs: map[string]string{"site_id": "wrong"}}, {ID: "siteBySn", Attrs: tc.attrs}}
if got := siteIDFrom(views); got != tc.want {
t.Fatalf("siteIDFrom = %q, want %q", got, tc.want)
}
})
}
}
// "source 0" is a number until the account's endpoint list gives it an address.
func TestNoteOcppSourceNamesTheBackend(t *testing.T) {
views := []chargerDetailView{
{ID: "ocpp", Attrs: map[string]string{"source": "1"}},
{ID: "ocppEndpoints", Attrs: map[string]string{
"list[0].source": "0", "list[0].url": "wss://ocpp.anker.example/0",
"list[1].source": "1", "list[1].url": "wss://ocpp.anker.example/1",
}},
}
noteOcppSource(views)
if want := "source 1 = wss://ocpp.anker.example/1"; views[0].Note != want {
t.Fatalf("note = %q, want %q", views[0].Note, want)
}
}
// A number the list does not carry stays a number: a guessed backend would be
// worse than saying nothing.
func TestNoteOcppSourceSaysNothingWhenTheListCannotAnswer(t *testing.T) {
views := []chargerDetailView{
{ID: "ocpp", Attrs: map[string]string{"source": "7"}},
{ID: "ocppEndpoints", Attrs: map[string]string{"list[0].source": "0", "list[0].url": "wss://x/0"}},
}
noteOcppSource(views)
if views[0].Note != "" {
t.Fatalf("note = %q, want none", views[0].Note)
}
}
// The entry's own address, not a neighbour's.
func TestEndpointForSourceStaysWithinItsEntry(t *testing.T) {
endpoints := map[string]string{
"list[0].source": "0", "list[0].endpoint": "wss://a",
"list[1].source": "2", "list[1].endpoint": "wss://b",
}
if got := endpointForSource(endpoints, "2"); got != "wss://b" {
t.Fatalf("endpointForSource = %q, want wss://b", got)
}
if got := endpointForSource(endpoints, "3"); got != "" {
t.Fatalf("endpointForSource = %q, want empty", got)
}
}
// The multi-serial views accept either spelling, and reject neither silently.
func TestSerialList(t *testing.T) {
if got := serialList(invokeParams{SN: "AT1"}); len(got) != 1 || got[0] != "AT1" {
t.Fatalf("single serial = %v", got)
}
got := serialList(invokeParams{SN: "AT1", SNs: []string{"AT2", " ", "AT3"}})
if len(got) != 2 || got[0] != "AT2" || got[1] != "AT3" {
t.Fatalf("several serials = %v", got)
}
if got := serialList(invokeParams{}); len(got) != 0 {
t.Fatalf("nothing given = %v", got)
}
}
// The paged and multi-view endpoints get a sane ask when the caller does not
// care, rather than a zero the cloud reads as "no page at all".
func TestPagingAndViewDefaults(t *testing.T) {
if pageNum(0) != 1 || pageNum(3) != 3 {
t.Fatalf("pageNum: %d %d", pageNum(0), pageNum(3))
}
if pageSize(0) != 20 || pageSize(500) != 20 || pageSize(50) != 50 {
t.Fatalf("pageSize: %d %d %d", pageSize(0), pageSize(500), pageSize(50))
}
if upgradeRecordView(0) != 1 || upgradeRecordView(4) != 1 || upgradeRecordView(2) != 2 {
t.Fatalf("upgradeRecordView: %d %d %d", upgradeRecordView(0), upgradeRecordView(4), upgradeRecordView(2))
}
}
@@ -90,6 +90,15 @@ const (
// having to reach it first.
statusWait = 12 * time.Second
// settingsMaxAge is how old the settings half may be before a status read
// asks for it again; settingsWait is how long that read then waits for the
// answer, and statusReqTries how many unanswered requests it takes before a
// charger is treated as one whose firmware ignores the message — after which
// the request still goes out, but no read pays the wait for it.
settingsMaxAge = 10 * time.Minute
settingsWait = 4 * time.Second
statusReqTries = 3
// commandWait is how long a command waits for the charger's confirmation
// message. A command is fire-and-forget on the wire, so this only decides
// whether we can say the charger answered — not whether it was sent.
@@ -262,6 +271,12 @@ type deviceState struct {
telemetryAt time.Time
settingsAt time.Time
triggeredUntil time.Time
// How many status requests this charger has been asked and not answered.
// The request is cheap to send and is sent regardless; what it buys is the
// right to wait a few seconds for the reply, and a charger whose firmware
// ignores the message should not cost every later read that wait.
statusReqMisses int
}
// mqttClient returns the account's broker connection, opening one if there is
@@ -671,6 +686,28 @@ func (c *mqttConn) snapshotOf(sn string) (map[string]any, time.Time, time.Time,
return out, st.telemetryAt, st.settingsAt, st.triggeredUntil
}
// noteStatusMiss records that a status request went unanswered, and reports how
// many have now in a row.
func (c *mqttConn) noteStatusMiss(sn string) int {
c.mu.Lock()
defer c.mu.Unlock()
st := c.devices[sn]
if st == nil {
return 0
}
st.statusReqMisses++
return st.statusReqMisses
}
// statusReqAnswered reports whether this charger has answered a status request
// recently enough to be worth waiting for again.
func (c *mqttConn) statusReqAnswered(sn string) bool {
c.mu.Lock()
defer c.mu.Unlock()
st := c.devices[sn]
return st == nil || st.statusReqMisses < statusReqTries
}
// noteTrigger records how long the charger has been asked to keep streaming.
func (c *mqttConn) noteTrigger(sn string, until time.Time) {
c.mu.Lock()
@@ -704,6 +741,20 @@ func (p *Plugin) mqttTrigger(ctx context.Context, c *mqttConn, model, sn string,
return nil
}
// mqttStatusRequest asks the charger to publish what it is set to. The trigger
// above buys telemetry and nothing else: the settings half arrives on its own
// message, and otherwise only after a command, which is why a charger that has
// been read but never commanded reports live amps and knows nothing about its
// own schedule, its Modbus server or its firmware. This is the message the app
// uses for that — a status request the charger answers with 0840.
func (p *Plugin) mqttStatusRequest(ctx context.Context, c *mqttConn, model, sn string) error {
frame, err := encodeFrame(msgEVStatusReq, []cmdField{bareTimestampField(time.Now())})
if err != nil {
return err
}
return c.publishFrame(ctx, model, sn, frame, 0)
}
// mqttSetMode sends the start / stop / skip-delay / boost command.
func (p *Plugin) mqttSetMode(ctx context.Context, c *mqttConn, model, sn, mode string) error {
v, ok := mqttModeValues[mode]
@@ -722,6 +773,25 @@ func (p *Plugin) mqttSetMode(ctx context.Context, c *mqttConn, model, sn, mode s
return c.publishFrame(ctx, model, sn, frame, mqttEncodingMode)
}
// mqttRestart reboots the charger. It is the cloud's answer to the OCPP reset —
// the one thing the phone can do to a charger that no register holds and no CSMS
// reaches when the charger is not on one. The charger goes away and comes back,
// so nothing confirms it: the acknowledgement would have to arrive from a device
// that is rebooting.
func (p *Plugin) mqttRestart(ctx context.Context, c *mqttConn, model, sn string) error {
frame, err := encodeFrame(msgEVPowerMode, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, powerModeRestart),
timestampField(time.Now()),
})
if err != nil {
return err
}
// The same encoding_type the mode command carries: the charger expects the
// field on these two messages and on no others.
return c.publishFrame(ctx, model, sn, frame, mqttEncodingMode)
}
// mqttSetMaxCurrent sets the charging current ceiling, in amps. The limit is
// checked by the same rule the Modbus path uses, because the rule is the
// charger's: the transport differs, the charger does not.
@@ -68,17 +68,23 @@ const typeByteMax byte = 0x31
// Message types this package speaks, for the A5191 (V1 Smart EV Charger).
// Outbound ones are commands, inbound ones are what the charger publishes back.
const (
msgEVStatusReq = "0040" // ask the charger to report its parameters
msgRealtimeTrigger = "0057" // ask for the fast telemetry stream
msgEVSettings = "0100" // the settings group: current limit, brightness, …
msgEVMode = "0105" // start / stop / skip delay / boost
msgEVSchedule = "0106" // the charging schedule: switch, mode and times
msgEVBalancing = "010c" // load balancing and the main breaker limit
msgEVSolar = "010e" // solar charging
msgEVPowerMode = "0108" // the device power mode: the one value restarts it
msgEVTelemetry = "0410" // fast telemetry, only while a trigger is live
msgEVParams = "0405" // settings and identity, sent after a command
msgEVParamsAlt = "0840" // the same fields, in answer to a status request
msgEVConfirm = "0900" // the same fields again, confirming a control change
msgEVCharging = "0403" // a couple of charging parameters
// powerModeRestart is the only value the power-mode command is known to take.
// The map documents 5 and nothing else, so nothing else is sent.
powerModeRestart uint8 = 5
)
// mqttField is one named value inside a device message. factor scales the raw
@@ -247,6 +253,17 @@ func timestampField(now time.Time) cmdField {
return varField(0xfe, uint32(now.Unix()))
}
// bareTimestampField is the timestamp the status request carries: the same
// clock as every other command, sent without its value type. The app sends it
// that way — the reference reads it as an Anker bug and keeps the whole command
// commented out because of it — and the charger answers what the app sends, so
// the oddity is reproduced rather than corrected.
func bareTimestampField(now time.Time) cmdField {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, uint32(now.Unix()))
return cmdField{name: 0xfe, typ: typeNone, value: b}
}
// clockField builds the two-byte field the charger carries a time of day in:
// the hour and the minute, least significant byte first, which is the same
// layout the decoder reads back as "HH:MM".
@@ -42,6 +42,76 @@ func TestEncodeFrameMatchesTheDocumentedTrigger(t *testing.T) {
}
}
// The status request is the one command whose timestamp travels without a value
// type — the app sends it that way, and the charger answers what the app sends.
func TestStatusRequestSendsItsClockWithoutAValueType(t *testing.T) {
got, err := encodeFrame(msgEVStatusReq, []cmdField{bareTimestampField(time.Unix(1756813256, 0))})
if err != nil {
t.Fatalf("encodeFrame: %v", err)
}
want := "ff09100003000f0040" + // header: marker, length 16, send pattern, type 0040
"fe04c8d7b668" // fe: four clock bytes, no value type between the length and them
if h := encodeHex(got); h[:len(want)] != want {
t.Fatalf("frame = %s\nwant %s + checksum", h, want)
}
if len(got) != 16 {
t.Errorf("frame is %d bytes, want 16", len(got))
}
// The field's length byte counts the value alone, since there is no type byte
// to count — the whole point of the oddity.
if got[10] != 4 {
t.Errorf("fe length byte is %d, want 4", got[10])
}
var sum byte
for _, b := range got {
sum ^= b
}
if sum != 0 {
t.Errorf("checksum does not close the frame: %02x", sum)
}
}
// The restart carries the one value the map documents for the power-mode
// command, opened and closed like every other command.
func TestRestartFrameCarriesThePowerModeValue(t *testing.T) {
got, err := encodeFrame(msgEVPowerMode, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, powerModeRestart),
timestampField(time.Unix(1756813256, 0)),
})
if err != nil {
t.Fatalf("encodeFrame: %v", err)
}
want := "ff09180003000f0108" + // header: marker, length 24, send pattern, type 0108
"a10122" + // a1: the opener, no value type
"a2020105" + // a2: ui 5 — restart
"fe0503c8d7b668" // fe: var — the sender's clock
if h := encodeHex(got); h[:len(want)] != want {
t.Fatalf("frame = %s / want %s + checksum", h, want)
}
var sum byte
for _, b := range got {
sum ^= b
}
if sum != 0 {
t.Errorf("checksum does not close the frame: %02x", sum)
}
}
// Both spellings reach the restart; nothing else does.
func TestIsRestartTakesEitherName(t *testing.T) {
for _, name := range []string{"restart", "reset"} {
if !isRestart(name) {
t.Errorf("%q should ask for a restart", name)
}
}
for _, name := range []string{"reboot", "start", "stop", "trigger", ""} {
if isRestart(name) {
t.Errorf("%q should not ask for a restart", name)
}
}
}
// A frame is only self-consistent if XORing every byte, checksum included,
// comes to zero — which is exactly what the decoder checks.
func TestEncodeFrameChecksumClosesToZero(t *testing.T) {
@@ -103,6 +103,18 @@ var settingCmds = []settingCmd{
// mode: the charger stops serving the register map on the LAN.
{wire: 0xb7, key: "modbusEnabled", state: "modbusSwitch", encode: switchOnOff(1, 0)},
}},
// The panel's own three: what a swipe up, a swipe down and a touch do. The
// charger has always reported them and the card has always shown them; they
// are settings like any other on this message, so they are writable here too.
{msgType: msgEVSettings, fields: []settingField{
{wire: 0xaf, key: "swipeUpMode", state: "swipeUpMode", encode: optionValue(0, 1, 2, 3)},
}},
{msgType: msgEVSettings, fields: []settingField{
{wire: 0xb0, key: "swipeDownMode", state: "swipeDownMode", encode: optionValue(0, 1, 2, 3)},
}},
{msgType: msgEVSettings, fields: []settingField{
{wire: 0xb2, key: "smartTouchMode", state: "smartTouchMode", encode: optionValue(0, 1)},
}},
{msgType: msgEVSchedule, fields: []settingField{
{wire: 0xa2, key: "scheduleEnabled", state: "scheduleSwitch", encode: switchOnOff(1, 2)},
{wire: 0xa8, key: "scheduleMode", state: "scheduleMode", encode: optionValue(0, 1)},
@@ -189,13 +189,27 @@ func (p *Plugin) mqttStatus(ctx context.Context, sn string) (json.RawMessage, er
// Re-arm whenever the window is spent or close to it, so a poll never lands
// in the gap between the last frame and the trigger expiring.
_, _, _, triggered := conn.snapshotOf(sn)
_, _, settingsAt, triggered := conn.snapshotOf(sn)
if time.Until(triggered) < triggerRenew {
if err := p.mqttTrigger(ctx, conn, model, sn, triggerWindow); err != nil {
return nil, err
}
}
// The trigger buys telemetry only. What the charger is set to — its schedule,
// its balancing, its Modbus server, its firmware — travels on its own message,
// and without asking it never comes: a charger that has been read a hundred
// times and commanded none reports amps and nothing else. So the read asks for
// that half too whenever it is missing or has gone stale.
askedSettings := false
if time.Since(settingsAt) > settingsMaxAge {
// A charger that will not answer this is not a failed read: the telemetry
// half is still the answer, and the settings half is what it was.
if err := p.mqttStatusRequest(ctx, conn, model, sn); err == nil {
askedSettings = true
}
}
// Anything older than the trigger's own interval is stale; wait for the next.
cutoff := time.Now().Add(-triggerRenew)
live, err := conn.waitFor(ctx, sn, func(st *deviceState) bool {
@@ -205,6 +219,19 @@ func (p *Plugin) mqttStatus(ctx context.Context, sn string) (json.RawMessage, er
return nil, err
}
// Give the parameter message a moment of its own, but only while this charger
// still looks like one that answers: the reference reads the status request as
// carrying an Anker bug, so a firmware that ignores it must not tax every read
// with the same wait forever.
if askedSettings && conn.statusReqAnswered(sn) {
settled, werr := conn.waitFor(ctx, sn, func(st *deviceState) bool {
return st.settingsAt.After(cutoff)
}, settingsWait)
if werr == nil && !settled {
conn.noteStatusMiss(sn)
}
}
values, telemetryAt, settingsAt, _ := conn.snapshotOf(sn)
if len(values) == 0 {
return nil, fmt.Errorf("anker-solix: charger %s did not answer over the cloud; it may be offline", sn)
@@ -247,6 +274,12 @@ var mqttCommands = map[string]string{
modeSkipDelay: modeSkipDelay,
}
// isRestart reports whether a command asks for a reboot. Both names answer to
// it: "restart" is what this transport calls the message, and "reset" is what
// the OCPP path has always called the same act, so a caller that knows one is
// not told the charger cannot do the other.
func isRestart(command string) bool { return command == "restart" || command == "reset" }
// mqttCommand issues one control command over the cloud.
func (p *Plugin) mqttCommand(ctx context.Context, sn, command string, amps float64) (json.RawMessage, error) {
// Validate before touching the cloud: a mistyped command should not cost a
@@ -260,8 +293,9 @@ func (p *Plugin) mqttCommand(ctx context.Context, sn, command string, amps float
return nil, err
}
case command == "trigger":
case isRestart(command):
default:
return nil, fmt.Errorf("anker-solix: %q is not a cloud command (start, stop, boost, skip-delay, limit, trigger)", command)
return nil, fmt.Errorf("anker-solix: %q is not a cloud command (start, stop, boost, skip-delay, limit, trigger, restart)", command)
}
model, err := p.chargerModel(ctx, sn)
@@ -284,6 +318,8 @@ func (p *Plugin) mqttCommand(ctx context.Context, sn, command string, amps float
err = p.mqttSetMode(ctx, conn, model, sn, mode)
case command == "limit":
err = p.mqttSetMaxCurrent(ctx, conn, model, sn, amps)
case isRestart(command):
err = p.mqttRestart(ctx, conn, model, sn)
default:
err = p.mqttTrigger(ctx, conn, model, sn, triggerWindow)
}
@@ -291,6 +327,15 @@ func (p *Plugin) mqttCommand(ctx context.Context, sn, command string, amps float
return nil, err
}
// A restart is the one command with nothing to wait for: the charger that
// would send the confirmation is the charger that is rebooting. Saying so
// beats waiting five seconds to report an unconfirmed command as if that
// were news.
if isRestart(command) {
return json.Marshal(mqttCommandDoc{Serial: sn, Command: command, Status: "accepted",
Detail: "sent; the charger reboots rather than confirming, and drops off the cloud for about a minute"})
}
// The charger answers a control change with a settings message. Waiting for
// it turns "published" into "the charger has it".
confirmed, waitErr := conn.waitFor(ctx, sn, func(st *deviceState) bool {
+22 -1
View File
@@ -147,6 +147,7 @@
"reset": "Genstart laderen",
"resetConfirm": "Genstart denne lader nu? En igangværende opladning bliver afbrudt. Indtast din adgangskode igen for at bekræfte.",
"resetPassword": "Din kontoadgangskode",
"restartCloudHint": "Laderen genstarter i stedet for at svare og forsvinder fra skyen i cirka et minut.",
"connectHint": "Indtast laderens serienummer og opdater. Laderen skal være forbundet til DriverVaults OCPP-backend (opsættes under Indstillinger → Integrationer).",
"connectionTitle": "Laderforbindelse",
"enterSerial": "Indtast et serienummer i stedet",
@@ -206,6 +207,8 @@
"productNumber": "Produktnummer",
"ratedPower": "Nominel effekt",
"currentRange": "Strømområde",
"upTo": "Op til",
"from": "Fra",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"sessionEnergy": "Sessionsenergi",
@@ -318,10 +321,28 @@
},
"views": {
"station": "Stationspost",
"stationFeature": "Stationsfunktioner",
"totals": "Ladetotaler",
"orders": "Ladehistorik",
"sessions": "Ladesessioner",
"income": "Besparelse og indtægt",
"ocpp": "OCPP-backend",
"rfid": "RFID-kort"
"ocppEndpoints": "OCPP-endepunkter hos Anker",
"rfid": "RFID-kort",
"shared": "Delt med",
"binding": "Kontotilknytning",
"group": "Enhedsgruppe",
"wifi": "Wi-Fi-detaljer",
"ota": "Firmwarestatus",
"upgrades": "Opdateringshistorik",
"tamper": "Manipulationslog",
"siteBySn": "Systemopslag",
"site": "Systemdetaljer",
"sitePrice": "Pris og CO2-faktor",
"siteWifi": "Netværk systemet kan se",
"energy": "Energianalyse"
},
"viewEmpty": "Denne visning svarede, men uden indhold.",
"offline": "Offline",
"refresh": "Opdater",
"rawTitle": "Som tjenesten melder det",
+22 -1
View File
@@ -147,6 +147,7 @@
"reset": "Reset charger",
"resetConfirm": "Reboot this charger now? Any active charging session will be interrupted. Re-enter your password to confirm.",
"resetPassword": "Your account password",
"restartCloudHint": "The charger reboots rather than answering, and drops off the cloud for about a minute.",
"connectHint": "Enter your charger's serial and refresh. The charger must be connected to DriverVault's OCPP backend (set up in Settings → Integrations).",
"connectionTitle": "Charger connection",
"enterSerial": "Enter a serial instead",
@@ -206,6 +207,8 @@
"productNumber": "Product number",
"ratedPower": "Rated power",
"currentRange": "Current range",
"upTo": "Up to",
"from": "From",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"sessionEnergy": "Session energy",
@@ -318,10 +321,28 @@
},
"views": {
"station": "Station record",
"stationFeature": "Station features",
"totals": "Charging totals",
"orders": "Charging history",
"sessions": "Charging sessions",
"income": "Savings and income",
"ocpp": "OCPP backend",
"rfid": "RFID cards"
"ocppEndpoints": "OCPP endpoints Anker uses",
"rfid": "RFID cards",
"shared": "Shared with",
"binding": "Account binding",
"group": "Device group",
"wifi": "Wi-Fi details",
"ota": "Firmware status",
"upgrades": "Update history",
"tamper": "Tamper records",
"siteBySn": "Site lookup",
"site": "Site detail",
"sitePrice": "Price and CO2 factor",
"siteWifi": "Networks the site sees",
"energy": "Energy analysis"
},
"viewEmpty": "This view answered, with nothing in it.",
"offline": "Offline",
"refresh": "Refresh",
"rawTitle": "As the service reports it",
+22 -1
View File
@@ -149,6 +149,7 @@
"reset": "Zrestartuj ładowarkę",
"resetConfirm": "Zrestartować teraz tę ładowarkę? Trwająca sesja ładowania zostanie przerwana. Wpisz ponownie hasło, aby potwierdzić.",
"resetPassword": "Hasło do Twojego konta",
"restartCloudHint": "Ładowarka zrestartuje się zamiast odpowiedzieć i zniknie z chmury na około minutę.",
"connectHint": "Wpisz numer seryjny ładowarki i odśwież. Ładowarka musi być połączona z backendem OCPP DriverVault (konfiguracja w Ustawienia → Integracje).",
"connectionTitle": "Połączenie z ładowarką",
"enterSerial": "Wpisz numer seryjny zamiast tego",
@@ -208,6 +209,8 @@
"productNumber": "Numer produktu",
"ratedPower": "Moc znamionowa",
"currentRange": "Zakres prądu",
"upTo": "Do",
"from": "Od",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"sessionEnergy": "Energia sesji",
@@ -320,10 +323,28 @@
},
"views": {
"station": "Rekord stacji",
"stationFeature": "Funkcje stacji",
"totals": "Sumy ładowania",
"orders": "Historia ładowania",
"sessions": "Sesje ładowania",
"income": "Oszczędności i przychód",
"ocpp": "Backend OCPP",
"rfid": "Karty RFID"
"ocppEndpoints": "Punkty OCPP używane przez Ankera",
"rfid": "Karty RFID",
"shared": "Udostępniono",
"binding": "Powiązanie z kontem",
"group": "Grupa urządzeń",
"wifi": "Szczegóły Wi-Fi",
"ota": "Stan firmware'u",
"upgrades": "Historia aktualizacji",
"tamper": "Rejestr naruszeń",
"siteBySn": "Wyszukanie systemu",
"site": "Szczegóły systemu",
"sitePrice": "Cena i współczynnik CO2",
"siteWifi": "Sieci widziane przez system",
"energy": "Analiza energii"
},
"viewEmpty": "Ten widok odpowiedział, ale nic nie zawierał.",
"offline": "Offline",
"refresh": "Odśwież",
"rawTitle": "Tak, jak podaje to usługa",
+5 -3
View File
@@ -641,9 +641,11 @@ class ApiClient {
return AnkerChargerList.fromJson(Map<String, dynamic>.from(data));
}
/// Every per-charger view the account holds — the station record, the charging
/// totals, the OCPP backend and the RFID cards — asked for one charger at a
/// time, because none of those endpoints lists chargers.
/// 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<ChargerDetails> getAnkerChargerDetails(String sn) async {
final data = await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/details");
if (data is! Map) return const ChargerDetails();
+10 -6
View File
@@ -1485,20 +1485,25 @@ class ProviderCharger {
}
}
/// One of the account's per-charger views — the station record, the charging
/// totals, the OCPP backend, the RFID cards. Anker documents none of these
/// payloads, so the fields arrive under the cloud's own keys; a view the account
/// cannot read carries its reason instead.
/// One of the account's views of a charger — the station record, the totals,
/// the history, the OCPP backend, the cards, the sharing, the firmware, or any
/// of the others. Relayed as the fields it sent: Anker documents none of these
/// payloads, so the cloud's own keys are the only honest labels.
class ChargerDetailView {
final String id;
final Map<String, String> attrs;
/// The one line in DriverVault's words rather than the cloud's: a number this
/// view reports that another view gives a meaning to, resolved by the server.
final String note;
final String error;
const ChargerDetailView({required this.id, this.attrs = const {}, this.error = ""});
const ChargerDetailView({required this.id, this.attrs = const {}, this.note = "", this.error = ""});
factory ChargerDetailView.fromJson(Map<String, dynamic> j) => ChargerDetailView(
id: _asStr(j["id"]),
attrs: _asStrMap(j["attrs"]),
note: _asStr(j["note"]),
error: _asStr(j["error"]),
);
@@ -1525,7 +1530,6 @@ class ChargerDetails {
? raw
.whereType<Map>()
.map((v) => ChargerDetailView.fromJson(Map<String, dynamic>.from(v)))
.where((v) => v.attrs.isNotEmpty || v.error.isNotEmpty)
.toList()
: const [],
);
+46 -10
View File
@@ -1606,9 +1606,11 @@ class _HomeTabState extends State<_HomeTab> {
),
],
// Reset reboots the charger over OCPP; neither the register map nor the
// cloud has an equivalent, so the button is not offered there.
if (!_readsDevice) ...[
// Rebooting the charger: an OCPP reset, or the cloud's own restart
// message, which is the way to reach a charger that is on neither a CSMS
// nor the LAN. No register does it, so Modbus is the one mode without
// the button.
if (!_readsDevice || _isCloud) ...[
const SizedBox(height: 12),
if (!_resetPrompt)
SizedBox(
@@ -1639,6 +1641,13 @@ class _HomeTabState extends State<_HomeTab> {
Text(t("charging.control.resetConfirm"),
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.w500, color: DriverVault.danger)),
// Over the cloud there is nothing to confirm it with: the
// charger that would answer is the one rebooting.
if (_isCloud) ...[
const SizedBox(height: 4),
Text(t("charging.control.restartCloudHint"),
style: const TextStyle(fontSize: 11, color: DriverVault.danger)),
],
const SizedBox(height: 8),
TextField(
controller: _resetPassword,
@@ -2226,6 +2235,16 @@ class _HomeTabState extends State<_HomeTab> {
]);
}
/// The current range as the charger reports it: both bounds when it sends
/// both, and the one it does send otherwise — "up to 32 A" is a fact, and
/// dropping the row because the other half is missing hides it.
String? _currentRange(int? min, int? max) {
if (min != null && max != null) return "$min$max A";
if (max != null) return "${t("charging.modbus.upTo")} $max A";
if (min != null) return "${t("charging.modbus.from")} $min A";
return null;
}
List<(String, String)> _deviceRows(ChargerStatus s) {
final product = s.integer("productNumber");
final min = s.integer("minCurrentA");
@@ -2238,7 +2257,9 @@ class _HomeTabState extends State<_HomeTab> {
("hardware", s.text("hardware")),
("productNumber", product == null ? null : "$product"),
("ratedPower", _unit(s.number("ratedPowerW"), 0, "W")),
("currentRange", min != null && max != null ? "$min$max A" : null),
// Either bound on its own is still a bound worth reading; only a charger
// that reports neither has nothing to say here.
("currentRange", _currentRange(min, max)),
("ocppLink", _enumLabel("ocpp", s.integer("ocppStatus"))),
("mqttLink", _enumLabel("mqtt", s.integer("mqttStatus"))),
]);
@@ -2398,15 +2419,30 @@ class _HomeTabState extends State<_HomeTab> {
const SizedBox(height: 8),
],
// The views that answer per charger rather than per account. Each says
// what it knows, or why it could not be read — an account that is not
// the charger's owner cannot read the cards, which is a fact about the
// account rather than a failure.
// what it knows, why it could not be read — an account that is not the
// charger's owner cannot read the cards, which is a fact about the
// account rather than a failure — or that it answered with nothing,
// which is equally an answer: a standalone charger has no station
// record and no site.
for (final view in _detailViews(charger)) ...[
_ReadingSection(
heading: t("charging.info.views.${view.id}"),
child: view.error.isNotEmpty
? Text(view.error, style: TextStyle(fontSize: 11, color: muted))
: _PairList(rows: view.rows, breakLong: true),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (view.error.isNotEmpty)
Text(view.error, style: TextStyle(fontSize: 11, color: muted))
else if (view.rows.isEmpty)
Text(t("charging.info.viewEmpty"), style: TextStyle(fontSize: 11, color: muted))
else
_PairList(rows: view.rows, breakLong: true),
if (view.note.isNotEmpty) ...[
const SizedBox(height: 6),
Text(view.note,
style: DriverVault.mono(context, size: 11, color: muted)),
],
],
),
),
const SizedBox(height: 8),
],
+4 -3
View File
@@ -328,9 +328,10 @@ export const api = {
// 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"),
// Every per-charger view the account holds — the station record, the charging
// totals, the OCPP backend and the RFID cards — asked for one charger at a
// time, because none of those endpoints lists chargers.
// 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.
getAnkerChargerDetails: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/details`),
+22 -1
View File
@@ -97,6 +97,7 @@
"reset": "Genstart laderen",
"resetConfirm": "Genstart denne lader nu? En igangværende opladning bliver afbrudt. Indtast din adgangskode igen for at bekræfte.",
"resetPassword": "Din kontoadgangskode",
"restartCloudHint": "Laderen genstarter i stedet for at svare og forsvinder fra skyen i cirka et minut.",
"connectHint": "Indtast laderens serienummer og opdater. Laderen skal være forbundet til DriverVaults OCPP-backend (opsættes under Indstillinger → Integrationer).",
"address": "Laderens adresse på dette netværk",
"addressPlaceholder": "IP-adresse (f.eks. 192.168.1.40)",
@@ -167,6 +168,8 @@
"productNumber": "Produktnummer",
"ratedPower": "Nominel effekt",
"currentRange": "Strømområde",
"upTo": "Op til",
"from": "Fra",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"plugged": "Kabel tilsluttet",
@@ -278,10 +281,28 @@
},
"views": {
"station": "Stationspost",
"stationFeature": "Stationsfunktioner",
"totals": "Ladetotaler",
"orders": "Ladehistorik",
"sessions": "Ladesessioner",
"income": "Besparelse og indtægt",
"ocpp": "OCPP-backend",
"rfid": "RFID-kort"
"ocppEndpoints": "OCPP-endepunkter hos Anker",
"rfid": "RFID-kort",
"shared": "Delt med",
"binding": "Kontotilknytning",
"group": "Enhedsgruppe",
"wifi": "Wi-Fi-detaljer",
"ota": "Firmwarestatus",
"upgrades": "Opdateringshistorik",
"tamper": "Manipulationslog",
"siteBySn": "Systemopslag",
"site": "Systemdetaljer",
"sitePrice": "Pris og CO2-faktor",
"siteWifi": "Netværk systemet kan se",
"energy": "Energianalyse"
},
"viewEmpty": "Denne visning svarede, men uden indhold.",
"offline": "Offline",
"refresh": "Opdater",
"rawTitle": "Som tjenesten melder det",
+22 -1
View File
@@ -83,6 +83,7 @@
"reset": "Reset charger",
"resetConfirm": "Reboot this charger now? Any active charging session will be interrupted. Re-enter your password to confirm.",
"resetPassword": "Your account password",
"restartCloudHint": "The charger reboots rather than answering, and drops off the cloud for about a minute.",
"connectHint": "Enter your charger's serial and refresh. The charger must be connected to DriverVault's OCPP backend (set up in Settings → Integrations).",
"address": "Charger address on this network",
"addressPlaceholder": "IP address (e.g. 192.168.1.40)",
@@ -153,6 +154,8 @@
"productNumber": "Product number",
"ratedPower": "Rated power",
"currentRange": "Current range",
"upTo": "Up to",
"from": "From",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"plugged": "Cable plugged in",
@@ -264,10 +267,28 @@
},
"views": {
"station": "Station record",
"stationFeature": "Station features",
"totals": "Charging totals",
"orders": "Charging history",
"sessions": "Charging sessions",
"income": "Savings and income",
"ocpp": "OCPP backend",
"rfid": "RFID cards"
"ocppEndpoints": "OCPP endpoints Anker uses",
"rfid": "RFID cards",
"shared": "Shared with",
"binding": "Account binding",
"group": "Device group",
"wifi": "Wi-Fi details",
"ota": "Firmware status",
"upgrades": "Update history",
"tamper": "Tamper records",
"siteBySn": "Site lookup",
"site": "Site detail",
"sitePrice": "Price and CO2 factor",
"siteWifi": "Networks the site sees",
"energy": "Energy analysis"
},
"viewEmpty": "This view answered, with nothing in it.",
"offline": "Offline",
"refresh": "Refresh",
"rawTitle": "As the service reports it",
+22 -1
View File
@@ -99,6 +99,7 @@
"reset": "Zrestartuj ładowarkę",
"resetConfirm": "Zrestartować teraz tę ładowarkę? Trwająca sesja ładowania zostanie przerwana. Wpisz ponownie hasło, aby potwierdzić.",
"resetPassword": "Hasło do Twojego konta",
"restartCloudHint": "Ładowarka zrestartuje się zamiast odpowiedzieć i zniknie z chmury na około minutę.",
"connectHint": "Wpisz numer seryjny ładowarki i odśwież. Ładowarka musi być połączona z backendem OCPP DriverVault (konfiguracja w Ustawienia → Integracje).",
"address": "Adres ładowarki w tej sieci",
"addressPlaceholder": "Adres IP (np. 192.168.1.40)",
@@ -169,6 +170,8 @@
"productNumber": "Numer produktu",
"ratedPower": "Moc znamionowa",
"currentRange": "Zakres prądu",
"upTo": "Do",
"from": "Od",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"plugged": "Kabel podłączony",
@@ -280,10 +283,28 @@
},
"views": {
"station": "Rekord stacji",
"stationFeature": "Funkcje stacji",
"totals": "Sumy ładowania",
"orders": "Historia ładowania",
"sessions": "Sesje ładowania",
"income": "Oszczędności i przychód",
"ocpp": "Backend OCPP",
"rfid": "Karty RFID"
"ocppEndpoints": "Punkty OCPP używane przez Ankera",
"rfid": "Karty RFID",
"shared": "Udostępniono",
"binding": "Powiązanie z kontem",
"group": "Grupa urządzeń",
"wifi": "Szczegóły Wi-Fi",
"ota": "Stan firmware'u",
"upgrades": "Historia aktualizacji",
"tamper": "Rejestr naruszeń",
"siteBySn": "Wyszukanie systemu",
"site": "Szczegóły systemu",
"sitePrice": "Cena i współczynnik CO2",
"siteWifi": "Sieci widziane przez system",
"energy": "Analiza energii"
},
"viewEmpty": "Ten widok odpowiedział, ale nic nie zawierał.",
"offline": "Offline",
"refresh": "Odśwież",
"rawTitle": "Tak, jak podaje to usługa",
+51 -13
View File
@@ -366,6 +366,16 @@ function countdown(sec) {
return m > 0 ? `${m} min ${r} s` : `${r} s`;
}
// The current range as the charger reports it: both bounds when it sends both,
// and the one it does send otherwise — "up to 32 A" is a fact, and dropping the
// row because the other half is missing hides it.
function currentRange(min, max) {
if (isSet(min) && isSet(max)) return `${min}\u2013${max} A`;
if (isSet(max)) return `${t("charging.modbus.upTo")} ${max} A`;
if (isSet(min)) return `${t("charging.modbus.from")} ${min} A`;
return null;
}
function sessionLength(sec) {
if (!isSet(sec)) return null;
const h = Math.floor(sec / 3600);
@@ -566,8 +576,10 @@ const deviceIdentity = computed(() => {
["hardware", s.hardware],
["productNumber", isSet(s.productNumber) ? String(s.productNumber) : null],
["ratedPower", unit(s.ratedPowerW, 0, "W")],
["currentRange",
isSet(s.minCurrentA) && isSet(s.maxCurrentA) ? `${s.minCurrentA}${s.maxCurrentA} A` : null],
// The charger's own floor and ceiling. Either half on its own is still a
// bound the number beside the slider has to respect, so a charger that
// reports one and not the other says the one.
["currentRange", currentRange(s.minCurrentA, s.maxCurrentA)],
["ocppLink", enumLabel("ocpp", s.ocppStatus)],
["mqttLink", enumLabel("mqtt", s.mqttStatus)],
]);
@@ -954,7 +966,13 @@ async function loadChargerDetails(force = false) {
// One view's fields, sorted, or the reason it could not be read. The keys are
// the cloud's own: Anker documents none of these payloads, so a name invented
// here would be a meaning invented here.
// here would be a meaning invented here. Note is the exception, and the server
// says which: a number the account elsewhere gives a meaning to, resolved.
//
// Every view the server asked for is drawn, the ones that answered with nothing
// included. A standalone charger has no station record and no site; that its
// site views are empty is the answer to "which of these does this account hold",
// and a box that quietly disappears cannot say it.
const chargerDetailViews = computed(() => {
const c = selectedHomeCharger.value;
const sn = c?.providerChargerId || c?.serial || "";
@@ -962,10 +980,11 @@ const chargerDetailViews = computed(() => {
return (doc?.views || []).map((view) => ({
id: view.id,
error: view.error || "",
note: view.note || "",
rows: Object.keys(view.attrs || {})
.sort()
.map((key) => ({ key, value: view.attrs[key] })),
})).filter((view) => view.rows.length || view.error);
}));
});
// Everything else the service said about this charger, under its own field
@@ -1063,9 +1082,16 @@ async function doAction(action, body) {
}
}
// Reset reboots the charger — a destructive action the server gates behind an
// explicit confirmation AND a password re-authentication (step-up). Reveal the
// inline password prompt; the actual call happens in confirmReset().
// Rebooting the charger. Two transports can: OCPP sends a reset, and the cloud
// sends the charger's own restart message — which is the only way to reboot a
// charger that is on neither a CSMS nor the local network. The register map has
// no such register, so the button is absent in Modbus mode rather than failing
// when pressed.
//
// Either way the server gates it behind an explicit confirmation AND a password
// re-authentication (step-up). Reveal the inline password prompt; the actual
// call happens in confirmReset().
const ctlCanRestart = computed(() => !ctlReadsDevice.value || ctlIsCloud.value);
const resetPrompt = ref(false);
const resetPassword = ref("");
@@ -1397,10 +1423,12 @@ onMounted(async () => {
{{ t("charging.control.skipDelay") }}
</button>
<!-- Reset reboots the charger over OCPP; neither the register map nor
the cloud has an equivalent, so the button is not offered there. -->
<!-- Rebooting the charger: an OCPP reset, or the cloud's own restart
message, which is the way to reach a charger that is on neither a
CSMS nor the LAN. No register does it, so Modbus is the one mode
without the button. -->
<button
v-if="!ctlReadsDevice && !resetPrompt"
v-if="ctlCanRestart && !resetPrompt"
class="dh-btn dh-btn-ghost mt-3 w-full"
:disabled="ctlBusy === 'reset'"
@click="askReset"
@@ -1410,6 +1438,9 @@ onMounted(async () => {
<!-- Step-up: destructive reset requires re-entering the password. -->
<div v-else-if="resetPrompt" class="mt-3 rounded-control border border-danger/40 bg-danger-soft p-3">
<p class="text-xs font-medium text-danger">{{ t("charging.control.resetConfirm") }}</p>
<!-- Over the cloud there is nothing to confirm it with: the charger
that would answer is the one rebooting. -->
<p v-if="ctlIsCloud" class="mt-1 text-[11px] text-danger">{{ t("charging.control.restartCloudHint") }}</p>
<input
v-model="resetPassword"
type="password"
@@ -1941,18 +1972,25 @@ onMounted(async () => {
</section>
<!-- The views that answer per charger rather than per account. Each
says what it knows, or why it could not be read an account
that is not the charger's owner cannot read the cards, which is
a fact about the account rather than a failure. -->
says what it knows, why it could not be read — an account that
is not the charger's owner cannot read the cards, which is a
fact about the account rather than a failure or that it
answered with nothing, which is equally an answer. -->
<section v-for="view in chargerDetailViews" :key="view.id" class="rounded-control bg-sunken p-3">
<h4 class="eyebrow">{{ t(`charging.info.views.${view.id}`) }}</h4>
<p v-if="view.error" class="mt-2 text-[11px] text-muted">{{ view.error }}</p>
<p v-else-if="!view.rows.length" class="mt-2 text-[11px] text-muted">
{{ t("charging.info.viewEmpty") }}
</p>
<dl v-else class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in view.rows" :key="row.key">
<dt class="data break-all text-[11px] text-muted">{{ row.key }}</dt>
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
</template>
</dl>
<!-- The one line in DriverVault's own words: a number this view
reports that another view gives an address. -->
<p v-if="view.note" class="data mt-2 text-[11px] text-muted">{{ view.note }}</p>
</section>
</div>