OCPP asks the charger to dial us: a public endpoint, a TLS certificate, and a route in through the customer's router. Our own handler then demanded two more things the V1 does not offer — TLS on a charger that connects over ws://, and Basic auth credentials the Anker app has no field for — so every connection was turned away before the upgrade. Anker publishes a Modbus TCP register map for this charger, and it inverts the problem: we dial the charger, on its own network, with no inbound reachability to arrange. That works for a charger behind a router that OCPP cannot reach at all. internal/modbus is the protocol, hand-rolled against the spec like the MQTT and WebSocket clients beside it. The plugin's modbus.go is the V1's map: the same 0-8 status enum the cloud already reports, per-phase measurements, and the writable registers behind start, stop, current limit, boost and phase mode. A new "modbus" control mode routes the existing control endpoints down it, so the REST surface, the rate limit, the confirmation step and the audit trail are the ones already there. The commands the register map has no equivalent for say so by name rather than failing as unknown, and a current below the charger's 6 A floor is refused because it pauses the charge rather than slowing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
72 lines
2.0 KiB
Go
72 lines
2.0 KiB
Go
package api
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestValidateModbusAddressAcceptsLANAddresses(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
host string
|
|
port int
|
|
}{
|
|
{"10.2.1.55", 502},
|
|
{"192.168.1.40", 0}, // 0 means the standard port
|
|
{"charger.local", 502},
|
|
{"fd00::1", 502},
|
|
} {
|
|
if err := validateModbusAddress(tc.host, tc.port); err != nil {
|
|
t.Errorf("validateModbusAddress(%q, %d) = %v, want nil", tc.host, tc.port, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The server dials this address on the caller's behalf, so an address that
|
|
// resolves back to the server would point it at its own services.
|
|
func TestValidateModbusAddressRefusesLoopback(t *testing.T) {
|
|
for _, host := range []string{"127.0.0.1", "::1", "localhost", "LocalHost", "0.0.0.0"} {
|
|
err := validateModbusAddress(host, 502)
|
|
if err == nil {
|
|
t.Errorf("validateModbusAddress(%q) was accepted", host)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), "server itself") {
|
|
t.Errorf("validateModbusAddress(%q) = %v, want it to say why", host, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateModbusAddressRejectsMalformed(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
host string
|
|
port int
|
|
}{
|
|
{"empty host", "", 502},
|
|
{"a URL rather than a host", "http://10.2.1.55/", 502},
|
|
{"host with a path", "10.2.1.55/modbus", 502},
|
|
{"port above the range", "10.2.1.55", 70000},
|
|
{"negative port", "10.2.1.55", -1},
|
|
}
|
|
for _, tc := range cases {
|
|
if err := validateModbusAddress(tc.host, tc.port); err == nil {
|
|
t.Errorf("%s was accepted", tc.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnkerModbusConfigTrimsAndDefaults(t *testing.T) {
|
|
cfg := ankerModbusConfig(ankerChargerBinding{ModbusHost: " 10.2.1.55 "})
|
|
if cfg.Host != "10.2.1.55" {
|
|
t.Errorf("Host = %q, want it trimmed", cfg.Host)
|
|
}
|
|
if got := cfg.Address(); got != "10.2.1.55:502" {
|
|
t.Errorf("Address = %q, want the standard port filled in", got)
|
|
}
|
|
|
|
cfg = ankerModbusConfig(ankerChargerBinding{ModbusHost: "10.2.1.55", ModbusPort: 1502})
|
|
if got := cfg.Address(); got != "10.2.1.55:1502" {
|
|
t.Errorf("Address = %q, want 10.2.1.55:1502", got)
|
|
}
|
|
}
|