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 '
' + - '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/,