From 0c375da10ff33c02ffdf4c29450f58a05635efb0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 14:16:30 +0000 Subject: [PATCH] feat(ev): add Zaptec Cloud charger support Register Zaptec as an HTTP cloud EV provider next to Easee. The wizard and Settings list chargers via OAuth password grant against api.zaptec.com. The Lua driver lives in testdata until the device-drivers pin includes it; operators can drop the same file in the user-drivers directory. Experimental until a live charger is exercised. Signed-off-by: Cursor Agent --- .changeset/zaptec-cloud.md | 5 + go/internal/config/config.go | 16 +- go/internal/config/evcharger_test.go | 30 ++ go/internal/drivers/testdata/zaptec_cloud.lua | 421 ++++++++++++++++++ go/internal/drivers/zaptec_cloud_test.go | 215 +++++++++ go/internal/evcloud/zaptec.go | 161 +++++++ go/internal/evcloud/zaptec_test.go | 155 +++++++ web/settings/tabs/ev.js | 35 +- web/setup.js | 10 +- web/setup.test.mjs | 2 + 10 files changed, 1021 insertions(+), 29 deletions(-) create mode 100644 .changeset/zaptec-cloud.md create mode 100644 go/internal/drivers/testdata/zaptec_cloud.lua create mode 100644 go/internal/drivers/zaptec_cloud_test.go create mode 100644 go/internal/evcloud/zaptec.go create mode 100644 go/internal/evcloud/zaptec_test.go diff --git a/.changeset/zaptec-cloud.md b/.changeset/zaptec-cloud.md new file mode 100644 index 000000000..eb5422663 --- /dev/null +++ b/.changeset/zaptec-cloud.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Zaptec Go, Go 2 and Pro can be added as a cloud EV charger. The setup wizard and Settings → EV offer Zaptec next to Easee; the same email and password list chargers on the account and drive current, pause and resume through Zaptec Cloud. The integration is experimental until a live charger has been exercised. diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 628e654a3..635eadb63 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -321,14 +321,14 @@ type V2XPolicy struct { // handler on POST /api/config. Providers that don't need auth (e.g. local // Modbus) leave Username + Password empty. type EVCharger struct { - Provider string `yaml:"provider" json:"provider"` // "easee" | "ctek" + Provider string `yaml:"provider" json:"provider"` // "easee" | "zaptec" | "ctek" // Connection — populate the block matching the provider's transport. HTTP *EVChargerHTTP `yaml:"http,omitempty" json:"http,omitempty"` Modbus *EVChargerModbus `yaml:"modbus,omitempty" json:"modbus,omitempty"` - // Optional auth — required by cloud HTTP providers like Easee, - // unused by local Modbus providers like CTEK. + // Optional auth — required by cloud HTTP providers like Easee and + // Zaptec, unused by local Modbus providers like CTEK. Username string `yaml:"username,omitempty" json:"username,omitempty"` Password string `yaml:"-" json:"password,omitempty"` // persisted in state.db, not YAML @@ -521,15 +521,15 @@ func (e *EVCharger) Validate() error { switch e.Provider { case "": return errors.New("ev_charger.provider: required") - case "easee": - // Username/Password are NOT enforced here. The runtime easee + case "easee", "zaptec": + // Username/Password are NOT enforced here. The runtime cloud // driver logs + idles when creds are missing, and the API picker - // requires both before calling Easee Cloud. Letting a partial + // requires both before calling the vendor cloud. Letting a partial // ev_charger block load is the original contract — the wizard // writes provider intent first, then captures creds in a second // API call. if e.Modbus != nil { - return errors.New("ev_charger.modbus: not valid for provider easee (HTTP transport)") + return fmt.Errorf("ev_charger.modbus: not valid for provider %s (HTTP transport)", e.Provider) } case "ctek": if e.Modbus == nil || e.Modbus.Host == "" { @@ -548,7 +548,7 @@ func (e *EVCharger) Validate() error { return errors.New("ev_charger: username/password not valid for provider ctek") } default: - return fmt.Errorf("ev_charger.provider %q: not supported (valid: easee, ctek)", e.Provider) + return fmt.Errorf("ev_charger.provider %q: not supported (valid: easee, zaptec, ctek)", e.Provider) } return nil } diff --git a/go/internal/config/evcharger_test.go b/go/internal/config/evcharger_test.go index acab818b4..167fa2c0f 100644 --- a/go/internal/config/evcharger_test.go +++ b/go/internal/config/evcharger_test.go @@ -84,6 +84,36 @@ func TestEVChargerValidateEasee(t *testing.T) { } } +func TestEVChargerValidateZaptec(t *testing.T) { + cases := []struct { + name string + e EVCharger + wantErr string + }{ + {"happy", EVCharger{Provider: "zaptec", Username: "u@x"}, ""}, + {"empty creds allowed (wizard placeholder)", EVCharger{Provider: "zaptec"}, ""}, + {"modbus block rejected", EVCharger{Provider: "zaptec", Username: "u@x", Modbus: &EVChargerModbus{Host: "h"}}, "modbus"}, + {"http block allowed", EVCharger{Provider: "zaptec", Username: "u@x", HTTP: &EVChargerHTTP{BaseURL: "https://staging"}}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.e.Validate() + if tc.wantErr == "" { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantErr) + } + }) + } +} + func TestEVChargerValidateCTek(t *testing.T) { cases := []struct { name string diff --git a/go/internal/drivers/testdata/zaptec_cloud.lua b/go/internal/drivers/testdata/zaptec_cloud.lua new file mode 100644 index 000000000..cfd1d09d5 --- /dev/null +++ b/go/internal/drivers/testdata/zaptec_cloud.lua @@ -0,0 +1,421 @@ +-- Zaptec Cloud EV charger driver (Go / Go 2 / Pro). +-- +-- Protocol: HTTPS against the Zaptec Cloud REST API (api.zaptec.com). +-- Canonical copy belongs in srcfl/device-drivers as zaptec_cloud.lua; +-- this testdata file is the in-tree source until that pin lands. Operators +-- can drop the same file in the user-drivers directory in the meantime. +-- +-- Auth: POST /oauth/token (OAuth2 password grant, form-encoded) +-- Telemetry: GET /api/chargers/{id}/state +-- Control: POST /api/chargers/{id}/update (current / phases) +-- POST /api/chargers/{id}/sendCommand/506 (pause) +-- POST /api/chargers/{id}/sendCommand/507 (resume; 528 = already running) +-- +-- Sign convention: positive W is charging (power into the vehicle / site load). +-- Minimum offered current is 6 A (IEC 61851); below that we pause. +-- +-- Config: +-- email / username Zaptec account email +-- password Zaptec account password (secret) +-- serial optional charger Id (UUID) or SerialNo +-- phases 1 or 3 (default 3) +-- min_a minimum current, default 6 +-- max_a maximum current, default 32 +-- base_url optional origin override (tests / staging) +-- +-- verification_status is experimental until a live charger has been +-- exercised end-to-end. + +DRIVER = { + host_api_min = 1, + host_api_max = 1, + id = "zaptec-cloud", + name = "Zaptec Cloud", + manufacturer = "Zaptec", + version = "0.1.0", + protocols = { "http" }, + capabilities = { "ev" }, + description = "Zaptec Go / Go 2 / Pro via Zaptec Cloud. Email + password; optional charger serial.", + homepage = "https://zaptec.com", + http_hosts = { "api.zaptec.com" }, + authors = { "FTW contributors" }, + tested_models = { "Go", "Go 2", "Pro" }, + verification_status = "experimental", + config_secrets = { "password" }, +} + +PROTOCOL = "http" + +local BASE_URL = "https://api.zaptec.com" + +-- Observation IDs from the Zaptec Cloud charger state document. +local OBS_VOLTAGE_L1 = 501 +local OBS_CURRENT_L1 = 507 +local OBS_MAX_CURRENT = 510 +local OBS_ACTIVE_PHASES = 512 +local OBS_CHARGE_POWER = 513 +local OBS_SESSION_ENERGY = 553 +local OBS_OP_MODE = 710 + +-- Operating modes (observation 710). +local OP_DISCONNECTED = 1 +local OP_CONNECTED = 2 +local OP_CHARGING = 3 +local OP_FINISHED = 5 + +-- sendCommand codes. +local CMD_STOP = 506 +local CMD_RESUME = 507 + +local email = nil +local password = nil +local serial_want = nil +local charger_id = nil +local phases = 3 +local min_a = 6 +local max_a = 32 +local access_token = nil +local refresh_token = nil +local token_expiry = 0 +local paused_state = false + +local function pick(t, ...) + if type(t) ~= "table" then return nil end + for i = 1, select("#", ...) do + local v = t[select(i, ...)] + if v ~= nil then return v end + end + return nil +end + +local function as_number(v) + if type(v) == "number" then return v end + if v == nil then return nil end + return tonumber(v) +end + +-- Keep HTTP error strings to a status prefix so a 4xx body that echoes +-- credentials or a bearer token never lands in the driver log. +local function redact_http_err(err) + if err == nil then return "request failed" end + local s = tostring(err) + return s:match("^(HTTP %d+)") or "request failed" +end + +local function url_encode(s) + s = tostring(s or "") + return (s:gsub("[^%w%-%.%_%~]", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +local function auth_headers() + return { Authorization = "Bearer " .. (access_token or "") } +end + +local function decode_json(raw) + if raw == nil or raw == "" then return nil, "empty body" end + local data, err = host.json_decode(raw) + if data == nil then return nil, tostring(err or "decode failed") end + return data, nil +end + +local function login() + local body = "grant_type=password" + .. "&username=" .. url_encode(email) + .. "&password=" .. url_encode(password) + local resp, err = host.http_post( + BASE_URL .. "/oauth/token", + body, + { ["Content-Type"] = "application/x-www-form-urlencoded" }) + if err then + return false, err + end + local data, derr = decode_json(resp) + if derr or not data then + return false, derr or "invalid JSON" + end + local token = pick(data, "access_token", "accessToken") + if not token or token == "" then + return false, "no access_token" + end + access_token = token + refresh_token = pick(data, "refresh_token", "refreshToken") or refresh_token + local expires_in = as_number(pick(data, "expires_in", "expiresIn")) or 3600 + token_expiry = host.millis() + (expires_in * 1000) - 60000 + host.log("info", "Zaptec: logged in") + return true +end + +local function refresh() + if not refresh_token or refresh_token == "" then return false end + local body = "grant_type=refresh_token" + .. "&refresh_token=" .. url_encode(refresh_token) + local resp, err = host.http_post( + BASE_URL .. "/oauth/token", + body, + { ["Content-Type"] = "application/x-www-form-urlencoded" }) + if err then + host.log("warn", "Zaptec token refresh failed: " .. redact_http_err(err)) + return false + end + local data, derr = decode_json(resp) + if derr or not data then return false end + local token = pick(data, "access_token", "accessToken") + if not token or token == "" then return false end + access_token = token + local rotated = pick(data, "refresh_token", "refreshToken") + if rotated and rotated ~= "" then refresh_token = rotated end + local expires_in = as_number(pick(data, "expires_in", "expiresIn")) or 3600 + token_expiry = host.millis() + (expires_in * 1000) - 60000 + return true +end + +local function ensure_auth() + if access_token and host.millis() < token_expiry then return true end + if refresh() then return true end + local ok = login() + return ok +end + +local function charger_rows(data) + if type(data) ~= "table" then return nil end + local rows = pick(data, "Data", "data") + if type(rows) == "table" then return rows end + if data[1] ~= nil then return data end + return nil +end + +local function charger_matches(ch, want) + if not want or want == "" then return true end + local id = tostring(pick(ch, "Id", "id") or "") + local serial = tostring(pick(ch, "SerialNo", "serialNo", "Serial") or "") + local device = tostring(pick(ch, "DeviceId", "deviceId") or "") + return id == want or serial == want or device == want +end + +local function resolve_charger() + local resp, err = host.http_get(BASE_URL .. "/api/chargers", auth_headers()) + if err then return nil, err end + local data, derr = decode_json(resp) + if derr then return nil, derr end + local rows = charger_rows(data) + if not rows or #rows == 0 then + return nil, "no chargers on account" + end + if serial_want and serial_want ~= "" then + for i = 1, #rows do + if charger_matches(rows[i], serial_want) then + return tostring(pick(rows[i], "Id", "id")), nil + end + end + return nil, "charger not found" + end + return tostring(pick(rows[1], "Id", "id")), nil +end + +local function observation_map(raw) + local data, err = decode_json(raw) + if err then return nil, err end + local rows = data + if type(data) == "table" and data[1] == nil then + rows = pick(data, "Data", "data") or data + end + local obs = {} + if type(rows) ~= "table" then return obs, nil end + for i = 1, #rows do + local item = rows[i] + local id = as_number(pick(item, "StateId", "stateId", "ObservationId", "observationId")) + if id then + obs[id] = pick(item, "ValueAsString", "valueAsString", "Value", "value") + end + end + return obs, nil +end + +local function send_command(code) + local url = BASE_URL .. "/api/chargers/" .. charger_id .. "/sendCommand/" .. tostring(code) + local _, err = host.http_post(url, "{}", auth_headers()) + if err then + -- 528: not paused, cannot resume — treat as success so a resume + -- issued while already running does not fail the command. + if code == CMD_RESUME and tostring(err):match("528") then + return true + end + host.log("warn", "Zaptec sendCommand " .. tostring(code) .. " failed: " .. redact_http_err(err)) + return false + end + return true +end + +local function update_charger(fields) + local body = host.json_encode(fields) + local _, err = host.http_post( + BASE_URL .. "/api/chargers/" .. charger_id .. "/update", + body, + auth_headers()) + if err then + host.log("warn", "Zaptec update failed: " .. redact_http_err(err)) + return false + end + return true +end + +local function watts_to_amps(power_w) + local p = phases + if p < 1 then p = 1 end + local amps = math.floor((tonumber(power_w) or 0) / (230 * p) + 0.5) + if amps < 0 then amps = 0 end + if amps > max_a then amps = max_a end + return amps +end + +function driver_init(config) + host.set_make("Zaptec") + config = config or {} + email = config.email or config.username + password = config.password + serial_want = config.serial + if serial_want == "" then serial_want = nil end + if email == "" then email = nil end + if password == "" then password = nil end + if config.base_url and tostring(config.base_url) ~= "" then + BASE_URL = tostring(config.base_url):gsub("/$", "") + end + local p = as_number(config.phases) + if p == 1 or p == 3 then phases = p end + local mn = as_number(config.min_a) + if mn and mn > 0 then min_a = mn end + local mx = as_number(config.max_a) + if mx and mx > 0 then max_a = mx end + + if not email or not password then + error("Zaptec: email/username and password required") + end + local ok, lerr = login() + if not ok then + error("Zaptec: initial login failed: " .. redact_http_err(lerr)) + end + local id, rerr = resolve_charger() + if not id then + error("Zaptec: could not list chargers: " .. redact_http_err(rerr)) + end + charger_id = id + host.set_sn(charger_id) + host.log("info", "Zaptec: driver initialized for " .. charger_id) +end + +function driver_poll() + if not charger_id or not email then + return 10000 + end + if not ensure_auth() then + host.log("warn", "Zaptec: auth failed, skipping poll") + return 10000 + end + local resp, err = host.http_get( + BASE_URL .. "/api/chargers/" .. charger_id .. "/state", + auth_headers()) + if err then + host.log("warn", "Zaptec: state poll failed: " .. redact_http_err(err)) + return 10000 + end + local obs, oerr = observation_map(resp) + if oerr or not obs then + host.log("warn", "Zaptec: state decode failed") + return 10000 + end + + local op_mode = as_number(obs[OBS_OP_MODE]) or OP_DISCONNECTED + local power_w = as_number(obs[OBS_CHARGE_POWER]) or 0 + if power_w < 0 then power_w = 0 end + local session_kwh = as_number(obs[OBS_SESSION_ENERGY]) or 0 + local session_wh = session_kwh * 1000 + local connected = (op_mode == OP_CONNECTED or op_mode == OP_CHARGING or op_mode == OP_FINISHED) + local charging = (op_mode == OP_CHARGING) + local max_current = as_number(obs[OBS_MAX_CURRENT]) + local active_phases = as_number(obs[OBS_ACTIVE_PHASES]) or phases + + host.emit("ev", { + w = power_w, + connected = connected, + charging = charging, + session_wh = session_wh, + max_a = max_current, + phases = active_phases, + op_mode = op_mode, + }) + + local i1 = as_number(obs[OBS_CURRENT_L1]) + if i1 then host.emit_metric("ev_l1_a", i1) end + local v1 = as_number(obs[OBS_VOLTAGE_L1]) + if v1 then host.emit_metric("ev_l1_v", v1) end + host.emit_metric("ev_power_w", power_w) + + return 5000 +end + +function driver_command(action, power_w, cmd) + if action == "init" or action == "deinit" then + return true + end + if not charger_id then + host.log("warn", "Zaptec: command before charger resolved") + return false + end + if not ensure_auth() then return false end + + if action == "ev_pause" then + local ok = send_command(CMD_STOP) + if ok then paused_state = true end + return ok + end + + if action == "ev_start" or action == "ev_resume" then + local ok = send_command(CMD_RESUME) + if ok then paused_state = false end + return ok + end + + if action == "ev_set_current" then + local amps = watts_to_amps(power_w) + if cmd and type(cmd) == "table" then + local req_phases = as_number(cmd.phases) + if req_phases == 1 or req_phases == 3 then + phases = req_phases + if not update_charger({ maxChargePhases = phases }) then + return false + end + end + end + if amps > 0 and amps < min_a then + amps = 0 + end + if amps <= 0 then + local ok = send_command(CMD_STOP) + if ok then paused_state = true end + return ok + end + if not update_charger({ maxChargeCurrent = amps }) then + return false + end + if paused_state then + if send_command(CMD_RESUME) then + paused_state = false + end + end + return true + end + + host.log("warn", "Zaptec: unknown action " .. tostring(action)) + return false +end + +function driver_default_mode() + -- Cloud charger keeps its last setpoint when FTW goes away. +end + +function driver_cleanup() + access_token = nil + refresh_token = nil +end diff --git a/go/internal/drivers/zaptec_cloud_test.go b/go/internal/drivers/zaptec_cloud_test.go new file mode 100644 index 000000000..325c90dc3 --- /dev/null +++ b/go/internal/drivers/zaptec_cloud_test.go @@ -0,0 +1,215 @@ +package drivers + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func zaptecCloudDriverPath(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(thisFile), "testdata", "zaptec_cloud.lua") +} + +func zaptecStateJSON(opMode int, powerW, sessionKWh float64) []byte { + type obs struct { + StateId int `json:"StateId"` + ValueAsString string `json:"ValueAsString"` + } + rows := []obs{ + {StateId: 710, ValueAsString: strconv.Itoa(opMode)}, + {StateId: 513, ValueAsString: strconv.FormatFloat(powerW, 'f', -1, 64)}, + {StateId: 553, ValueAsString: strconv.FormatFloat(sessionKWh, 'f', -1, 64)}, + {StateId: 510, ValueAsString: "16"}, + {StateId: 512, ValueAsString: "3"}, + {StateId: 507, ValueAsString: "10"}, + {StateId: 501, ValueAsString: "230"}, + } + b, _ := json.Marshal(rows) + return b +} + +type zaptecFake struct { + loginHits int + listHits int + stateHits int + updateHits int + commandHits []int + lastForm string + lastUpdate string + lastAuth string + loginCT string +} + +func (f *zaptecFake) handler(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/oauth/token" && r.Method == http.MethodPost: + f.loginHits++ + f.loginCT = r.Header.Get("Content-Type") + body, _ := io.ReadAll(r.Body) + f.lastForm = string(body) + if !strings.Contains(f.lastForm, "grant_type=password") || + !strings.Contains(f.lastForm, "username=user%40example.com") || + !strings.Contains(f.lastForm, "password=secret") { + http.Error(w, "bad creds", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "tok-xyz", + "expires_in": 3600, + }) + case r.URL.Path == "/api/chargers" && r.Method == http.MethodGet: + f.listHits++ + f.lastAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"Data":[{"Id":"chg-uuid-1","Name":"Garage","SerialNo":"ZAP123"}]}`)) + case strings.HasSuffix(r.URL.Path, "/state") && r.Method == http.MethodGet: + f.stateHits++ + _, _ = w.Write(zaptecStateJSON(3, 7360, 4.2)) + case strings.HasSuffix(r.URL.Path, "/update") && r.Method == http.MethodPost: + f.updateHits++ + body, _ := io.ReadAll(r.Body) + f.lastUpdate = string(body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + case strings.Contains(r.URL.Path, "/sendCommand/"): + idx := strings.LastIndex(r.URL.Path, "/") + code, _ := strconv.Atoi(r.URL.Path[idx+1:]) + f.commandHits = append(f.commandHits, code) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"Code":0}`)) + default: + http.Error(w, "unknown route "+r.URL.Path, http.StatusNotFound) + } +} + +func TestZaptecCloudInitPollAndCommands(t *testing.T) { + fake := &zaptecFake{} + srv := httptest.NewServer(http.HandlerFunc(fake.handler)) + defer srv.Close() + + tel := telemetry.NewStore() + env := NewHostEnv("zaptec", tel).WithHTTP() + d, err := NewLuaDriver(zaptecCloudDriverPath(t), env) + if err != nil { + t.Fatalf("load driver: %v", err) + } + defer d.Cleanup() + + cfg := map[string]any{ + "email": "user@example.com", + "password": "secret", + "base_url": srv.URL, + "phases": 3, + } + if err := d.Init(context.Background(), cfg); err != nil { + t.Fatalf("init: %v", err) + } + if fake.loginHits != 1 { + t.Fatalf("login hits=%d, want 1", fake.loginHits) + } + if !strings.HasPrefix(fake.loginCT, "application/x-www-form-urlencoded") { + t.Errorf("login Content-Type=%q, want form-urlencoded", fake.loginCT) + } + if fake.listHits != 1 { + t.Fatalf("list hits=%d, want 1 (auto-detect charger)", fake.listHits) + } + if fake.lastAuth != "Bearer tok-xyz" { + t.Errorf("list auth=%q, want Bearer tok-xyz", fake.lastAuth) + } + + if _, err := d.Poll(context.Background()); err != nil { + t.Fatalf("poll: %v", err) + } + reading := tel.Get("zaptec", telemetry.DerEV) + if reading == nil { + t.Fatal("no EV reading after poll") + } + if reading.RawW != 7360 { + t.Errorf("EV W=%v, want 7360 (charging, positive)", reading.RawW) + } + var extra map[string]any + if err := json.Unmarshal(reading.Data, &extra); err != nil { + t.Fatalf("decode EV data: %v", err) + } + if extra["connected"] != true { + t.Errorf("connected=%v, want true", extra["connected"]) + } + if extra["charging"] != true { + t.Errorf("charging=%v, want true", extra["charging"]) + } + if extra["session_wh"] != 4200.0 { + t.Errorf("session_wh=%v, want 4200 (4.2 kWh)", extra["session_wh"]) + } + + // 11040 W / (230 V × 3) = 16 A. + if err := d.Command(context.Background(), []byte(`{"action":"ev_set_current","power_w":11040}`)); err != nil { + t.Fatalf("ev_set_current: %v", err) + } + if fake.updateHits != 1 { + t.Fatalf("update hits=%d, want 1", fake.updateHits) + } + if !strings.Contains(fake.lastUpdate, "maxChargeCurrent") { + t.Errorf("update body=%q, want maxChargeCurrent", fake.lastUpdate) + } + if !strings.Contains(fake.lastUpdate, "16") { + t.Errorf("update body=%q, want 16 A", fake.lastUpdate) + } + + if err := d.Command(context.Background(), []byte(`{"action":"ev_pause"}`)); err != nil { + t.Fatalf("ev_pause: %v", err) + } + if err := d.Command(context.Background(), []byte(`{"action":"ev_resume"}`)); err != nil { + t.Fatalf("ev_resume: %v", err) + } + if len(fake.commandHits) < 2 { + t.Fatalf("sendCommand hits=%v, want pause 506 then resume 507", fake.commandHits) + } + if fake.commandHits[0] != 506 { + t.Errorf("first command=%d, want 506 (stop-final)", fake.commandHits[0]) + } + if fake.commandHits[1] != 507 { + t.Errorf("second command=%d, want 507 (resume)", fake.commandHits[1]) + } + + if err := d.DefaultMode(); err != nil { + t.Fatalf("default mode: %v", err) + } +} + +func TestZaptecCloudLoginDoesNotLeakPassword(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + http.Error(w, "invalid login: "+string(body), http.StatusUnauthorized) + })) + defer srv.Close() + + tel := telemetry.NewStore() + env := NewHostEnv("zaptec", tel).WithHTTP() + d, err := NewLuaDriver(zaptecCloudDriverPath(t), env) + if err != nil { + t.Fatalf("load driver: %v", err) + } + defer d.Cleanup() + + err = d.Init(context.Background(), map[string]any{ + "email": "user@example.com", + "password": "supersecret", + "base_url": srv.URL, + }) + if err != nil && strings.Contains(err.Error(), "supersecret") { + t.Errorf("password leaked into init error: %v", err) + } +} diff --git a/go/internal/evcloud/zaptec.go b/go/internal/evcloud/zaptec.go new file mode 100644 index 000000000..1dc1fc793 --- /dev/null +++ b/go/internal/evcloud/zaptec.go @@ -0,0 +1,161 @@ +package evcloud + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/srcfl/ftw/go/internal/config" +) + +// zaptecDefaultBaseURL is the Zaptec cloud API origin. Split out so +// tests can inject an httptest.Server URL via WithBaseURL. OAuth lives +// at {base}/oauth/token; chargers at {base}/api/chargers. +const zaptecDefaultBaseURL = "https://api.zaptec.com" + +// zaptecDefaultTimeout bounds every HTTP call so a stalled TCP +// connection to api.zaptec.com can't tie up the HTTP handler goroutine +// indefinitely. 15 s matches Easee and the Lua HTTP capability. +const zaptecDefaultTimeout = 15 * time.Second + +func init() { Register("zaptec", NewZaptec()) } + +// Zaptec implements Provider for the Zaptec Cloud API (Go, Go 2, Pro). +// The HTTP client and base URL are injectable so tests can point at an +// httptest.Server and production code can plug in a custom transport +// without touching this package. +type Zaptec struct { + client *http.Client + baseURL string +} + +// NewZaptec builds a Zaptec provider pointed at the production API with +// the standard 15 s timeout. +func NewZaptec() *Zaptec { + return &Zaptec{ + client: &http.Client{Timeout: zaptecDefaultTimeout}, + baseURL: zaptecDefaultBaseURL, + } +} + +// WithHTTPClient returns a copy of z using the supplied client. Intended +// for tests (inject a client whose Transport points at httptest.Server) +// and for wiring transports with custom round-trippers. +func (z *Zaptec) WithHTTPClient(c *http.Client) *Zaptec { + cp := *z + cp.client = c + return &cp +} + +// WithBaseURL returns a copy of z pointed at the given base URL (no +// trailing slash). Paired with WithHTTPClient for httptest wiring. +func (z *Zaptec) WithBaseURL(u string) *Zaptec { + cp := *z + cp.baseURL = u + return &cp +} + +// Describe is the wizard's hook for rendering a Zaptec-flavored form +// (HTTP transport, "Email" as the username label). +func (z *Zaptec) Describe() Descriptor { + return Descriptor{ + Name: "zaptec", + Label: "Zaptec", + Transport: TransportHTTP, + NeedsAuth: true, + UsernameLabel: "Email", + LuaDriver: "drivers/zaptec_cloud.lua", + } +} + +// ListChargers logs in with the credentials from cfg and returns the +// chargers on the account. cfg.HTTP.BaseURL overrides the default base +// URL when set, which is mostly useful for staging or self-hosted +// reverse proxies. +func (z *Zaptec) ListChargers(cfg *config.EVCharger) ([]Charger, error) { + if cfg == nil { + return nil, errors.New("zaptec: nil config") + } + if cfg.Username == "" { + return nil, errors.New("zaptec: username required") + } + if cfg.Password == "" { + return nil, errors.New("zaptec: password required") + } + client := z + if cfg.HTTP != nil && cfg.HTTP.BaseURL != "" { + client = z.WithBaseURL(cfg.HTTP.BaseURL) + } + token, err := client.login(cfg.Username, cfg.Password) + if err != nil { + return nil, err + } + return client.listChargers(token) +} + +func (z *Zaptec) login(email, password string) (string, error) { + form := url.Values{} + form.Set("grant_type", "password") + form.Set("username", email) + form.Set("password", password) + req, err := http.NewRequest("POST", z.baseURL+"/oauth/token", strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("login: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := z.client.Do(req) + if err != nil { + return "", fmt.Errorf("login request: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 400 { + // Status-only message — the body on a 4xx can echo the submitted + // credentials, and "invalid email or password" is the only + // actionable info we can surface anyway. + return "", fmt.Errorf("login: HTTP %d", resp.StatusCode) + } + var tok struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(raw, &tok); err != nil || tok.AccessToken == "" { + return "", fmt.Errorf("login: no token in response") + } + return tok.AccessToken, nil +} + +func (z *Zaptec) listChargers(token string) ([]Charger, error) { + req, err := http.NewRequest("GET", z.baseURL+"/api/chargers", nil) + if err != nil { + return nil, fmt.Errorf("chargers: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := z.client.Do(req) + if err != nil { + return nil, fmt.Errorf("chargers request: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("chargers: HTTP %d", resp.StatusCode) + } + var page struct { + Data []struct { + ID string `json:"Id"` + Name string `json:"Name"` + } `json:"Data"` + } + if err := json.Unmarshal(raw, &page); err != nil { + return nil, fmt.Errorf("chargers: decode: %w", err) + } + out := make([]Charger, len(page.Data)) + for i, ch := range page.Data { + out[i] = Charger{ID: ch.ID, Name: ch.Name} + } + return out, nil +} diff --git a/go/internal/evcloud/zaptec_test.go b/go/internal/evcloud/zaptec_test.go new file mode 100644 index 000000000..c9ea53a4d --- /dev/null +++ b/go/internal/evcloud/zaptec_test.go @@ -0,0 +1,155 @@ +package evcloud + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" +) + +// TestZaptecListChargers covers the happy path end-to-end against an +// httptest.Server. Demonstrates that the client + base URL injection +// actually works, and that the OAuth password grant is form-encoded. +func TestZaptecListChargers(t *testing.T) { + var loginHits, chargerHits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth/token": + loginHits++ + if r.Method != http.MethodPost { + t.Errorf("login method: got %s, want POST", r.Method) + } + ct := r.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "application/x-www-form-urlencoded") { + t.Errorf("login Content-Type: got %q, want form-urlencoded", ct) + } + body, _ := io.ReadAll(r.Body) + form := string(body) + if !strings.Contains(form, "grant_type=password") { + t.Errorf("login body missing grant_type=password: %q", form) + } + if !strings.Contains(form, "username=user%40example.com") && !strings.Contains(form, "username=user@example.com") { + t.Errorf("login body missing username: %q", form) + } + if !strings.Contains(form, "password=hunter2") { + t.Errorf("login body missing password: %q", form) + } + _ = json.NewEncoder(w).Encode(map[string]string{"access_token": "tok-abc"}) + case "/api/chargers": + chargerHits++ + if r.Header.Get("Authorization") != "Bearer tok-abc" { + http.Error(w, "missing bearer", http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"Data":[{"Id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","Name":"Garage"},{"Id":"ffffffff-0000-1111-2222-333333333333","Name":"Driveway"}]}`)) + default: + http.Error(w, "unknown route", http.StatusNotFound) + } + })) + defer srv.Close() + + z := NewZaptec().WithHTTPClient(srv.Client()).WithBaseURL(srv.URL) + got, err := z.ListChargers(&config.EVCharger{ + Provider: "zaptec", + Username: "user@example.com", + Password: "hunter2", + }) + if err != nil { + t.Fatalf("ListChargers: %v", err) + } + if len(got) != 2 { + t.Fatalf("chargers: got %d, want 2 — %+v", len(got), got) + } + if got[0].ID != "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" || got[0].Name != "Garage" { + t.Errorf("charger[0]: got %+v, want Garage UUID", got[0]) + } + if got[1].Name != "Driveway" { + t.Errorf("charger[1]: got %+v, want Driveway", got[1]) + } + if loginHits != 1 || chargerHits != 1 { + t.Errorf("hits: login=%d chargers=%d, want 1/1", loginHits, chargerHits) + } +} + +// TestZaptecLoginRejectsBadCreds verifies a 401 from /oauth/token +// surfaces as a status-only error. The submitted password must not +// leak into the error message even if the upstream echoes it. +func TestZaptecLoginRejectsBadCreds(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + http.Error(w, "invalid login: "+string(body), http.StatusUnauthorized) + })) + defer srv.Close() + + z := NewZaptec().WithHTTPClient(srv.Client()).WithBaseURL(srv.URL) + _, err := z.ListChargers(&config.EVCharger{ + Provider: "zaptec", + Username: "user@example.com", + Password: "supersecret", + }) + if err == nil { + t.Fatal("expected error") + } + if strings.Contains(err.Error(), "supersecret") { + t.Errorf("password leaked into error message: %v", err) + } + if !strings.Contains(err.Error(), "HTTP 401") { + t.Errorf("expected 'HTTP 401' in error, got: %v", err) + } +} + +// TestZaptecTimeoutBounded is the regression test for a hung cloud: +// a server that hangs forever must not wedge the caller. We configure +// a 200 ms client timeout and make sure the request returns an error +// within a generous ceiling. +func TestZaptecTimeoutBounded(t *testing.T) { + srv := httptest.NewServer(nil) + defer srv.Close() + block := make(chan struct{}) + defer close(block) + srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-block + }) + + client := &http.Client{Timeout: 200 * time.Millisecond} + z := NewZaptec().WithHTTPClient(client).WithBaseURL(srv.URL) + + done := make(chan error, 1) + go func() { + _, err := z.ListChargers(&config.EVCharger{ + Provider: "zaptec", + Username: "a@b", + Password: "c", + }) + done <- err + }() + select { + case err := <-done: + if err == nil { + t.Fatal("expected timeout error, got nil") + } + case <-time.After(2 * time.Second): + t.Fatal("ListChargers did not return within 2s despite 200ms client timeout") + } +} + +func TestZaptecDescribe(t *testing.T) { + d := NewZaptec().Describe() + if d.Name != "zaptec" || d.Label != "Zaptec" { + t.Errorf("name/label: %+v", d) + } + if d.Transport != TransportHTTP { + t.Errorf("transport: %s, want http", d.Transport) + } + if !d.NeedsAuth || d.UsernameLabel != "Email" { + t.Errorf("auth form: %+v", d) + } + if d.LuaDriver != "drivers/zaptec_cloud.lua" { + t.Errorf("lua driver hint: %q", d.LuaDriver) + } +} diff --git a/web/settings/tabs/ev.js b/web/settings/tabs/ev.js index 8ae11715e..07d8e31a6 100644 --- a/web/settings/tabs/ev.js +++ b/web/settings/tabs/ev.js @@ -12,15 +12,15 @@ var field = ctx.field, selectField = ctx.selectField, help = ctx.help; var getByPath = ctx.getByPath, config = ctx.config; if (!config.ev_charger) config.ev_charger = {}; - // If ev_charger is empty but an easee driver exists with config, - // populate the EV tab from the driver's config block so the UI - // reflects what's actually running. + // If ev_charger is empty but an Easee/Zaptec driver exists with + // config, populate the EV tab from the driver's config block so + // the UI reflects what's actually running. if (!config.ev_charger.email && config.drivers) { for (var di = 0; di < config.drivers.length; di++) { var drv = config.drivers[di]; - if (drv.name === "easee" && drv.config) { - config.ev_charger.provider = "easee"; - config.ev_charger.email = drv.config.email || ""; + if ((drv.name === "easee" || drv.name === "zaptec") && drv.config) { + config.ev_charger.provider = drv.name; + config.ev_charger.email = drv.config.email || drv.config.username || ""; config.ev_charger.password = drv.config.password || ""; config.ev_charger.serial = drv.config.serial || ""; break; @@ -33,8 +33,8 @@ : '⚠ No credentials saved'; return '
checking…
' + '
EV Charger' + - selectField("Provider", "ev_charger.provider", ["easee"], "easee", - "Cloud service provider for the EV charger. Currently only Easee is supported.") + + selectField("Provider", "ev_charger.provider", ["easee", "zaptec"], "easee", + "Cloud service for the EV charger. Easee and Zaptec authenticate with email and password.") + field("Email", "ev_charger.email", "text", "", "Account email for the charger cloud service.") + '' + @@ -44,7 +44,7 @@ "Serial number of the charger. Leave empty to auto-detect the first charger on the account.") + '
' + '

' + - 'Credentials are used to authenticate with the Easee Cloud API. ' + + 'Credentials authenticate with the charger cloud API (Easee or Zaptec). ' + 'The charger serial is optional — if left empty the driver will use the first charger found on your account.' + '

'; }, @@ -85,24 +85,25 @@ } }); } - var easee = null; + var charger = null; for (var i = 0; i < drivers.length; i++) { - if ((drivers[i].name || "").toLowerCase().indexOf("easee") >= 0) { - easee = drivers[i]; + var n = (drivers[i].name || "").toLowerCase(); + if (n.indexOf("easee") >= 0 || n.indexOf("zaptec") >= 0) { + charger = drivers[i]; break; } } - if (!easee) { + if (!charger) { el.className = "ha-status-indicator ha-off"; - el.textContent = "○ no Easee driver configured"; + el.textContent = "○ no cloud charger driver configured"; return; } - if (easee.status === "ok" || easee.status === "online") { + if (charger.status === "ok" || charger.status === "online") { el.className = "ha-status-indicator ha-ok"; - el.textContent = "● charger connected · " + (easee.device_id || easee.name); + el.textContent = "● charger connected · " + (charger.device_id || charger.name); } else { el.className = "ha-status-indicator ha-warn"; - el.textContent = "⚠ charger " + (easee.status || "unknown") + " — check credentials"; + el.textContent = "⚠ charger " + (charger.status || "unknown") + " — check credentials"; } }).catch(function () { el.className = "ha-status-indicator ha-warn"; diff --git a/web/setup.js b/web/setup.js index 6e65dbe20..7cbcc5bd7 100644 --- a/web/setup.js +++ b/web/setup.js @@ -582,11 +582,13 @@ // Known EV charger providers, keyed by the `provider` string the Go // config (EVCharger.Provider) accepts. `transport` selects which field // block (#ev-fields-http vs #ev-fields-modbus) the wizard reveals: - // - easee: cloud HTTP, needs username/password + serial lookup. - // - ctek: local Modbus/TCP, needs host/port/unit, no auth. + // - easee: cloud HTTP, needs username/password + serial lookup. + // - zaptec: cloud HTTP, needs username/password + serial lookup. + // - ctek: local Modbus/TCP, needs host/port/unit, no auth. // Mirrors go/internal/config/config.go EVCharger.Validate. var EV_PROVIDERS = [ { value: 'easee', label: 'Easee', transport: 'http' }, + { value: 'zaptec', label: 'Zaptec', transport: 'http' }, { value: 'ctek', label: 'CTEK', transport: 'modbus' } ]; @@ -854,8 +856,8 @@ // EV Charger — shape the block to match the provider's transport // (see go/internal/config/config.go EVCharger). Cloud HTTP providers - // (easee) carry username/password/serial; local Modbus providers - // (ctek) carry a modbus{host,port,unit_id} block and reject auth. + // (easee, zaptec) carry username/password/serial; local Modbus + // providers (ctek) carry a modbus{host,port,unit_id} block and reject auth. var evProvider = document.getElementById('ev-provider').value; if (evProvider) { var ev = { provider: evProvider }; diff --git a/web/setup.test.mjs b/web/setup.test.mjs index a697be478..3752fef87 100644 --- a/web/setup.test.mjs +++ b/web/setup.test.mjs @@ -43,6 +43,8 @@ describe("setup wizard EV charger — provider options (Job 1)", () => { "a provider table must drive the #ev-provider options"); assert.match(JS, /value:\s*['"]easee['"]/, "Easee (the cloud HTTP provider) must be selectable"); + assert.match(JS, /value:\s*['"]zaptec['"]/, + "Zaptec (the cloud HTTP provider) must be selectable"); assert.match(JS, /value:\s*['"]ctek['"]/, "CTEK (the local Modbus provider) must be selectable"); assert.match(JS, /populateEVProviders/,