package ankersolix import ( "strings" "testing" ) // cmdFor finds the command that carries a setting, the way a write does. func cmdFor(t *testing.T, key string) settingCmd { t.Helper() i, ok := settingIndex[key] if !ok { t.Fatalf("%q is not a writable setting", key) } return settingCmds[i] } // A single-field setting is one field between the opener and the timestamp, so // the frame is checkable byte for byte up to the clock. func TestEncodeSettingCmdWritesOneField(t *testing.T) { frame, err := encodeSettingCmd(cmdFor(t, "ledBrightness"), map[string]any{"ledBrightness": 50.0}, nil) if err != nil { t.Fatalf("encodeSettingCmd: %v", err) } got := encodeHex(frame) // header (type 0100), the a1 opener, aa as a one-byte 50, then fe and the sum. if want := "ff09180003000f0100a10122aa020132fe0503"; !strings.HasPrefix(got, want) { t.Fatalf("frame = %s\nwant prefix %s", got, want) } if sum := xorChecksum(frame); sum != 0 { t.Errorf("XOR over the whole frame = %02x, want 00", sum) } } // Plug lock and the schedule switch are the two fields where on is 1 and off is // 2. Getting that backwards would silently invert them, so both are pinned. func TestSwitchesUseTheirOwnOnOffValues(t *testing.T) { cases := []struct { key string on bool want byte }{ {"plugLock", true, 1}, {"plugLock", false, 2}, {"autoStart", true, 1}, {"autoStart", false, 0}, } for _, tc := range cases { cmd := cmdFor(t, tc.key) var field settingField for _, f := range cmd.fields { if f.key == tc.key { field = f } } got, err := field.encode(field.wire, tc.on) if err != nil { t.Fatalf("%s(%v): %v", tc.key, tc.on, err) } if len(got.value) != 1 || got.value[0] != tc.want { t.Errorf("%s(%v) = %v, want %d", tc.key, tc.on, got.value, tc.want) } } } // The charger reads a grouped command as the whole truth, so a write that names // one of its settings must carry the others as the charger last reported them. func TestGroupedSettingResendsSiblingsFromState(t *testing.T) { state := map[string]any{ "lightOffScheduleSwitch": 1.0, "lightOffStart": "22:30", "lightOffEnd": "06:15", } frame, err := encodeSettingCmd(cmdFor(t, "lightOffSchedule"), map[string]any{"lightOffSchedule": false}, state) if err != nil { t.Fatalf("encodeSettingCmd: %v", err) } fields := fieldsOf(t, frame) // b4 the switch, then the two times as hour<<8|minute, least significant byte // first — 22:30 and 06:15 exactly as the charger reported them. if got := encodeHex(fields[0xb4]); got != "00" { t.Errorf("switch = %s, want 00 — the value the write asked for", got) } if got := encodeHex(fields[0xb5]); got != "1e16" { t.Errorf("start = %s, want 1e16 (22:30) — the charger's own value, carried through", got) } if got := encodeHex(fields[0xb6]); got != "0f06" { t.Errorf("end = %s, want 0f06 (06:15) — the charger's own value, carried through", got) } } // A sibling the charger has never reported is the one case where the write // stops: sending a made-up value would set it for real. func TestGroupedSettingRefusesWithoutState(t *testing.T) { _, err := encodeSettingCmd(cmdFor(t, "lightOffSchedule"), map[string]any{"lightOffSchedule": true}, map[string]any{}) if err == nil { t.Fatal("a grouped write with no state was accepted") } if !strings.Contains(err.Error(), "lightOffStart") { t.Errorf("error does not name the missing sibling: %v", err) } } // The load-balancing group carries a meter serial nothing here can invent, so it // travels back exactly as the charger reported it. func TestLoadBalancingCarriesTheMeterBack(t *testing.T) { state := map[string]any{ "loadBalancing": 0.0, "loadBalanceMonitorMode": 1.0, "loadBalanceMeterFlag": 1.0, "loadBalanceMonitorSN": "SM1234567890", } frame, err := encodeSettingCmd(cmdFor(t, "loadBalancing"), map[string]any{"loadBalancing": true}, state) if err != nil { t.Fatalf("encodeSettingCmd: %v", err) } if got := encodeHex(frame[7:9]); got != msgEVBalancing { t.Errorf("message type = %s, want %s", got, msgEVBalancing) } if !strings.Contains(string(frame), "SM1234567890") { t.Errorf("the meter serial is not in the frame: %s", encodeHex(frame)) } } // Values are checked against the ranges the charger takes, before anything is // published — a step, a bound, a clock and a name are all caught here. func TestSettingValuesAreValidated(t *testing.T) { cases := []struct { name string key string val any }{ {"brightness off its 10% step", "ledBrightness", 55.0}, {"brightness above 100", "ledBrightness", 120.0}, {"breaker limit below 10 A", "mainBreakerLimitA", 5.0}, {"breaker limit above 500 A", "mainBreakerLimitA", 600.0}, {"a current that pauses instead of charging", "maxCurrentA", 3.0}, {"a schedule mode the charger has no name for", "scheduleMode", 7.0}, {"a time that is not a time", "weekStart", "half past nine"}, {"a switch that is not a switch", "autoStart", "maybe"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cmd := cmdFor(t, tc.key) for _, f := range cmd.fields { if f.key != tc.key { continue } if _, err := f.encode(f.wire, tc.val); err == nil { t.Errorf("%s = %v was accepted", tc.key, tc.val) } } }) } } // Amps go on the wire in deciamps, the same as the Modbus register carries them. func TestMaxCurrentIsWrittenInDeciamps(t *testing.T) { frame, err := encodeSettingCmd(cmdFor(t, "maxCurrentA"), map[string]any{"maxCurrentA": 16.0}, nil) if err != nil { t.Fatalf("encodeSettingCmd: %v", err) } // 16 A is 160 on the wire, two bytes, least significant first. if got := encodeHex(fieldsOf(t, frame)[0xa8]); got != "a000" { t.Errorf("a8 = %s, want a000 (160 deciamps)", got) } } // Two settings on the same command belong in one frame; two on different // commands are two frames, in the table's order. func TestSettingsGroupByCommand(t *testing.T) { byCmd := map[int]bool{} for _, k := range []string{"weekStart", "weekEnd", "ledBrightness"} { byCmd[settingIndex[k]] = true } if len(byCmd) != 2 { t.Errorf("three settings landed on %d commands, want 2", len(byCmd)) } if settingIndex["weekStart"] != settingIndex["weekEnd"] { t.Error("the two week times are not on the same command") } } // Every settable name has a decode entry behind it, so a caller can read a // snapshot and send the same names back. func TestEverySettingReadsBack(t *testing.T) { decoded := map[string]bool{} for _, f := range evParams { decoded[f.name] = true } for _, cmd := range settingCmds { for _, f := range cmd.fields { if !decoded[f.state] { t.Errorf("setting %q reads its current value from %q, which nothing decodes", f.key, f.state) } } } } // fieldsOf splits a command frame into its data fields, keyed by wire name and // stripped of the value type. A command message type has no decode map of its // own — the charger reports these values back on a different message — so a // command frame is checked as the bytes it is. func fieldsOf(t *testing.T, frame []byte) map[byte][]byte { t.Helper() raw, ok := splitFields(frame, frameHeaderLen) if !ok { t.Fatalf("frame does not divide into whole data fields: %s", encodeHex(frame)) } out := map[byte][]byte{} for _, f := range raw { out[f.name] = f.value } return out }