Every field in the cloud MQTT map that has a documented meaning now reads as a named row, on the same labels the register map uses for the same quantities. The raw block stays, and shrinks to what genuinely nobody has identified — which is the only honest reason for a key like 0410.b9 to be on screen at all. Three fields the reference decodes for nobody are decoded here. ac is where the charge is coming from — off or paused, grid, solar — and it is called chargingSource rather than chargingMode, because that name already belongs to a Modbus register and the last time a cloud field borrowed one, d9 spent a release reporting the wrong thing under the right name. b6 is the session's order id. f1, f2 and f3 are the identity fields the reference marks multi-value: four bytes read as the parts of a version, in the order they arrive, which is what the account view's own firmware string looks like. If the panel shows those parts reversed, the order is the thing to flip — it is the one assumption here that the wire has not yet confirmed. The rest was already decoded and simply never drawn. The readings card now shows the session's start, its id, the charging source, whether a cable is in, the charging window, and — since a reading is worth what its age is — the live-stream flag and both stream clocks, because telemetry and settings arrive on different messages with different triggers. Per-phase session energy joins the phase matrix as a fourth column, appearing on the transport that counts a session and staying away from the one that does not, exactly as the reactive and apparent pair does. The settings block gains the fourteen the register map has no address for: plug lock, auto restart, random delay, the schedule and its mode, the weekend window and how the weekend is handled, the light-off schedule and window, the breaker limit, the solar mode and its minimum current, automatic phase switching, the three panel gestures, and what the two balancing features are watching — the meter and monitor serials by name, their two unpinned numbers as the numbers they are. A local network block says whether the charger's own Modbus server is on and where, which is the answer the Modbus mode's setup screen otherwise has to be given by hand. The device block gains the controller version. A test now holds the line the projection quietly drew: every name in the message maps must reach a snapshot field. A name added to a map without a field to land in would otherwise surface in the raw block looking like something we understood. Left raw: a1, the frame opener the charger echoes back; b7, which the map itself calls unidentified; b9, bc and bd, which appear in no map; the five-minute 0400; and 0857 — a message type the reference's closed inventory of fourteen does not contain and this charger publishes anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
7.1 KiB
Go
194 lines
7.1 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; 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)
|
|
}
|
|
}
|