The HabuDen has no cloud API to connect to. It is commissioned over Bluetooth in
the Greencell GC app, pointed at an MQTT broker the owner runs, and from then on
publishes there — so the connector is an MQTT client rather than an HTTP one,
and nothing in it reaches Greencell. The wire contract is Home Assistant's own
greencell component and the greencell_client 1.0.3 library beneath it, which is
the only published description of the topics: a BROADCAST on /greencell/broadcast
draws device announcements, and /greencell/evse/{sn}/ carries current in
milliamps, voltage, power under "momentary", the EVSE state, and the access level
chosen in the app.
That meant an MQTT client, and the server takes no dependencies, so internal/mqtt
is hand-rolled the way internal/ocpp's RFC 6455 layer is. It is scoped to what
this connector needs and says so: QoS 0 for everything we send, clean session,
no reconnect — a connection lives for one plugin call, which is exactly how the
manager builds and tears down an instance. Inbound PUBLISH is accepted at QoS 0,
1 and 2 with the acknowledgements each requires, because the QoS of a delivery is
the broker's choice and not ours; an unacknowledged QoS 1 is redelivered forever.
Read-only, and the reason is worth writing down rather than rediscovering. A
device in EXECUTE mode accepts START, STOP, SET_CURRENT and QUERY — but the topic
those go to appears in no source: not Greencell's integration page, not
greencell_client, and Home Assistant ships sensor-only for that same reason.
Publishing to a guessed topic would be a control feature whose failure mode is a
driver believing they stopped a charge. So the access level is reported, and
commandTopic is the seam: an operator who has watched their own broker and found
theirs sets it, and a state read then sends QUERY — the one command a READ-mode
device also honours — instead of waiting out the charger's publish cadence. The
day the topic is public, control is a payload away from the same field.
What the cascade resolves here is a broker, not an account, so host, port, TLS and
credentials resolve together from the highest layer that names a host: an
organization's address paired with a user's password would address a broker with
credentials never meant for it. The serial, the QUERY topic and the listen window
each describe the charger rather than the endpoint, so each resolves on its own.
Two reading rules the tests pin. A phase the device did not report stays nil
rather than zero, because zero amps on a charger is a real measurement — a JSON
null decoding to 0.0 was a live bug until a test caught it — and a partial read
returns with received/complete flags instead of failing, since a device that
publishes some topics on a slower cadence is still worth reading. And a reachable
broker with no charger on it is degraded, not down: the half we configure works
and the missing half is the device. The plugin's end-to-end tests run against an
in-process broker written to the raw wire format, so a bug in the client cannot
hide behind a matching bug in the fixture.
The apps get the third connector card. The panel needed nothing — it renders a
plugin's ConfigFields itself — but the per-user panes are still hand-written per
integration, which is now three near-copies and the argument for the generic
version already noted in the plugins README. The web form splits the broker from
the charger because the server resolves them differently. The phone card is a
declarative config against the shared widget, which gained a number field type, a
degraded state that reads amber rather than red, and a fix for a locked field
that was covering its own displayed value with dots. Twenty keys in three
languages across both apps; Greencell, HabuDen and the literal QUERY join the
proper nouns that stay in English.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
853 lines
26 KiB
Go
853 lines
26 KiB
Go
package greencell
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"drivervault/apiserver/internal/plugins"
|
|
)
|
|
|
|
// ---- descriptor & registration -----------------------------------------------
|
|
|
|
func TestDescriptor(t *testing.T) {
|
|
d := (&Plugin{}).Descriptor()
|
|
if d.Name != "greencell" {
|
|
t.Fatalf("name = %q, want greencell", d.Name)
|
|
}
|
|
if d.Kind != plugins.KindBuiltin {
|
|
t.Fatalf("kind = %q, want builtin", d.Kind)
|
|
}
|
|
if len(d.Capabilities) == 0 {
|
|
t.Fatal("expected capabilities")
|
|
}
|
|
|
|
fields := map[string]plugins.ConfigField{}
|
|
for _, f := range d.ConfigFields {
|
|
fields[f.Key] = f
|
|
}
|
|
for _, k := range []string{"host", "port", "tls", "username", "password", "serial", "commandTopic", "timeout"} {
|
|
if _, ok := fields[k]; !ok {
|
|
t.Errorf("config field %q should be present", k)
|
|
}
|
|
}
|
|
// Nothing is required at the global layer — the per-user cascade may supply
|
|
// the broker instead, exactly as it does for the other connectors.
|
|
for k, f := range fields {
|
|
if f.Required {
|
|
t.Errorf("config field %q must not be required at the global layer", k)
|
|
}
|
|
}
|
|
if !fields["password"].Secret {
|
|
t.Error("the broker password must be marked secret")
|
|
}
|
|
if fields["username"].Secret {
|
|
t.Error("the broker username is not a secret")
|
|
}
|
|
if fields["tls"].Type != "select" {
|
|
t.Errorf("tls type = %q, want select", fields["tls"].Type)
|
|
}
|
|
if fields["port"].Default != defaultPort {
|
|
t.Errorf("port default = %q, want %q", fields["port"].Default, defaultPort)
|
|
}
|
|
}
|
|
|
|
func TestRegistered(t *testing.T) {
|
|
// init() must have registered the factory; registering twice panics, so a
|
|
// duplicate name would surface at startup rather than here.
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Fatal("expected a panic on duplicate registration, meaning the plugin registered itself")
|
|
}
|
|
}()
|
|
plugins.Register("greencell", func() plugins.Plugin { return &Plugin{} })
|
|
}
|
|
|
|
// ---- config ------------------------------------------------------------------
|
|
|
|
func TestInitBuildsBrokerAddress(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
config map[string]string
|
|
address string
|
|
tls bool
|
|
}{
|
|
{"explicit port", map[string]string{"host": "10.2.1.10", "port": "1884"}, "10.2.1.10:1884", false},
|
|
{"default plain port", map[string]string{"host": "mqtt.local"}, "mqtt.local:1883", false},
|
|
{"default tls port", map[string]string{"host": "mqtt.local", "tls": "on"}, "mqtt.local:8883", true},
|
|
{"tls with explicit port", map[string]string{"host": "mqtt.local", "tls": "true", "port": "9001"}, "mqtt.local:9001", true},
|
|
{"ipv6 host", map[string]string{"host": "fd00::1", "port": "1883"}, "[fd00::1]:1883", false},
|
|
{"no host", map[string]string{}, "", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var p Plugin
|
|
if err := p.Init(context.Background(), tc.config); err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
if p.address != tc.address {
|
|
t.Errorf("address = %q, want %q", p.address, tc.address)
|
|
}
|
|
if p.useTLS != tc.tls {
|
|
t.Errorf("useTLS = %v, want %v", p.useTLS, tc.tls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSnapshotWithoutBrokerIsAnError(t *testing.T) {
|
|
var p Plugin
|
|
_ = p.Init(context.Background(), map[string]string{})
|
|
if _, _, err := p.snapshot(); err == nil {
|
|
t.Fatal("expected an error when no broker is configured")
|
|
}
|
|
}
|
|
|
|
func TestParseTimeout(t *testing.T) {
|
|
cases := map[string]time.Duration{
|
|
"": defaultTimeout,
|
|
"abc": defaultTimeout,
|
|
"0": defaultTimeout,
|
|
"-5": defaultTimeout,
|
|
" 20 ": 20 * time.Second,
|
|
"1": minTimeout,
|
|
"600": maxTimeout, // clamped
|
|
"999999": maxTimeout,
|
|
}
|
|
for in, want := range cases {
|
|
if got := parseTimeout(in); got != want {
|
|
t.Errorf("parseTimeout(%q) = %v, want %v", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTruthy(t *testing.T) {
|
|
for _, v := range []string{"on", "ON", "true", "yes", "1", " tls "} {
|
|
if !truthy(v) {
|
|
t.Errorf("truthy(%q) = false", v)
|
|
}
|
|
}
|
|
for _, v := range []string{"", "off", "false", "no", "0", "maybe"} {
|
|
if truthy(v) {
|
|
t.Errorf("truthy(%q) = true", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- serials & announcements -------------------------------------------------
|
|
|
|
func TestDeviceModel(t *testing.T) {
|
|
// The HabuDen pattern comes from greencell_client's GreencellUtils.
|
|
if got := deviceModel("EVGC021B22752405ZM0018"); got != nameHabuDen {
|
|
t.Errorf("HabuDen serial named %q, want %q", got, nameHabuDen)
|
|
}
|
|
for _, sn := range []string{"EVGC031B22752405ZM0018", "EVGC022B22752405ZM0018", "HABU_DEN", ""} {
|
|
if got := deviceModel(sn); got != nameGeneric {
|
|
t.Errorf("deviceModel(%q) = %q, want %q", sn, got, nameGeneric)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseAnnouncement(t *testing.T) {
|
|
dev, ok := parseAnnouncement([]byte(`{"id":"EVGC021B22752405ZM0018","fw":"1.2.3"}`))
|
|
if !ok {
|
|
t.Fatal("a payload with an id should parse")
|
|
}
|
|
if dev.SN != "EVGC021B22752405ZM0018" {
|
|
t.Errorf("sn = %q", dev.SN)
|
|
}
|
|
if dev.Model != nameHabuDen || !strings.HasPrefix(dev.Name, nameHabuDen+" ") {
|
|
t.Errorf("name/model = %q / %q", dev.Name, dev.Model)
|
|
}
|
|
// Fields this connector has no schema for must survive.
|
|
if !strings.Contains(string(dev.Announcement), `"fw":"1.2.3"`) {
|
|
t.Errorf("announcement should be passed through verbatim, got %s", dev.Announcement)
|
|
}
|
|
|
|
for _, bad := range []string{`{"id":""}`, `{"id":" "}`, `{"name":"BROADCAST"}`, `not json`, ``} {
|
|
if _, ok := parseAnnouncement([]byte(bad)); ok {
|
|
t.Errorf("parseAnnouncement(%q) should have been rejected", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTopicSuffix(t *testing.T) {
|
|
sn := "SN1"
|
|
for _, want := range telemetryTopics {
|
|
got, ok := topicSuffix(evsePrefix+sn+"/"+want, sn)
|
|
if !ok || got != want {
|
|
t.Errorf("topicSuffix for %q = %q/%v", want, got, ok)
|
|
}
|
|
}
|
|
for _, topic := range []string{
|
|
evsePrefix + "OTHER/current", // another charger
|
|
evsePrefix + sn + "/unknown", // a topic we do not model
|
|
discoveryTopic,
|
|
evsePrefix + sn, // no suffix at all
|
|
} {
|
|
if _, ok := topicSuffix(topic, sn); ok {
|
|
t.Errorf("topicSuffix(%q) should not match", topic)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- payload parsing ---------------------------------------------------------
|
|
|
|
func TestAsNumber(t *testing.T) {
|
|
f := asNumber(json.RawMessage(`230.5`))
|
|
if f == nil || *f != 230.5 {
|
|
t.Errorf("number = %v", f)
|
|
}
|
|
// Some firmware quotes its numbers.
|
|
f = asNumber(json.RawMessage(`"16000"`))
|
|
if f == nil || *f != 16000 {
|
|
t.Errorf("quoted number = %v", f)
|
|
}
|
|
for _, bad := range []string{`null`, `"abc"`, `{}`, ``} {
|
|
if got := asNumber(json.RawMessage(bad)); got != nil {
|
|
t.Errorf("asNumber(%q) = %v, want nil", bad, *got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParsePhasesLeavesMissingPhasesUnset(t *testing.T) {
|
|
// A single-phase charge reports only l1; l2/l3 must stay nil rather than
|
|
// reading as a real zero.
|
|
p := parsePhases(json.RawMessage(`{"l1":16000}`))
|
|
if p.L1 == nil || *p.L1 != 16000 {
|
|
t.Errorf("l1 = %v", p.L1)
|
|
}
|
|
if p.L2 != nil || p.L3 != nil {
|
|
t.Errorf("missing phases should be nil, got %v %v", p.L2, p.L3)
|
|
}
|
|
if got := parsePhases(json.RawMessage(`not json`)); got.L1 != nil {
|
|
t.Error("a malformed payload should yield no phases")
|
|
}
|
|
if got := parsePhases(nil); got.L1 != nil {
|
|
t.Error("a missing payload should yield no phases")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeState(t *testing.T) {
|
|
if got := normalizeState("CHARGING"); got != stateCharging {
|
|
t.Errorf("CHARGING -> %q", got)
|
|
}
|
|
if got := normalizeState(" waiting_for_car "); got != stateWaitingForCar {
|
|
t.Errorf("waiting_for_car -> %q", got)
|
|
}
|
|
for _, in := range []string{"", "SOMETHING_NEW", "UNKNOWN"} {
|
|
if got := normalizeState(in); got != stateUnknown {
|
|
t.Errorf("normalizeState(%q) = %q, want unknown", in, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeAccess(t *testing.T) {
|
|
cases := map[string]string{
|
|
"EXECUTE": accessExecute, "read": accessRead, "DISABLED": accessDisabled,
|
|
"UNAVAILABLE": accessUnavailable,
|
|
// OFFLINE is greencell_client's deprecated spelling of UNAVAILABLE.
|
|
"OFFLINE": accessUnavailable,
|
|
"": accessUnknown, "nonsense": accessUnknown,
|
|
}
|
|
for in, want := range cases {
|
|
if got := normalizeAccess(in); got != want {
|
|
t.Errorf("normalizeAccess(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildStateThreePhaseCharge(t *testing.T) {
|
|
raw := map[string]json.RawMessage{
|
|
topicCurrent: json.RawMessage(`{"l1":16000,"l2":15980,"l3":16010}`),
|
|
topicVoltage: json.RawMessage(`{"l1":230.1,"l2":229.7,"l3":231.0}`),
|
|
topicPower: json.RawMessage(`{"momentary":11040}`),
|
|
topicStatus: json.RawMessage(`{"state":"CHARGING"}`),
|
|
topicDeviceState: json.RawMessage(`{"level":"EXECUTE"}`),
|
|
}
|
|
st := buildState("EVGC021B22752405ZM0018", raw)
|
|
|
|
if !st.Complete {
|
|
t.Error("all five topics reported, so the reading is complete")
|
|
}
|
|
// Current is published in milliamps.
|
|
if st.CurrentA.L1 == nil || *st.CurrentA.L1 != 16 {
|
|
t.Errorf("l1 current = %v A, want 16", st.CurrentA.L1)
|
|
}
|
|
if st.VoltageV.L1 == nil || *st.VoltageV.L1 != 230.1 {
|
|
t.Errorf("l1 voltage = %v", st.VoltageV.L1)
|
|
}
|
|
if st.PowerW == nil || *st.PowerW != 11040 {
|
|
t.Errorf("power = %v", st.PowerW)
|
|
}
|
|
if st.Status != stateCharging || !st.Charging || !st.Plugged || st.Fault {
|
|
t.Errorf("status flags: %q charging=%v plugged=%v fault=%v", st.Status, st.Charging, st.Plugged, st.Fault)
|
|
}
|
|
if st.AccessLevel != accessExecute || !st.CanExecute || !st.Available {
|
|
t.Errorf("access: %q canExecute=%v available=%v", st.AccessLevel, st.CanExecute, st.Available)
|
|
}
|
|
if st.LivePhases != 3 {
|
|
t.Errorf("livePhases = %d, want 3", st.LivePhases)
|
|
}
|
|
if st.Model != nameHabuDen {
|
|
t.Errorf("model = %q", st.Model)
|
|
}
|
|
if st.ObservedAt.IsZero() {
|
|
t.Error("observedAt should be stamped")
|
|
}
|
|
}
|
|
|
|
func TestBuildStateSinglePhaseAndPartial(t *testing.T) {
|
|
// Only two topics reported: the rest must read as unset, not as zero.
|
|
raw := map[string]json.RawMessage{
|
|
topicCurrent: json.RawMessage(`{"l1":16000,"l2":30,"l3":0}`),
|
|
topicStatus: json.RawMessage(`{"state":"CHARGING"}`),
|
|
}
|
|
st := buildState("SN1", raw)
|
|
|
|
if st.Complete {
|
|
t.Error("a partial reading must not claim to be complete")
|
|
}
|
|
if st.Received[topicCurrent] != true || st.Received[topicPower] != false {
|
|
t.Errorf("received map = %v", st.Received)
|
|
}
|
|
if st.PowerW != nil {
|
|
t.Errorf("power = %v, want nil when the topic was silent", *st.PowerW)
|
|
}
|
|
// 30 mA is 0.03 A — noise on an idle phase, not a live one.
|
|
if st.LivePhases != 1 {
|
|
t.Errorf("livePhases = %d, want 1", st.LivePhases)
|
|
}
|
|
if st.AccessLevel != accessUnknown {
|
|
t.Errorf("access level = %q, want unknown when device_state was silent", st.AccessLevel)
|
|
}
|
|
if st.Model != nameGeneric {
|
|
t.Errorf("model = %q, want the generic name for a non-HabuDen serial", st.Model)
|
|
}
|
|
}
|
|
|
|
func TestBuildStateUnavailable(t *testing.T) {
|
|
// The device signals unavailability through the status topic itself, which
|
|
// upstream detects by substring because the payload is not a state document.
|
|
for _, payload := range []string{`{"state":"UNAVAILABLE"}`, `"OFFLINE"`, `{"status":"UNAVAILABLE"}`} {
|
|
st := buildState("SN1", map[string]json.RawMessage{
|
|
topicStatus: json.RawMessage(payload),
|
|
topicDeviceState: json.RawMessage(`{"level":"READ"}`),
|
|
})
|
|
if st.Status != stateUnavailable {
|
|
t.Errorf("payload %s -> status %q, want unavailable", payload, st.Status)
|
|
}
|
|
if st.Available {
|
|
t.Errorf("payload %s should not read as available", payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildStateErrorStates(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
state string
|
|
fault bool
|
|
plugged bool
|
|
}{
|
|
{"ERROR_CAR", true, true},
|
|
{"ERROR_EVSE", true, false},
|
|
{"IDLE", false, false},
|
|
{"FINISHED", false, true},
|
|
} {
|
|
st := buildState("SN1", map[string]json.RawMessage{
|
|
topicStatus: json.RawMessage(`{"state":"` + tc.state + `"}`),
|
|
})
|
|
if st.Fault != tc.fault || st.Plugged != tc.plugged {
|
|
t.Errorf("%s: fault=%v plugged=%v, want %v/%v", tc.state, st.Fault, st.Plugged, tc.fault, tc.plugged)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildStateDisabledIsNotAvailable(t *testing.T) {
|
|
st := buildState("SN1", map[string]json.RawMessage{
|
|
topicStatus: json.RawMessage(`{"state":"IDLE"}`),
|
|
topicDeviceState: json.RawMessage(`{"level":"DISABLED"}`),
|
|
})
|
|
if st.Available || st.CanExecute {
|
|
t.Errorf("a DISABLED device is neither available nor executable: %+v", st)
|
|
}
|
|
}
|
|
|
|
// ---- Invoke dispatch ---------------------------------------------------------
|
|
|
|
func TestInvokeUnknownAction(t *testing.T) {
|
|
var p Plugin
|
|
_ = p.Init(context.Background(), map[string]string{"host": "127.0.0.1"})
|
|
if _, err := p.Invoke(context.Background(), "nope", nil); err == nil {
|
|
t.Fatal("expected an error for an unknown action")
|
|
}
|
|
}
|
|
|
|
func TestInvokeChargerStateRequiresSerial(t *testing.T) {
|
|
var p Plugin
|
|
_ = p.Init(context.Background(), map[string]string{"host": "127.0.0.1"})
|
|
_, err := p.Invoke(context.Background(), "charger-state", nil)
|
|
if err == nil || !strings.Contains(err.Error(), "serial") {
|
|
t.Fatalf("error = %v, want a missing-serial error", err)
|
|
}
|
|
}
|
|
|
|
func TestInvokeRejectsMalformedParams(t *testing.T) {
|
|
var p Plugin
|
|
_ = p.Init(context.Background(), map[string]string{"host": "127.0.0.1"})
|
|
if _, err := p.Invoke(context.Background(), "chargers", json.RawMessage(`[`)); err == nil {
|
|
t.Fatal("expected an error for malformed params")
|
|
}
|
|
}
|
|
|
|
// ---- end to end against a broker ---------------------------------------------
|
|
|
|
func TestDiscoverCollectsEveryDeviceThatAnswers(t *testing.T) {
|
|
addr := startBroker(t, []brokerMsg{
|
|
{discoveryTopic, `{"id":"EVGC021B22752405ZM0018"}`},
|
|
{discoveryTopic, `{"id":"SOMETHINGELSE"}`},
|
|
{discoveryTopic, `{"id":"EVGC021B22752405ZM0018"}`}, // a repeat is one device
|
|
{discoveryTopic, `{"name":"BROADCAST"}`}, // our own echo, not a device
|
|
})
|
|
|
|
p := pluginAt(t, addr, map[string]string{"timeout": "5"})
|
|
devices, err := p.discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("discover: %v", err)
|
|
}
|
|
if len(devices) != 2 {
|
|
t.Fatalf("found %d devices, want 2: %+v", len(devices), devices)
|
|
}
|
|
if devices[0].SN != "EVGC021B22752405ZM0018" || devices[0].Model != nameHabuDen {
|
|
t.Errorf("first device = %+v", devices[0])
|
|
}
|
|
if devices[1].Model != nameGeneric {
|
|
t.Errorf("second device model = %q, want the generic name", devices[1].Model)
|
|
}
|
|
}
|
|
|
|
func TestDiscoverReturnsEmptyOnSilence(t *testing.T) {
|
|
addr := startBroker(t, nil)
|
|
p := pluginAt(t, addr, map[string]string{"timeout": "1"})
|
|
devices, err := p.discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("discover: %v", err)
|
|
}
|
|
if len(devices) != 0 {
|
|
t.Fatalf("want no devices, got %+v", devices)
|
|
}
|
|
}
|
|
|
|
func TestChargerStateReadsTelemetry(t *testing.T) {
|
|
sn := "EVGC021B22752405ZM0018"
|
|
addr := startBroker(t, []brokerMsg{
|
|
{evsePrefix + sn + "/current", `{"l1":16000,"l2":0,"l3":0}`},
|
|
{evsePrefix + sn + "/voltage", `{"l1":230,"l2":231,"l3":229}`},
|
|
{evsePrefix + sn + "/power", `{"momentary":3680}`},
|
|
{evsePrefix + sn + "/status", `{"state":"CHARGING"}`},
|
|
{evsePrefix + sn + "/device_state", `{"level":"READ"}`},
|
|
// Another charger on the same broker must not bleed into this reading.
|
|
{evsePrefix + "OTHER/power", `{"momentary":99999}`},
|
|
})
|
|
|
|
p := pluginAt(t, addr, map[string]string{"timeout": "5"})
|
|
st, err := p.chargerState(context.Background(), sn)
|
|
if err != nil {
|
|
t.Fatalf("chargerState: %v", err)
|
|
}
|
|
if !st.Complete {
|
|
t.Errorf("expected a complete reading, got %v", st.Received)
|
|
}
|
|
if st.PowerW == nil || *st.PowerW != 3680 {
|
|
t.Errorf("power = %v, want 3680 (the other charger's reading must not leak in)", st.PowerW)
|
|
}
|
|
if st.CurrentA.L1 == nil || *st.CurrentA.L1 != 16 {
|
|
t.Errorf("l1 = %v A, want 16", st.CurrentA.L1)
|
|
}
|
|
if st.LivePhases != 1 {
|
|
t.Errorf("livePhases = %d, want 1", st.LivePhases)
|
|
}
|
|
if st.AccessLevel != accessRead || st.CanExecute {
|
|
t.Errorf("READ mode should not report as executable: %+v", st)
|
|
}
|
|
}
|
|
|
|
func TestChargerStateReturnsPartialAtTheDeadline(t *testing.T) {
|
|
sn := "SN1"
|
|
addr := startBroker(t, []brokerMsg{
|
|
{evsePrefix + sn + "/status", `{"state":"IDLE"}`},
|
|
})
|
|
|
|
p := pluginAt(t, addr, map[string]string{"timeout": "1"})
|
|
st, err := p.chargerState(context.Background(), sn)
|
|
if err != nil {
|
|
t.Fatalf("a partial reading should not be an error: %v", err)
|
|
}
|
|
if st.Complete || st.Status != stateIdle {
|
|
t.Errorf("state = %+v", st)
|
|
}
|
|
if st.Received[topicVoltage] {
|
|
t.Error("voltage was never published, so it must read as not received")
|
|
}
|
|
}
|
|
|
|
func TestChargerStateErrorsOnSilence(t *testing.T) {
|
|
addr := startBroker(t, nil)
|
|
p := pluginAt(t, addr, map[string]string{"timeout": "1"})
|
|
_, err := p.chargerState(context.Background(), "SN1")
|
|
if err == nil || !strings.Contains(err.Error(), "no data from charger") {
|
|
t.Fatalf("error = %v, want a no-data error", err)
|
|
}
|
|
}
|
|
|
|
func TestChargerStateFailsWithoutABroker(t *testing.T) {
|
|
// Nothing is listening, so the session cannot be opened at all.
|
|
p := pluginAt(t, "127.0.0.1:1", map[string]string{"timeout": "1"})
|
|
if _, err := p.chargerState(context.Background(), "SN1"); err == nil {
|
|
t.Fatal("expected a connection error")
|
|
}
|
|
}
|
|
|
|
func TestHealthCheckClassifiesTheThreeOutcomes(t *testing.T) {
|
|
t.Run("ok", func(t *testing.T) {
|
|
addr := startBroker(t, []brokerMsg{{discoveryTopic, `{"id":"EVGC021B22752405ZM0018"}`}})
|
|
p := pluginAt(t, addr, map[string]string{"timeout": "5"})
|
|
h := p.HealthCheck(context.Background())
|
|
if h.Status != plugins.StatusOK {
|
|
t.Fatalf("status = %q (%s), want ok", h.Status, h.Detail)
|
|
}
|
|
if !strings.Contains(h.Detail, "1 Greencell device") {
|
|
t.Errorf("detail = %q", h.Detail)
|
|
}
|
|
})
|
|
|
|
t.Run("degraded when the broker answers but no charger does", func(t *testing.T) {
|
|
addr := startBroker(t, nil)
|
|
p := pluginAt(t, addr, map[string]string{"timeout": "1"})
|
|
h := p.HealthCheck(context.Background())
|
|
if h.Status != plugins.StatusDegraded {
|
|
t.Fatalf("status = %q (%s), want degraded", h.Status, h.Detail)
|
|
}
|
|
})
|
|
|
|
t.Run("down when the broker is unreachable", func(t *testing.T) {
|
|
p := pluginAt(t, "127.0.0.1:1", map[string]string{"timeout": "1"})
|
|
h := p.HealthCheck(context.Background())
|
|
if h.Status != plugins.StatusDown {
|
|
t.Fatalf("status = %q (%s), want down", h.Status, h.Detail)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInvokeChargersShape(t *testing.T) {
|
|
addr := startBroker(t, []brokerMsg{{discoveryTopic, `{"id":"EVGC021B22752405ZM0018"}`}})
|
|
p := pluginAt(t, addr, map[string]string{"timeout": "5"})
|
|
|
|
raw, err := p.Invoke(context.Background(), "chargers", nil)
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
var doc struct {
|
|
Chargers []Device `json:"chargers"`
|
|
}
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(doc.Chargers) != 1 || doc.Chargers[0].SN != "EVGC021B22752405ZM0018" {
|
|
t.Fatalf("chargers = %+v", doc.Chargers)
|
|
}
|
|
}
|
|
|
|
func TestInvokeChargerStateFallsBackToTheConfiguredSerial(t *testing.T) {
|
|
sn := "EVGC021B22752405ZM0018"
|
|
addr := startBroker(t, []brokerMsg{{evsePrefix + sn + "/status", `{"state":"FINISHED"}`}})
|
|
p := pluginAt(t, addr, map[string]string{"timeout": "1", "serial": sn})
|
|
|
|
raw, err := p.Invoke(context.Background(), "charger-state", nil)
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
var st State
|
|
if err := json.Unmarshal(raw, &st); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if st.SN != sn || st.Status != stateFinished {
|
|
t.Fatalf("state = %+v", st)
|
|
}
|
|
}
|
|
|
|
// ---- test broker -------------------------------------------------------------
|
|
|
|
// brokerMsg is one publication the test broker pushes once the plugin has
|
|
// subscribed and sent its discovery broadcast.
|
|
type brokerMsg struct {
|
|
topic string
|
|
payload string
|
|
}
|
|
|
|
// pluginAt returns a plugin configured to talk to addr.
|
|
func pluginAt(t *testing.T, addr string, extra map[string]string) *Plugin {
|
|
t.Helper()
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
t.Fatalf("bad address %q: %v", addr, err)
|
|
}
|
|
config := map[string]string{"host": host, "port": port}
|
|
for k, v := range extra {
|
|
config[k] = v
|
|
}
|
|
p := &Plugin{}
|
|
if err := p.Init(context.Background(), config); err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// startBroker runs an MQTT 3.1.1 broker that does just enough for these tests:
|
|
// accept a connection, acknowledge the subscription, and — once the client sends
|
|
// its discovery broadcast — push the scripted messages back. It speaks the wire
|
|
// format directly rather than reusing internal/mqtt, so a bug in the client
|
|
// cannot hide behind a matching bug in the fixture.
|
|
func startBroker(t *testing.T, script []brokerMsg) string {
|
|
t.Helper()
|
|
return startBrokerRecording(t, nil, script)
|
|
}
|
|
|
|
// startBrokerRecording is startBroker with a channel that receives everything the
|
|
// client publishes, so a test can assert on what went out as well as what came
|
|
// back. A nil channel records nothing.
|
|
func startBrokerRecording(t *testing.T, seen chan<- brokerMsg, script []brokerMsg) string {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
var wg sync.WaitGroup
|
|
// Close the listener before waiting: the accept loop only ends when the
|
|
// listener does, and cleanups run last-registered first.
|
|
t.Cleanup(func() { _ = ln.Close(); wg.Wait() })
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
defer conn.Close()
|
|
serveBroker(conn, seen, script)
|
|
}()
|
|
}
|
|
}()
|
|
return ln.Addr().String()
|
|
}
|
|
|
|
func serveBroker(conn net.Conn, seen chan<- brokerMsg, script []brokerMsg) {
|
|
br := bufio.NewReader(conn)
|
|
for {
|
|
typ, _, body, err := readRaw(br)
|
|
if err != nil {
|
|
return
|
|
}
|
|
switch typ {
|
|
case 1: // CONNECT
|
|
if _, err := conn.Write(frame(2, 0, []byte{0, 0})); err != nil {
|
|
return
|
|
}
|
|
case 8: // SUBSCRIBE — acknowledge every requested filter with QoS 0
|
|
if len(body) < 2 {
|
|
return
|
|
}
|
|
ack := append([]byte{body[0], body[1]}, make([]byte, countFilters(body[2:]))...)
|
|
if _, err := conn.Write(frame(9, 0, ack)); err != nil {
|
|
return
|
|
}
|
|
case 3: // PUBLISH — from the client; record it, then answer with the script
|
|
if seen != nil {
|
|
if topic, rest, err := splitRawString(body); err == nil {
|
|
select {
|
|
case seen <- brokerMsg{topic, string(rest)}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
for _, m := range script {
|
|
pub := append(encRawString(m.topic), m.payload...)
|
|
if _, err := conn.Write(frame(3, 0, pub)); err != nil {
|
|
return
|
|
}
|
|
}
|
|
case 12: // PINGREQ
|
|
if _, err := conn.Write(frame(13, 0, nil)); err != nil {
|
|
return
|
|
}
|
|
case 14: // DISCONNECT
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// countFilters counts the topic filters in a SUBSCRIBE payload so the SUBACK can
|
|
// carry one return code per filter.
|
|
func countFilters(b []byte) int {
|
|
n := 0
|
|
for len(b) >= 3 {
|
|
l := int(binary.BigEndian.Uint16(b))
|
|
if len(b) < 2+l+1 {
|
|
return n
|
|
}
|
|
b = b[2+l+1:]
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
// splitRawString peels an MQTT UTF-8 string off the front of b.
|
|
func splitRawString(b []byte) (string, []byte, error) {
|
|
if len(b) < 2 {
|
|
return "", nil, io.ErrUnexpectedEOF
|
|
}
|
|
n := int(binary.BigEndian.Uint16(b))
|
|
if len(b) < 2+n {
|
|
return "", nil, io.ErrUnexpectedEOF
|
|
}
|
|
return string(b[2 : 2+n]), b[2+n:], nil
|
|
}
|
|
|
|
func encRawString(s string) []byte {
|
|
out := binary.BigEndian.AppendUint16(nil, uint16(len(s)))
|
|
return append(out, s...)
|
|
}
|
|
|
|
func frame(typ, flags byte, body []byte) []byte {
|
|
out := []byte{typ<<4 | flags}
|
|
n := len(body)
|
|
for {
|
|
digit := byte(n % 128)
|
|
n /= 128
|
|
if n > 0 {
|
|
digit |= 0x80
|
|
}
|
|
out = append(out, digit)
|
|
if n == 0 {
|
|
break
|
|
}
|
|
}
|
|
return append(out, body...)
|
|
}
|
|
|
|
func readRaw(br *bufio.Reader) (typ, flags byte, body []byte, err error) {
|
|
head, err := br.ReadByte()
|
|
if err != nil {
|
|
return 0, 0, nil, err
|
|
}
|
|
var (
|
|
length int
|
|
multiplier = 1
|
|
)
|
|
for i := 0; i < 4; i++ {
|
|
digit, err := br.ReadByte()
|
|
if err != nil {
|
|
return 0, 0, nil, err
|
|
}
|
|
length += int(digit&0x7F) * multiplier
|
|
if digit&0x80 == 0 {
|
|
break
|
|
}
|
|
multiplier *= 128
|
|
}
|
|
body = make([]byte, length)
|
|
if _, err := io.ReadFull(br, body); err != nil {
|
|
return 0, 0, nil, err
|
|
}
|
|
return head >> 4, head & 0x0F, body, nil
|
|
}
|
|
|
|
// ---- the operator-supplied QUERY topic ---------------------------------------
|
|
|
|
func TestCommandTopicFor(t *testing.T) {
|
|
var p Plugin
|
|
_ = p.Init(context.Background(), map[string]string{
|
|
"host": "h", "commandTopic": "/greencell/evse/" + snPlaceholder + "/command",
|
|
})
|
|
if got := p.commandTopicFor("SN1"); got != "/greencell/evse/SN1/command" {
|
|
t.Errorf("commandTopicFor = %q", got)
|
|
}
|
|
|
|
// A topic without the placeholder is used as given.
|
|
_ = p.Init(context.Background(), map[string]string{"host": "h", "commandTopic": "/fixed/topic"})
|
|
if got := p.commandTopicFor("SN1"); got != "/fixed/topic" {
|
|
t.Errorf("commandTopicFor = %q, want the literal topic", got)
|
|
}
|
|
|
|
// Unset means listen-only; nothing is ever published.
|
|
_ = p.Init(context.Background(), map[string]string{"host": "h"})
|
|
if got := p.commandTopicFor("SN1"); got != "" {
|
|
t.Errorf("commandTopicFor = %q, want empty when unconfigured", got)
|
|
}
|
|
}
|
|
|
|
func TestChargerStateSendsQueryWhenATopicIsConfigured(t *testing.T) {
|
|
sn := "SN1"
|
|
seen := make(chan brokerMsg, 4)
|
|
addr := startBrokerRecording(t, seen, []brokerMsg{
|
|
{evsePrefix + sn + "/status", `{"state":"IDLE"}`},
|
|
})
|
|
|
|
p := pluginAt(t, addr, map[string]string{
|
|
"timeout": "1", "commandTopic": "/greencell/evse/" + snPlaceholder + "/command",
|
|
})
|
|
if _, err := p.chargerState(context.Background(), sn); err != nil {
|
|
t.Fatalf("chargerState: %v", err)
|
|
}
|
|
|
|
var got []brokerMsg
|
|
close(seen)
|
|
for m := range seen {
|
|
got = append(got, m)
|
|
}
|
|
// The discovery broadcast, then the QUERY on the configured topic.
|
|
var query *brokerMsg
|
|
for i := range got {
|
|
if got[i].topic == "/greencell/evse/SN1/command" {
|
|
query = &got[i]
|
|
}
|
|
}
|
|
if query == nil {
|
|
t.Fatalf("no QUERY was published; broker saw %+v", got)
|
|
}
|
|
if query.payload != queryCommand {
|
|
t.Errorf("QUERY payload = %q, want %q", query.payload, queryCommand)
|
|
}
|
|
}
|
|
|
|
func TestChargerStateSendsNothingWithoutACommandTopic(t *testing.T) {
|
|
sn := "SN1"
|
|
seen := make(chan brokerMsg, 4)
|
|
addr := startBrokerRecording(t, seen, []brokerMsg{
|
|
{evsePrefix + sn + "/status", `{"state":"IDLE"}`},
|
|
})
|
|
|
|
p := pluginAt(t, addr, map[string]string{"timeout": "1"})
|
|
if _, err := p.chargerState(context.Background(), sn); err != nil {
|
|
t.Fatalf("chargerState: %v", err)
|
|
}
|
|
|
|
close(seen)
|
|
for m := range seen {
|
|
if m.topic != broadcastTopic {
|
|
t.Errorf("unconfigured, the plugin must publish only the discovery broadcast; it also sent %+v", m)
|
|
}
|
|
}
|
|
}
|