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; extra is what is // left, which — now that every name in the message maps is projected — is the // fields no map names at all. func TestProjectSnapshotKeepsWhatItHasNoFieldFor(t *testing.T) { snap := projectMqttSnapshot("SN1", "A5191", map[string]any{ "powerTotal": 3680.0, "maxCurrentSetA": 16.0, "sessionWhL1": 4100.0, rawFieldName(msgEVTelemetry, 0xc9): 300.0, rawFieldName("0400", 0xa2): 7.0, }) for _, key := range []string{"powerTotal", "maxCurrentSetA", "sessionWhL1"} { 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) } } for key, want := range map[string]any{"0410.c9": 300.0, "0400.a2": 7.0} { if snap.Extra[key] != want { t.Errorf("extra[%q] = %v, want %v", key, snap.Extra[key], want) } } // 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) } } // Every field the message maps name has somewhere to land. A name added to a map // without a field to project it into would otherwise show up in the raw block // under a name that reads as if it were understood. func TestEveryNamedFieldIsProjected(t *testing.T) { values := map[string]any{} for _, fields := range []map[byte]mqttField{evTelemetry, evParams, evCharging} { for _, f := range fields { values[f.name] = 1.0 } } snap := projectMqttSnapshot("SN1", "A5191", values) if len(snap.Extra) != 0 { t.Errorf("these named fields reach no snapshot field: %v", snap.Extra) } }