Two cards on the Charging page were answering with a fraction of what the charger and the account actually report, and in both the losses happened quietly, in a parse that kept the fields it recognised and dropped the rest on the floor. Charger information asked three account-wide views and kept fourteen fields. A charger registered on its own is absent from the site view, which is the only one of the three carrying state, charge power and OCPP status — so exactly the charger that stands alone got the column of dashes, and nothing said why. The per-charger station record, get_evcharger_station_info, is what the mobile app opens when you tap a charger, and it is the one view that answers for a charger outside a station; it is now the fourth view, asked per charger, a failure there costing that charger's row and no more. Alongside it, every field each of the four views sent is kept as attrs, under the cloud's own key, nested objects joined with a dot and arrays carrying their index. First view to answer a key wins, which is the rule the named fields already merged by. Two hundred keys and two hundred and forty runes per value keep a station record with a session list from becoming the whole card. Charger readings lost data twice over. The frame decoder skipped any field byte its per-message map could not name, and a message type with no map decoded to nothing at all; those fields are now kept under the message and the byte they arrived in — 0410.c9 — decoded but unscaled, because a factor is half of a meaning and we do not have the other half. Then the projection read forty-odd names into typed fields and dropped the remainder: sessionStartedAt, the per-phase session energies, the three touch modes, the load-balance monitor and its meter flag, the solar monitor. Those land in extra, and the list maintains itself — the four accessors note every key they read, extra is what is left, and a field modelled later stops appearing there without anyone remembering to remove it. Keeping unnamed fields had one consequence worth guarding. An unmapped message now decodes to something rather than nothing, and ingest stamped settingsAt for anything that was not telemetry — the timestamp a control command waits on to say the charger acknowledged it. A frame we cannot read is not an acknowledgement, so the stamp is now conditional on the message type being one we map, while its fields are kept either way. Both cards show the remainder as what it is: the service's own key, no unit, no translation, no renaming, under a heading that says whose words these are. The blocks appear only when there is something in them, so a Modbus charger's readings card and a charger the cloud says nothing more about are unchanged. Naming one of these fields is a later commit, made from evidence; inventing a label for it today would only make a guess look settled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
6.8 KiB
Go
184 lines
6.8 KiB
Go
package ankersolix
|
|
|
|
import (
|
|
"slices"
|
|
"testing"
|
|
)
|
|
|
|
func TestProjectSnapshotNamesTheChargerState(t *testing.T) {
|
|
snap := projectMqttSnapshot("SN1", "A5191", map[string]any{
|
|
"voltageL1": 230.1,
|
|
"currentL1": 16.0,
|
|
"powerTotal": 3680.0,
|
|
"sessionSeconds": 3725.0,
|
|
"sessionWh": 12500.0,
|
|
"status": 2.0,
|
|
"ocppStatus": 2.0,
|
|
"cpSignal": 5.0,
|
|
"phaseMode": 1.0,
|
|
})
|
|
|
|
if snap.Serial != "SN1" || snap.Model != "A5191" {
|
|
t.Errorf("snapshot identifies %s/%s", snap.Serial, snap.Model)
|
|
}
|
|
if snap.StatusDesc != stateCharging {
|
|
t.Errorf("statusDesc = %q, want %q", snap.StatusDesc, stateCharging)
|
|
}
|
|
if snap.OcppStatusDesc != "connected" {
|
|
t.Errorf("ocppStatusDesc = %q, want connected", snap.OcppStatusDesc)
|
|
}
|
|
// The control-pilot names are the charger's own, shared with the Modbus map.
|
|
if snap.CPSignalDesc != cpSignalNames[5] {
|
|
t.Errorf("cpSignalDesc = %q, want %q", snap.CPSignalDesc, cpSignalNames[5])
|
|
}
|
|
if snap.VoltageL1 == nil || *snap.VoltageL1 != 230.1 {
|
|
t.Errorf("voltageL1 = %v", snap.VoltageL1)
|
|
}
|
|
// A quantity the charger did not send stays nil, so a view can tell "not
|
|
// reported" from "zero" — an unplugged charger really does read 0 A.
|
|
if snap.VoltageL2 != nil {
|
|
t.Errorf("voltageL2 = %v, want nil for a value that was not sent", *snap.VoltageL2)
|
|
}
|
|
if snap.Settings != nil {
|
|
t.Errorf("settings = %+v, want none until a settings message arrives", snap.Settings)
|
|
}
|
|
if snap.Local != nil {
|
|
t.Errorf("local = %+v, want none until a settings message arrives", snap.Local)
|
|
}
|
|
}
|
|
|
|
// The mode is the one thing the cloud REST view can only approximate: it has no
|
|
// boost flag and no countdowns, so a charger waiting out a start delay reads to
|
|
// it as simply started. Over MQTT those fields exist, and the mode must use them.
|
|
func TestProjectSnapshotDerivesModeFromTheMqttOnlySignals(t *testing.T) {
|
|
waiting := projectMqttSnapshot("SN1", "A5191", map[string]any{
|
|
"status": 1.0, // preparing
|
|
"startCountdownSeconds": 45.0,
|
|
})
|
|
if waiting.Mode != modeWaitStart {
|
|
t.Errorf("mode = %q, want %q while a start delay is running", waiting.Mode, modeWaitStart)
|
|
}
|
|
if !slices.Contains(waiting.ModeOptions, modeSkipDelay) {
|
|
t.Errorf("modeOptions = %v, want the delay to be skippable", waiting.ModeOptions)
|
|
}
|
|
|
|
plugging := projectMqttSnapshot("SN1", "A5191", map[string]any{
|
|
"status": 1.0,
|
|
"plugCountdownSeconds": 60.0,
|
|
})
|
|
if plugging.Mode != modeWaitPlug {
|
|
t.Errorf("mode = %q, want %q while it waits for a plug", plugging.Mode, modeWaitPlug)
|
|
}
|
|
|
|
boosting := projectMqttSnapshot("SN1", "A5191", map[string]any{
|
|
"status": 2.0, // charging
|
|
"boostMode": 1.0,
|
|
})
|
|
if boosting.Mode != modeBoostCharge {
|
|
t.Errorf("mode = %q, want %q while boost is running", boosting.Mode, modeBoostCharge)
|
|
}
|
|
|
|
idle := projectMqttSnapshot("SN1", "A5191", map[string]any{"status": 0.0})
|
|
if idle.Mode != modeStopCharge {
|
|
t.Errorf("mode = %q, want %q in standby", idle.Mode, modeStopCharge)
|
|
}
|
|
if !slices.Contains(idle.ModeOptions, modeStartCharge) {
|
|
t.Errorf("modeOptions = %v, want a standby charger to be startable", idle.ModeOptions)
|
|
}
|
|
}
|
|
|
|
// Two of the charger's settings read 1 for on and 2 for off, which is the
|
|
// opposite of every other flag it sends: read as booleans they would both come
|
|
// back on.
|
|
func TestProjectSnapshotHandlesTheInvertedSwitches(t *testing.T) {
|
|
off := projectMqttSnapshot("SN1", "A5191", map[string]any{
|
|
"plugLockSwitch": 2.0,
|
|
"scheduleSwitch": 2.0,
|
|
})
|
|
if off.Settings == nil {
|
|
t.Fatal("settings missing")
|
|
}
|
|
if off.Settings.PlugLock == nil || *off.Settings.PlugLock {
|
|
t.Errorf("plugLock = %v, want off for the charger's value 2", off.Settings.PlugLock)
|
|
}
|
|
if off.Settings.ScheduleEnabled == nil || *off.Settings.ScheduleEnabled {
|
|
t.Errorf("scheduleEnabled = %v, want off for the charger's value 2", off.Settings.ScheduleEnabled)
|
|
}
|
|
|
|
on := projectMqttSnapshot("SN1", "A5191", map[string]any{"plugLockSwitch": 1.0})
|
|
if on.Settings == nil || on.Settings.PlugLock == nil || !*on.Settings.PlugLock {
|
|
t.Errorf("plugLock = %v, want on for the charger's value 1", on.Settings)
|
|
}
|
|
}
|
|
|
|
// The settings message carries the charger's own view of its LAN side, which is
|
|
// the address the Modbus mode otherwise has to be told by hand.
|
|
func TestProjectSnapshotReportsLocalAccess(t *testing.T) {
|
|
snap := projectMqttSnapshot("SN1", "A5191", map[string]any{
|
|
"modbusSwitch": 1.0,
|
|
"ipAddress": "192.168.1.44",
|
|
"modbusPort": 502.0,
|
|
"modbusTimeoutSeconds": 60.0,
|
|
"maxCurrentSetA": 32.0,
|
|
})
|
|
if snap.Local == nil {
|
|
t.Fatal("local access missing")
|
|
}
|
|
if snap.Local.ModbusEnabled == nil || !*snap.Local.ModbusEnabled {
|
|
t.Errorf("modbusEnabled = %v, want on", snap.Local.ModbusEnabled)
|
|
}
|
|
if snap.Local.Host != "192.168.1.44" {
|
|
t.Errorf("host = %q", snap.Local.Host)
|
|
}
|
|
if snap.Local.Port == nil || *snap.Local.Port != 502 {
|
|
t.Errorf("port = %v, want 502", snap.Local.Port)
|
|
}
|
|
if snap.Settings == nil || snap.Settings.MaxCurrentA == nil || *snap.Settings.MaxCurrentA != 32 {
|
|
t.Errorf("settings.maxCurrentA = %+v, want 32", snap.Settings)
|
|
}
|
|
}
|
|
|
|
func TestProjectSnapshotOfNothingIsEmpty(t *testing.T) {
|
|
snap := projectMqttSnapshot("SN1", "A5191", map[string]any{})
|
|
if snap.Status != nil || snap.Mode != "" || snap.Settings != nil || snap.Local != nil {
|
|
t.Errorf("an empty message set produced state: %+v", snap)
|
|
}
|
|
}
|
|
|
|
// A value the projection has a field for belongs in that field; everything else
|
|
// the charger sent belongs in extra — the named values this package has not
|
|
// modelled, and the fields no message map can name at all.
|
|
func TestProjectSnapshotKeepsWhatItHasNoFieldFor(t *testing.T) {
|
|
snap := projectMqttSnapshot("SN1", "A5191", map[string]any{
|
|
"powerTotal": 3680.0,
|
|
"maxCurrentSetA": 16.0,
|
|
"sessionStartedAt": 1756800000.0,
|
|
"sessionWhL1": 4100.0,
|
|
"swipeUpMode": 2.0,
|
|
"loadBalanceMonitorSN": "METER1",
|
|
rawFieldName(msgEVTelemetry, 0xc9): 300.0,
|
|
})
|
|
|
|
for _, key := range []string{"powerTotal", "maxCurrentSetA"} {
|
|
if _, ok := snap.Extra[key]; ok {
|
|
t.Errorf("extra[%q] is set; a value with a field of its own must not be repeated there", key)
|
|
}
|
|
}
|
|
want := map[string]any{
|
|
"sessionStartedAt": 1756800000.0, "sessionWhL1": 4100.0, "swipeUpMode": 2.0,
|
|
"loadBalanceMonitorSN": "METER1", "0410.c9": 300.0,
|
|
}
|
|
for key, value := range want {
|
|
if snap.Extra[key] != value {
|
|
t.Errorf("extra[%q] = %v, want %v", key, snap.Extra[key], value)
|
|
}
|
|
}
|
|
|
|
// A charger whose every value has a field of its own reports no extra at all,
|
|
// so a view can hide the block rather than draw an empty one.
|
|
bare := projectMqttSnapshot("SN1", "A5191", map[string]any{"powerTotal": 0.0})
|
|
if bare.Extra != nil {
|
|
t.Errorf("extra = %v, want none when nothing is left over", bare.Extra)
|
|
}
|
|
}
|